Skip to main content

sim_lib_openai_server/plan/
shape.rs

1use sim_kernel::{Error, Expr, Result, Symbol};
2
3use crate::plan::combinators::combinator_by_name;
4
5/// Structural limits enforced when checking a plan expression.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub struct PlanLimits {
8    /// Maximum nesting depth permitted for the plan tree.
9    pub max_depth: usize,
10    /// Maximum number of children any single combinator may have.
11    pub max_fan_out: usize,
12}
13
14impl Default for PlanLimits {
15    fn default() -> Self {
16        Self {
17            max_depth: 4,
18            max_fan_out: 4,
19        }
20    }
21}
22
23/// Validates a plan expression against the default [`PlanLimits`].
24pub fn check_plan(plan: &Expr) -> Result<()> {
25    check_plan_with_limits(plan, PlanLimits::default())
26}
27
28/// Validates a plan expression against the supplied structural limits.
29pub fn check_plan_with_limits(plan: &Expr, limits: PlanLimits) -> Result<()> {
30    check_plan_at(plan, &limits, 1)
31}
32
33/// Returns an indented, human-readable outline of a validated plan tree.
34pub fn explain_plan(plan: &Expr) -> Result<String> {
35    check_plan(plan)?;
36    let mut lines = Vec::new();
37    explain_plan_at(plan, 0, &mut lines)?;
38    Ok(lines.join("\n"))
39}
40
41fn check_plan_at(plan: &Expr, limits: &PlanLimits, depth: usize) -> Result<()> {
42    if depth > limits.max_depth {
43        return Err(Error::Eval(format!(
44            "plan nesting exceeds maximum depth {}",
45            limits.max_depth
46        )));
47    }
48    let (name, args) = plan_parts(plan)?;
49    if name == "atom" {
50        return check_atom(args);
51    }
52
53    let Some(combinator) = combinator_by_name(name) else {
54        return Err(Error::Eval(format!("unknown plan combinator {name}")));
55    };
56    let children = args.iter().filter(|arg| !is_keyword_arg(arg)).count();
57    if children < combinator.min_children || children > combinator.max_children {
58        return Err(Error::Eval(format!(
59            "plan/{name} expects {}..{} children, found {children}",
60            combinator.min_children, combinator.max_children
61        )));
62    }
63    if children > limits.max_fan_out {
64        return Err(Error::Eval(format!(
65            "plan/{name} fan-out {children} exceeds maximum {}",
66            limits.max_fan_out
67        )));
68    }
69    for arg in args {
70        if let Some(keyword) = keyword_name(arg) {
71            if !combinator.keywords.contains(&keyword) {
72                return Err(Error::Eval(format!(
73                    "unknown keyword {keyword} for plan/{name}"
74                )));
75            }
76            continue;
77        }
78        check_plan_at(arg, limits, depth + 1)?;
79    }
80    Ok(())
81}
82
83fn check_atom(args: &[Expr]) -> Result<()> {
84    match args {
85        [Expr::String(address)] if !address.trim().is_empty() => Ok(()),
86        [_] => Err(Error::Eval("plan/atom address must be a string".to_owned())),
87        _ => Err(Error::Eval("plan/atom expects one address".to_owned())),
88    }
89}
90
91fn explain_plan_at(plan: &Expr, indent: usize, lines: &mut Vec<String>) -> Result<()> {
92    let (name, args) = plan_parts(plan)?;
93    let prefix = "  ".repeat(indent);
94    if name == "atom" {
95        let [Expr::String(address)] = args else {
96            return Err(Error::Eval("plan/atom expects one address".to_owned()));
97        };
98        lines.push(format!("{prefix}plan/atom {address}"));
99        return Ok(());
100    }
101    lines.push(format!("{prefix}plan/{name}"));
102    for arg in args {
103        if let Some(keyword) = keyword_name(arg) {
104            lines.push(format!("{prefix}  keyword {keyword}"));
105        } else {
106            explain_plan_at(arg, indent + 1, lines)?;
107        }
108    }
109    Ok(())
110}
111
112pub(crate) fn plan_parts(plan: &Expr) -> Result<(&str, &[Expr])> {
113    let Expr::List(items) = plan else {
114        return Err(Error::Eval("plan must be a list expression".to_owned()));
115    };
116    let Some((Expr::Symbol(symbol), args)) = items.split_first() else {
117        return Err(Error::Eval("plan must have a symbolic head".to_owned()));
118    };
119    plan_name(symbol).map(|name| (name, args))
120}
121
122fn plan_name(symbol: &Symbol) -> Result<&str> {
123    let name = symbol.name.as_ref();
124    name.strip_prefix("plan/")
125        .ok_or_else(|| Error::Eval(format!("plan head must use plan/*, found {name}")))
126}
127
128fn is_keyword_arg(expr: &Expr) -> bool {
129    keyword_name(expr).is_some()
130}
131
132fn keyword_name(expr: &Expr) -> Option<&str> {
133    let Expr::Map(entries) = expr else {
134        return None;
135    };
136    entries.iter().find_map(|(key, value)| match (key, value) {
137        (Expr::Symbol(key), Expr::Symbol(name))
138            if key.namespace.is_none() && key.name.as_ref() == "keyword" =>
139        {
140            Some(name.name.as_ref())
141        }
142        _ => None,
143    })
144}