use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::state::{
ActionKind, ActionSchemaPayload, CognitiveAttrs, CognitiveEdge, CognitiveEdgeKind,
CognitiveNode, ConstraintPayload, ConstraintType, Effect, GoalPayload, GoalStatus, NodeId,
NodeKind, NodePayload, Precondition, Priority, TaskPayload, TaskStatus,
};
pub type PlanStepId = u32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub goal_id: NodeId,
pub goal_description: String,
pub steps: Vec<PlanStep>,
pub score: PlanScore,
pub rationale: String,
pub created_at: f64,
pub viable: bool,
pub blockers: Vec<Blocker>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStep {
pub ordinal: u32,
pub schema_name: String,
pub action_kind: ActionKind,
pub description: String,
pub schema_node: Option<NodeId>,
pub preconditions: Vec<BoundPrecondition>,
pub expected_effects: Vec<Effect>,
pub estimated_duration_secs: f64,
pub derivation: StepDerivation,
pub feasibility: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundPrecondition {
pub description: String,
pub required: bool,
pub satisfied: bool,
pub bound_node: Option<NodeId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StepDerivation {
DirectMatch,
BackwardChain { enables_step: u32 },
SkillDecomposition { skill_id: u64 },
TaskPrerequisite { task_node: NodeId },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanScore {
pub feasibility: f64,
pub expected_utility: f64,
pub simplicity: f64,
pub schema_success_rate: f64,
pub urgency: f64,
pub composite: f64,
pub estimated_total_secs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Blocker {
pub step_ordinal: Option<u32>,
pub description: String,
pub severity: f64,
pub kind: BlockerKind,
pub resolution: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BlockerKind {
UnsatisfiedPrecondition,
ConstraintViolation,
PrerequisiteIncomplete,
GoalInactive,
DeadlinePressure,
LowConfidenceSchema,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannerConfig {
pub max_depth: u32,
pub beam_width: usize,
pub top_k: usize,
pub min_feasibility: f64,
pub min_schema_success_rate: f64,
pub weight_feasibility: f64,
pub weight_utility: f64,
pub weight_simplicity: f64,
pub weight_success_rate: f64,
pub weight_urgency: f64,
pub default_step_duration_secs: f64,
}
impl Default for PlannerConfig {
fn default() -> Self {
Self {
max_depth: 4,
beam_width: 6,
top_k: 3,
min_feasibility: 0.3,
min_schema_success_rate: 0.2,
weight_feasibility: 0.30,
weight_utility: 0.25,
weight_simplicity: 0.15,
weight_success_rate: 0.20,
weight_urgency: 0.10,
default_step_duration_secs: 30.0,
}
}
}
pub struct PlanningContext<'a> {
pub schemas: &'a [SchemaEntry],
pub goals: &'a [GoalEntry],
pub tasks: &'a [TaskEntry],
pub constraints: &'a [ConstraintEntry],
pub edges: &'a [CognitiveEdge],
pub skills: &'a [SkillTemplate],
pub now: f64,
pub config: &'a PlannerConfig,
}
#[derive(Debug, Clone)]
pub struct SchemaEntry {
pub node_id: NodeId,
pub attrs: CognitiveAttrs,
pub payload: ActionSchemaPayload,
}
#[derive(Debug, Clone)]
pub struct GoalEntry {
pub node_id: NodeId,
pub attrs: CognitiveAttrs,
pub payload: GoalPayload,
}
#[derive(Debug, Clone)]
pub struct TaskEntry {
pub node_id: NodeId,
pub attrs: CognitiveAttrs,
pub payload: TaskPayload,
}
#[derive(Debug, Clone)]
pub struct ConstraintEntry {
pub node_id: NodeId,
pub payload: ConstraintPayload,
}
#[derive(Debug, Clone)]
pub struct SkillTemplate {
pub skill_id: u64,
pub description: String,
pub steps: Vec<SkillStepInfo>,
pub confidence: f64,
pub success_rate: f64,
}
#[derive(Debug, Clone)]
pub struct SkillStepInfo {
pub ordinal: u16,
pub action_kind: String,
pub description: String,
pub expected_duration_ms: u64,
pub optional: bool,
}
#[derive(Debug, Clone)]
pub struct PlanProposal {
pub plans: Vec<Plan>,
pub rejected_schemas: Vec<RejectedSchema>,
pub global_blockers: Vec<Blocker>,
pub candidates_evaluated: usize,
}
#[derive(Debug, Clone)]
pub struct RejectedSchema {
pub schema_name: String,
pub reason: String,
}
pub fn instantiate_plan(
goal_id: NodeId,
ctx: &PlanningContext,
) -> PlanProposal {
let config = ctx.config;
let goal = match ctx.goals.iter().find(|g| g.node_id == goal_id) {
Some(g) => g,
None => {
return PlanProposal {
plans: Vec::new(),
rejected_schemas: Vec::new(),
global_blockers: vec![Blocker {
step_ordinal: None,
description: "Goal not found in active goals".to_string(),
severity: 1.0,
kind: BlockerKind::GoalInactive,
resolution: None,
}],
candidates_evaluated: 0,
};
}
};
let mut global_blockers = Vec::new();
if goal.payload.status != GoalStatus::Active {
global_blockers.push(Blocker {
step_ordinal: None,
description: format!("Goal status is {:?}, not Active", goal.payload.status),
severity: 1.0,
kind: BlockerKind::GoalInactive,
resolution: Some("Reactivate the goal first".to_string()),
});
return PlanProposal {
plans: Vec::new(),
rejected_schemas: Vec::new(),
global_blockers,
candidates_evaluated: 0,
};
}
if let Some(deadline) = goal.payload.deadline {
if deadline < ctx.now {
global_blockers.push(Blocker {
step_ordinal: None,
description: "Goal deadline has passed".to_string(),
severity: 0.8,
kind: BlockerKind::DeadlinePressure,
resolution: Some("Extend the deadline or abandon the goal".to_string()),
});
}
}
let advancing_schemas = find_advancing_schemas(goal_id, ctx);
let mut rejected_schemas = Vec::new();
let mut candidate_plans: Vec<Plan> = Vec::new();
for se in &advancing_schemas {
if se.payload.success_rate < config.min_schema_success_rate {
rejected_schemas.push(RejectedSchema {
schema_name: se.payload.name.clone(),
reason: format!(
"Success rate {:.0}% below minimum {:.0}%",
se.payload.success_rate * 100.0,
config.min_schema_success_rate * 100.0,
),
});
continue;
}
let (step, blockers) = build_plan_step(se, 0, StepDerivation::DirectMatch, ctx);
if step.feasibility >= config.min_feasibility {
let plan = build_plan(goal, vec![step], blockers, ctx);
candidate_plans.push(plan);
}
}
for skill in ctx.skills {
if skill.confidence < 0.4 {
continue;
}
if let Some(plan) = decompose_via_skill(goal, skill, ctx) {
candidate_plans.push(plan);
}
}
let goal_tasks = find_tasks_for_goal(goal_id, ctx);
if !goal_tasks.is_empty() {
if let Some(plan) = build_task_chain_plan(goal, &goal_tasks, ctx) {
candidate_plans.push(plan);
}
}
if candidate_plans.len() < config.beam_width && config.max_depth > 1 {
let bc_plans = backward_chain(goal, &advancing_schemas, ctx, config.max_depth);
candidate_plans.extend(bc_plans);
}
let candidates_evaluated = candidate_plans.len();
candidate_plans.sort_by(|a, b| {
b.score
.composite
.partial_cmp(&a.score.composite)
.unwrap_or(std::cmp::Ordering::Equal)
});
candidate_plans.truncate(config.top_k);
for plan in &mut candidate_plans {
let constraint_blockers = check_constraints(plan, ctx);
plan.blockers.extend(constraint_blockers);
plan.viable = plan
.blockers
.iter()
.all(|b| b.severity < 0.9);
}
PlanProposal {
plans: candidate_plans,
rejected_schemas,
global_blockers,
candidates_evaluated,
}
}
pub fn next_plan_step(
goal_id: NodeId,
ctx: &PlanningContext,
) -> Option<PlanStep> {
let proposal = instantiate_plan(goal_id, ctx);
let best_plan = proposal.plans.into_iter().find(|p| p.viable)?;
best_plan
.steps
.into_iter()
.find(|step| {
!best_plan.blockers.iter().any(|b| {
b.step_ordinal == Some(step.ordinal) && b.severity >= 0.9
})
})
}
pub fn evaluate_plan(plan: &Plan, config: &PlannerConfig) -> PlanScore {
score_plan(&plan.steps, config)
}
pub fn detect_blockers(
goal_id: NodeId,
ctx: &PlanningContext,
) -> Vec<Blocker> {
let proposal = instantiate_plan(goal_id, ctx);
let mut all_blockers = proposal.global_blockers;
for plan in &proposal.plans {
for b in &plan.blockers {
if !all_blockers.iter().any(|existing| existing.description == b.description) {
all_blockers.push(b.clone());
}
}
}
all_blockers
}
fn find_advancing_schemas<'a>(
goal_id: NodeId,
ctx: &'a PlanningContext,
) -> Vec<&'a SchemaEntry> {
let mut result = Vec::new();
let advancing_ids: HashSet<NodeId> = ctx
.edges
.iter()
.filter(|e| {
e.dst == goal_id
&& (e.kind == CognitiveEdgeKind::AdvancesGoal
|| e.kind == CognitiveEdgeKind::Causes)
})
.map(|e| e.src)
.collect();
for se in ctx.schemas {
if advancing_ids.contains(&se.node_id) {
result.push(se);
}
}
let existing: HashSet<NodeId> = result.iter().map(|s| s.node_id).collect();
for se in ctx.schemas {
if existing.contains(&se.node_id) {
continue;
}
if se.attrs.activation >= 0.5
&& !se.payload.effects.is_empty()
&& se.payload.effects.iter().any(|e| e.utility > 0.0)
{
result.push(se);
}
}
result
}
fn find_tasks_for_goal<'a>(
goal_id: NodeId,
ctx: &'a PlanningContext,
) -> Vec<&'a TaskEntry> {
ctx.tasks
.iter()
.filter(|t| t.payload.goal_id == Some(goal_id))
.collect()
}
fn build_plan_step(
schema: &SchemaEntry,
ordinal: u32,
derivation: StepDerivation,
ctx: &PlanningContext,
) -> (PlanStep, Vec<Blocker>) {
let mut bound_preconds = Vec::new();
let mut blockers = Vec::new();
let mut satisfied_required = 0usize;
let mut total_required = 0usize;
for pc in &schema.payload.preconditions {
let (satisfied, bound_node) = check_precondition(pc, ctx);
if pc.required {
total_required += 1;
if satisfied {
satisfied_required += 1;
} else {
blockers.push(Blocker {
step_ordinal: Some(ordinal),
description: format!(
"Required precondition not met: {}",
pc.description
),
severity: 0.8,
kind: BlockerKind::UnsatisfiedPrecondition,
resolution: Some(format!("Satisfy: {}", pc.description)),
});
}
}
bound_preconds.push(BoundPrecondition {
description: pc.description.clone(),
required: pc.required,
satisfied,
bound_node,
});
}
let feasibility = if total_required == 0 {
schema.payload.success_rate.max(0.5)
} else {
let precond_ratio = satisfied_required as f64 / total_required as f64;
precond_ratio * schema.payload.success_rate.max(0.1)
};
let estimated_secs = if schema.payload.execution_count > 0 {
ctx.config.default_step_duration_secs
} else {
ctx.config.default_step_duration_secs
};
let step = PlanStep {
ordinal,
schema_name: schema.payload.name.clone(),
action_kind: schema.payload.action_kind,
description: schema.payload.description.clone(),
schema_node: Some(schema.node_id),
preconditions: bound_preconds,
expected_effects: schema.payload.effects.clone(),
estimated_duration_secs: estimated_secs,
derivation,
feasibility,
};
(step, blockers)
}
fn check_precondition(
precondition: &Precondition,
ctx: &PlanningContext,
) -> (bool, Option<NodeId>) {
if let Some(node_ref) = precondition.node_ref {
if let Some(goal) = ctx.goals.iter().find(|g| g.node_id == node_ref) {
let satisfied = goal.payload.status == GoalStatus::Completed;
return (satisfied, Some(node_ref));
}
if let Some(task) = ctx.tasks.iter().find(|t| t.node_id == node_ref) {
let satisfied = task.payload.status == TaskStatus::Completed;
return (satisfied, Some(node_ref));
}
let has_support = ctx.edges.iter().any(|e| {
(e.src == node_ref || e.dst == node_ref)
&& e.confidence > 0.5
});
return (has_support, Some(node_ref));
}
(!precondition.required, None)
}
fn decompose_via_skill(
goal: &GoalEntry,
skill: &SkillTemplate,
ctx: &PlanningContext,
) -> Option<Plan> {
if skill.steps.is_empty() {
return None;
}
let mut plan_steps = Vec::new();
let mut blockers = Vec::new();
for (i, skill_step) in skill.steps.iter().enumerate() {
if skill_step.optional {
continue; }
let matching_schema = ctx.schemas.iter().find(|s| {
s.payload.name.to_lowercase().contains(&skill_step.action_kind.to_lowercase())
|| format!("{:?}", s.payload.action_kind)
.to_lowercase()
.contains(&skill_step.action_kind.to_lowercase())
});
let step = if let Some(schema) = matching_schema {
let (mut step, step_blockers) = build_plan_step(
schema,
i as u32,
StepDerivation::SkillDecomposition {
skill_id: skill.skill_id,
},
ctx,
);
step.description = skill_step.description.clone();
step.estimated_duration_secs = skill_step.expected_duration_ms as f64 / 1000.0;
blockers.extend(step_blockers);
step
} else {
PlanStep {
ordinal: i as u32,
schema_name: skill_step.action_kind.clone(),
action_kind: ActionKind::Execute,
description: skill_step.description.clone(),
schema_node: None,
preconditions: Vec::new(),
expected_effects: Vec::new(),
estimated_duration_secs: skill_step.expected_duration_ms as f64 / 1000.0,
derivation: StepDerivation::SkillDecomposition {
skill_id: skill.skill_id,
},
feasibility: skill.success_rate * 0.8,
}
};
plan_steps.push(step);
}
if plan_steps.is_empty() {
return None;
}
Some(build_plan(goal, plan_steps, blockers, ctx))
}
fn build_task_chain_plan(
goal: &GoalEntry,
tasks: &[&TaskEntry],
ctx: &PlanningContext,
) -> Option<Plan> {
let ordered = topological_sort_tasks(tasks);
if ordered.is_empty() {
return None;
}
let mut plan_steps = Vec::new();
let mut blockers = Vec::new();
for (i, task) in ordered.iter().enumerate() {
if task.payload.status == TaskStatus::Completed {
continue; }
for prereq_id in &task.payload.prerequisites {
let prereq_done = tasks
.iter()
.find(|t| t.node_id == *prereq_id)
.map(|t| t.payload.status == TaskStatus::Completed)
.unwrap_or(false);
if !prereq_done {
blockers.push(Blocker {
step_ordinal: Some(i as u32),
description: format!(
"Prerequisite task {:?} not completed",
prereq_id
),
severity: 0.7,
kind: BlockerKind::PrerequisiteIncomplete,
resolution: Some("Complete prerequisite task first".to_string()),
});
}
}
let matching_schema = ctx.schemas.iter().find(|s| {
s.payload.description.to_lowercase().contains(
&task.payload.description.to_lowercase().split_whitespace().next().unwrap_or(""),
)
});
let step = if let Some(schema) = matching_schema {
let (mut step, step_blockers) = build_plan_step(
schema,
i as u32,
StepDerivation::TaskPrerequisite {
task_node: task.node_id,
},
ctx,
);
step.description = task.payload.description.clone();
if let Some(mins) = task.payload.estimated_minutes {
step.estimated_duration_secs = mins as f64 * 60.0;
}
blockers.extend(step_blockers);
step
} else {
let feasibility = if task.payload.status == TaskStatus::InProgress {
0.8
} else {
0.6
};
PlanStep {
ordinal: i as u32,
schema_name: "task_execution".to_string(),
action_kind: ActionKind::Execute,
description: task.payload.description.clone(),
schema_node: None,
preconditions: Vec::new(),
expected_effects: Vec::new(),
estimated_duration_secs: task
.payload
.estimated_minutes
.map(|m| m as f64 * 60.0)
.unwrap_or(ctx.config.default_step_duration_secs),
derivation: StepDerivation::TaskPrerequisite {
task_node: task.node_id,
},
feasibility,
}
};
plan_steps.push(step);
}
if plan_steps.is_empty() {
return None;
}
Some(build_plan(goal, plan_steps, blockers, ctx))
}
fn topological_sort_tasks<'a>(tasks: &[&'a TaskEntry]) -> Vec<&'a TaskEntry> {
let task_ids: HashSet<NodeId> = tasks.iter().map(|t| t.node_id).collect();
let mut in_degree: HashMap<NodeId, usize> = HashMap::new();
let mut dependents: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for task in tasks {
in_degree.entry(task.node_id).or_insert(0);
for prereq in &task.payload.prerequisites {
if task_ids.contains(prereq) {
*in_degree.entry(task.node_id).or_insert(0) += 1;
dependents.entry(*prereq).or_default().push(task.node_id);
}
}
}
let mut queue: Vec<NodeId> = in_degree
.iter()
.filter(|(_, &d)| d == 0)
.map(|(&id, _)| id)
.collect();
let mut result = Vec::new();
while let Some(id) = queue.pop() {
if let Some(task) = tasks.iter().find(|t| t.node_id == id) {
result.push(*task);
}
if let Some(deps) = dependents.get(&id) {
for dep_id in deps {
if let Some(degree) = in_degree.get_mut(dep_id) {
*degree = degree.saturating_sub(1);
if *degree == 0 {
queue.push(*dep_id);
}
}
}
}
}
result
}
fn backward_chain(
goal: &GoalEntry,
initial_schemas: &[&SchemaEntry],
ctx: &PlanningContext,
max_depth: u32,
) -> Vec<Plan> {
let mut plans = Vec::new();
for schema in initial_schemas {
let (final_step, mut blockers) =
build_plan_step(schema, 0, StepDerivation::DirectMatch, ctx);
let unmet: Vec<&BoundPrecondition> = final_step
.preconditions
.iter()
.filter(|p| p.required && !p.satisfied)
.collect();
if unmet.is_empty() {
continue; }
let mut prefix_steps: Vec<PlanStep> = Vec::new();
let mut depth = 1u32;
for (i, unmet_precond) in unmet.iter().enumerate() {
if depth >= max_depth {
break;
}
let enabler = find_enabling_schema(unmet_precond, ctx);
if let Some(enabler_schema) = enabler {
let (step, step_blockers) = build_plan_step(
enabler_schema,
i as u32,
StepDerivation::BackwardChain {
enables_step: final_step.ordinal,
},
ctx,
);
blockers.extend(step_blockers);
prefix_steps.push(step);
depth += 1;
}
}
if !prefix_steps.is_empty() {
for (j, step) in prefix_steps.iter_mut().enumerate() {
step.ordinal = j as u32;
}
let mut all_steps = prefix_steps;
let mut final_step = final_step;
final_step.ordinal = all_steps.len() as u32;
all_steps.push(final_step);
let plan = build_plan(goal, all_steps, blockers, ctx);
plans.push(plan);
}
}
plans
}
fn find_enabling_schema<'a>(
precondition: &BoundPrecondition,
ctx: &'a PlanningContext,
) -> Option<&'a SchemaEntry> {
let desc_lower = precondition.description.to_lowercase();
ctx.schemas.iter().find(|s| {
s.payload.effects.iter().any(|effect| {
let effect_lower = effect.description.to_lowercase();
let precond_words: HashSet<&str> = desc_lower.split_whitespace().collect();
let effect_words: HashSet<&str> = effect_lower.split_whitespace().collect();
let overlap = precond_words.intersection(&effect_words).count();
overlap >= 2 || effect_lower.contains(&desc_lower)
})
})
}
fn score_plan(steps: &[PlanStep], config: &PlannerConfig) -> PlanScore {
if steps.is_empty() {
return PlanScore {
feasibility: 0.0,
expected_utility: 0.0,
simplicity: 1.0,
schema_success_rate: 0.0,
urgency: 0.0,
composite: 0.0,
estimated_total_secs: 0.0,
};
}
let feasibility = steps
.iter()
.map(|s| s.feasibility)
.fold(1.0, |acc, f| acc * f);
let expected_utility = steps
.iter()
.flat_map(|s| s.expected_effects.iter())
.map(|e| e.utility * e.probability)
.fold(0.0f64, f64::max);
let simplicity = 1.0 / (1.0 + (steps.len() as f64 - 1.0) * 0.3);
let success_rates: Vec<f64> = steps
.iter()
.filter(|s| s.schema_node.is_some())
.map(|s| s.feasibility.max(0.1))
.collect();
let schema_success_rate = if success_rates.is_empty() {
0.5
} else {
let product: f64 = success_rates.iter().product();
product.powf(1.0 / success_rates.len() as f64)
};
let estimated_total_secs: f64 = steps.iter().map(|s| s.estimated_duration_secs).sum();
PlanScore {
feasibility,
expected_utility,
simplicity,
schema_success_rate,
urgency: 0.0, composite: 0.0, estimated_total_secs,
}
}
fn build_plan(
goal: &GoalEntry,
steps: Vec<PlanStep>,
blockers: Vec<Blocker>,
ctx: &PlanningContext,
) -> Plan {
let config = ctx.config;
let mut score = score_plan(&steps, config);
score.urgency = goal.attrs.urgency;
score.composite = config.weight_feasibility * score.feasibility
+ config.weight_utility * score.expected_utility.max(0.0)
+ config.weight_simplicity * score.simplicity
+ config.weight_success_rate * score.schema_success_rate
+ config.weight_urgency * score.urgency;
let viable = blockers.iter().all(|b| b.severity < 0.9);
let rationale = if steps.len() == 1 {
format!(
"Direct action: {} (feasibility={:.0}%)",
steps[0].schema_name,
score.feasibility * 100.0,
)
} else {
format!(
"{}-step plan via {} (feasibility={:.0}%, utility={:.2})",
steps.len(),
steps
.iter()
.map(|s| s.schema_name.as_str())
.collect::<Vec<_>>()
.join(" → "),
score.feasibility * 100.0,
score.expected_utility,
)
};
Plan {
goal_id: goal.node_id,
goal_description: goal.payload.description.clone(),
steps,
score,
rationale,
created_at: ctx.now,
viable,
blockers,
}
}
fn check_constraints(plan: &Plan, ctx: &PlanningContext) -> Vec<Blocker> {
let mut blockers = Vec::new();
for constraint in ctx.constraints {
for step in &plan.steps {
let condition_lower = constraint.payload.condition.to_lowercase();
let step_desc_lower = step.description.to_lowercase();
let schema_lower = step.schema_name.to_lowercase();
let matches = condition_lower
.split_whitespace()
.any(|word| step_desc_lower.contains(word) || schema_lower.contains(word));
if matches {
let severity = match constraint.payload.constraint_type {
ConstraintType::Hard => 0.95,
ConstraintType::Soft => 0.5,
};
blockers.push(Blocker {
step_ordinal: Some(step.ordinal),
description: format!(
"Constraint violated: {}",
constraint.payload.description
),
severity,
kind: BlockerKind::ConstraintViolation,
resolution: Some(format!(
"Imposed by: {}. Condition: {}",
constraint.payload.imposed_by,
constraint.payload.condition
)),
});
}
}
}
blockers
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStore {
plans: HashMap<u32, Plan>,
pub total_generated: u64,
pub total_succeeded: u64,
pub total_failed: u64,
}
impl PlanStore {
pub fn new() -> Self {
Self {
plans: HashMap::new(),
total_generated: 0,
total_succeeded: 0,
total_failed: 0,
}
}
pub fn get_plan(&self, goal_id: NodeId) -> Option<&Plan> {
self.plans.get(&goal_id.to_raw())
}
pub fn set_plan(&mut self, plan: Plan) {
self.total_generated += 1;
self.plans.insert(plan.goal_id.to_raw(), plan);
}
pub fn mark_succeeded(&mut self, goal_id: NodeId) -> Option<Plan> {
self.total_succeeded += 1;
self.plans.remove(&goal_id.to_raw())
}
pub fn mark_failed(&mut self, goal_id: NodeId) -> Option<Plan> {
self.total_failed += 1;
self.plans.remove(&goal_id.to_raw())
}
pub fn remove_plan(&mut self, goal_id: NodeId) -> Option<Plan> {
self.plans.remove(&goal_id.to_raw())
}
pub fn active_plans(&self) -> Vec<&Plan> {
self.plans.values().collect()
}
pub fn active_count(&self) -> usize {
self.plans.len()
}
pub fn prune_inactive_goals(&mut self, active_goal_ids: &HashSet<u32>) -> usize {
let before = self.plans.len();
self.plans.retain(|gid, _| active_goal_ids.contains(gid));
before - self.plans.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{
ActionKind, ActionSchemaPayload, CognitiveAttrs, CognitiveEdge, CognitiveEdgeKind,
ConstraintPayload, ConstraintType, Effect, GoalPayload, GoalStatus, NodeId, NodeKind,
Precondition, Priority, Provenance, TaskPayload, TaskStatus,
};
fn default_attrs() -> CognitiveAttrs {
CognitiveAttrs {
confidence: 0.8,
activation: 0.6,
salience: 0.5,
persistence: 0.7,
valence: 0.3,
urgency: 0.5,
novelty: 0.2,
last_updated_ms: 1000000,
volatility: 0.1,
provenance: Provenance::Inferred,
evidence_count: 5,
}
}
fn test_goal(id: u32) -> GoalEntry {
GoalEntry {
node_id: NodeId::new(NodeKind::Goal, id),
attrs: default_attrs(),
payload: GoalPayload {
description: "Test goal".to_string(),
status: GoalStatus::Active,
progress: 0.0,
deadline: None,
priority: Priority::Medium,
parent_goal: None,
completion_criteria: "Goal is achieved".to_string(),
},
}
}
fn test_schema(id: u32, name: &str) -> SchemaEntry {
SchemaEntry {
node_id: NodeId::new(NodeKind::ActionSchema, id),
attrs: default_attrs(),
payload: ActionSchemaPayload {
name: name.to_string(),
description: format!("Schema: {}", name),
action_kind: ActionKind::Execute,
preconditions: Vec::new(),
effects: vec![Effect {
description: "Positive outcome".to_string(),
probability: 0.8,
utility: 0.6,
}],
confidence_threshold: 0.5,
success_rate: 0.8,
execution_count: 10,
acceptance_count: 8,
},
}
}
fn test_config() -> PlannerConfig {
PlannerConfig::default()
}
fn edge(src: NodeId, dst: NodeId, kind: CognitiveEdgeKind) -> CognitiveEdge {
CognitiveEdge {
src,
dst,
kind,
weight: 0.8,
created_at_ms: 1000000,
last_confirmed_ms: 2000000,
observation_count: 5,
confidence: 0.7,
}
}
#[test]
fn test_empty_plan() {
let config = test_config();
let goal = test_goal(1);
let ctx = PlanningContext {
schemas: &[],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &[],
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
assert!(proposal.plans.is_empty());
}
#[test]
fn test_goal_not_found() {
let config = test_config();
let ctx = PlanningContext {
schemas: &[],
goals: &[],
tasks: &[],
constraints: &[],
edges: &[],
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(NodeId::new(NodeKind::Goal, 99), &ctx);
assert!(proposal.plans.is_empty());
assert!(!proposal.global_blockers.is_empty());
}
#[test]
fn test_inactive_goal() {
let config = test_config();
let mut goal = test_goal(1);
goal.payload.status = GoalStatus::Completed;
let ctx = PlanningContext {
schemas: &[],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &[],
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
assert!(proposal.plans.is_empty());
assert!(proposal.global_blockers.iter().any(|b| {
matches!(b.kind, BlockerKind::GoalInactive)
}));
}
#[test]
fn test_direct_plan_with_advancing_schema() {
let config = test_config();
let goal = test_goal(1);
let schema = test_schema(1, "achieve_goal");
let edges = vec![edge(
schema.node_id,
goal.node_id,
CognitiveEdgeKind::AdvancesGoal,
)];
let ctx = PlanningContext {
schemas: &[schema],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &edges,
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
assert!(!proposal.plans.is_empty());
let best = &proposal.plans[0];
assert_eq!(best.steps.len(), 1);
assert_eq!(best.steps[0].schema_name, "achieve_goal");
assert!(best.score.composite > 0.0);
assert!(best.viable);
}
#[test]
fn test_schema_with_preconditions() {
let config = test_config();
let goal = test_goal(1);
let prereq_goal = GoalEntry {
node_id: NodeId::new(NodeKind::Goal, 2),
attrs: default_attrs(),
payload: GoalPayload {
description: "Prerequisite goal".to_string(),
status: GoalStatus::Completed,
progress: 1.0,
deadline: None,
priority: Priority::Medium,
parent_goal: None,
completion_criteria: "Done".to_string(),
},
};
let mut schema = test_schema(1, "guarded_action");
schema.payload.preconditions = vec![Precondition {
description: "Prerequisite goal must be completed".to_string(),
node_ref: Some(prereq_goal.node_id),
required: true,
}];
let edges = vec![edge(
schema.node_id,
goal.node_id,
CognitiveEdgeKind::AdvancesGoal,
)];
let ctx = PlanningContext {
schemas: &[schema],
goals: &[goal.clone(), prereq_goal],
tasks: &[],
constraints: &[],
edges: &edges,
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
assert!(!proposal.plans.is_empty());
let best = &proposal.plans[0];
assert!(best.steps[0].preconditions[0].satisfied);
}
#[test]
fn test_unsatisfied_precondition_blocker() {
let config = test_config();
let goal = test_goal(1);
let prereq_goal = GoalEntry {
node_id: NodeId::new(NodeKind::Goal, 2),
attrs: default_attrs(),
payload: GoalPayload {
description: "Not done yet".to_string(),
status: GoalStatus::Active, progress: 0.3,
deadline: None,
priority: Priority::Medium,
parent_goal: None,
completion_criteria: "Done".to_string(),
},
};
let mut schema = test_schema(1, "needs_prereq");
schema.payload.preconditions = vec![Precondition {
description: "Need prereq".to_string(),
node_ref: Some(prereq_goal.node_id),
required: true,
}];
let edges = vec![edge(
schema.node_id,
goal.node_id,
CognitiveEdgeKind::AdvancesGoal,
)];
let ctx = PlanningContext {
schemas: &[schema],
goals: &[goal.clone(), prereq_goal],
tasks: &[],
constraints: &[],
edges: &edges,
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
if !proposal.plans.is_empty() {
let best = &proposal.plans[0];
assert!(!best.steps[0].preconditions[0].satisfied);
}
}
#[test]
fn test_skill_decomposition() {
let config = test_config();
let goal = test_goal(1);
let schema = test_schema(1, "send_reminder");
let skill = SkillTemplate {
skill_id: 42,
description: "Multi-step reminder".to_string(),
steps: vec![
SkillStepInfo {
ordinal: 0,
action_kind: "send_reminder".to_string(),
description: "Send the reminder".to_string(),
expected_duration_ms: 5000,
optional: false,
},
SkillStepInfo {
ordinal: 1,
action_kind: "confirm".to_string(),
description: "Confirm delivery".to_string(),
expected_duration_ms: 2000,
optional: false,
},
],
confidence: 0.8,
success_rate: 0.9,
};
let mut schema_with_activation = schema;
schema_with_activation.attrs.activation = 0.6;
let ctx = PlanningContext {
schemas: &[schema_with_activation],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &[],
skills: &[skill],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
let skill_plans: Vec<&Plan> = proposal
.plans
.iter()
.filter(|p| p.steps.len() > 1)
.collect();
assert!(!skill_plans.is_empty() || !proposal.plans.is_empty());
}
#[test]
fn test_task_chain_plan() {
let config = test_config();
let goal = test_goal(1);
let task1 = TaskEntry {
node_id: NodeId::new(NodeKind::Task, 1),
attrs: default_attrs(),
payload: TaskPayload {
description: "First task".to_string(),
status: TaskStatus::Completed,
goal_id: Some(goal.node_id),
deadline: None,
priority: Priority::Medium,
estimated_minutes: Some(10),
prerequisites: Vec::new(),
},
};
let task2 = TaskEntry {
node_id: NodeId::new(NodeKind::Task, 2),
attrs: default_attrs(),
payload: TaskPayload {
description: "Second task".to_string(),
status: TaskStatus::Pending,
goal_id: Some(goal.node_id),
deadline: None,
priority: Priority::Medium,
estimated_minutes: Some(20),
prerequisites: vec![task1.node_id],
},
};
let ctx = PlanningContext {
schemas: &[],
goals: &[goal.clone()],
tasks: &[task1, task2],
constraints: &[],
edges: &[],
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
if !proposal.plans.is_empty() {
let plan = &proposal.plans[0];
assert!(plan.steps.iter().any(|s| s.description == "Second task"));
}
}
#[test]
fn test_constraint_violation() {
let config = test_config();
let goal = test_goal(1);
let schema = test_schema(1, "delete_all");
let edges = vec![edge(
schema.node_id,
goal.node_id,
CognitiveEdgeKind::AdvancesGoal,
)];
let constraint = ConstraintEntry {
node_id: NodeId::new(NodeKind::Constraint, 1),
payload: ConstraintPayload {
description: "Never delete user data".to_string(),
constraint_type: ConstraintType::Hard,
condition: "delete".to_string(),
imposed_by: "system_policy".to_string(),
},
};
let ctx = PlanningContext {
schemas: &[schema],
goals: &[goal.clone()],
tasks: &[],
constraints: &[constraint],
edges: &edges,
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
if !proposal.plans.is_empty() {
let plan = &proposal.plans[0];
assert!(plan.blockers.iter().any(|b| {
matches!(b.kind, BlockerKind::ConstraintViolation)
}));
assert!(!plan.viable);
}
}
#[test]
fn test_plan_scoring() {
let config = test_config();
let step1 = PlanStep {
ordinal: 0,
schema_name: "step1".to_string(),
action_kind: ActionKind::Execute,
description: "First".to_string(),
schema_node: Some(NodeId::new(NodeKind::ActionSchema, 1)),
preconditions: Vec::new(),
expected_effects: vec![Effect {
description: "Good".to_string(),
probability: 0.9,
utility: 0.7,
}],
estimated_duration_secs: 30.0,
derivation: StepDerivation::DirectMatch,
feasibility: 0.9,
};
let step2 = PlanStep {
ordinal: 1,
schema_name: "step2".to_string(),
action_kind: ActionKind::Communicate,
description: "Second".to_string(),
schema_node: Some(NodeId::new(NodeKind::ActionSchema, 2)),
preconditions: Vec::new(),
expected_effects: vec![Effect {
description: "Done".to_string(),
probability: 0.8,
utility: 0.5,
}],
estimated_duration_secs: 20.0,
derivation: StepDerivation::BackwardChain { enables_step: 0 },
feasibility: 0.7,
};
let score = score_plan(&[step1, step2], &config);
assert!((score.feasibility - 0.63).abs() < 0.01);
assert!(score.simplicity < 1.0);
assert!(score.estimated_total_secs > 0.0);
}
#[test]
fn test_next_plan_step() {
let config = test_config();
let goal = test_goal(1);
let schema = test_schema(1, "quick_action");
let edges = vec![edge(
schema.node_id,
goal.node_id,
CognitiveEdgeKind::AdvancesGoal,
)];
let ctx = PlanningContext {
schemas: &[schema],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &edges,
skills: &[],
now: 1000.0,
config: &config,
};
let step = next_plan_step(goal.node_id, &ctx);
assert!(step.is_some());
assert_eq!(step.unwrap().schema_name, "quick_action");
}
#[test]
fn test_detect_blockers_deadline() {
let config = test_config();
let mut goal = test_goal(1);
goal.payload.deadline = Some(500.0);
let ctx = PlanningContext {
schemas: &[],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &[],
skills: &[],
now: 1000.0,
config: &config,
};
let blockers = detect_blockers(goal.node_id, &ctx);
assert!(blockers.iter().any(|b| {
matches!(b.kind, BlockerKind::DeadlinePressure)
}));
}
#[test]
fn test_plan_store() {
let mut store = PlanStore::new();
let goal_id = NodeId::new(NodeKind::Goal, 1);
assert_eq!(store.active_count(), 0);
assert!(store.get_plan(goal_id).is_none());
let plan = Plan {
goal_id,
goal_description: "Test".to_string(),
steps: Vec::new(),
score: PlanScore {
feasibility: 0.8,
expected_utility: 0.6,
simplicity: 1.0,
schema_success_rate: 0.9,
urgency: 0.5,
composite: 0.7,
estimated_total_secs: 30.0,
},
rationale: "Test plan".to_string(),
created_at: 1000.0,
viable: true,
blockers: Vec::new(),
};
store.set_plan(plan);
assert_eq!(store.active_count(), 1);
assert_eq!(store.total_generated, 1);
assert!(store.get_plan(goal_id).is_some());
store.mark_succeeded(goal_id);
assert_eq!(store.active_count(), 0);
assert_eq!(store.total_succeeded, 1);
}
#[test]
fn test_topological_sort() {
let t1 = TaskEntry {
node_id: NodeId::new(NodeKind::Task, 1),
attrs: default_attrs(),
payload: TaskPayload {
description: "A".to_string(),
status: TaskStatus::Pending,
goal_id: None,
deadline: None,
priority: Priority::Medium,
estimated_minutes: None,
prerequisites: Vec::new(),
},
};
let t2 = TaskEntry {
node_id: NodeId::new(NodeKind::Task, 2),
attrs: default_attrs(),
payload: TaskPayload {
description: "B".to_string(),
status: TaskStatus::Pending,
goal_id: None,
deadline: None,
priority: Priority::Medium,
estimated_minutes: None,
prerequisites: vec![t1.node_id],
},
};
let t3 = TaskEntry {
node_id: NodeId::new(NodeKind::Task, 3),
attrs: default_attrs(),
payload: TaskPayload {
description: "C".to_string(),
status: TaskStatus::Pending,
goal_id: None,
deadline: None,
priority: Priority::Medium,
estimated_minutes: None,
prerequisites: vec![t1.node_id, t2.node_id],
},
};
let tasks: Vec<&TaskEntry> = vec![&t3, &t2, &t1]; let sorted = topological_sort_tasks(&tasks);
assert_eq!(sorted.len(), 3);
let pos1 = sorted.iter().position(|t| t.node_id == t1.node_id).unwrap();
let pos2 = sorted.iter().position(|t| t.node_id == t2.node_id).unwrap();
let pos3 = sorted.iter().position(|t| t.node_id == t3.node_id).unwrap();
assert!(pos1 < pos2);
assert!(pos2 < pos3);
}
#[test]
fn test_multiple_plans_ranking() {
let config = test_config();
let goal = test_goal(1);
let schema_good = test_schema(1, "good_action");
let mut schema_weak = test_schema(2, "weak_action");
schema_weak.payload.success_rate = 0.3;
let edges = vec![
edge(schema_good.node_id, goal.node_id, CognitiveEdgeKind::AdvancesGoal),
edge(schema_weak.node_id, goal.node_id, CognitiveEdgeKind::AdvancesGoal),
];
let ctx = PlanningContext {
schemas: &[schema_good, schema_weak],
goals: &[goal.clone()],
tasks: &[],
constraints: &[],
edges: &edges,
skills: &[],
now: 1000.0,
config: &config,
};
let proposal = instantiate_plan(goal.node_id, &ctx);
assert!(proposal.plans.len() >= 2);
assert!(proposal.plans[0].score.composite >= proposal.plans[1].score.composite);
assert_eq!(proposal.plans[0].steps[0].schema_name, "good_action");
}
}