use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::ground::{ground, Outcome};
use crate::packed::PackedTask;
use crate::parser;
use crate::pddl3;
use crate::resolve::{self, Solved};
use crate::search;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
#[default]
Auto,
Ff,
Partition,
Pddl3,
Temporal,
Portfolio,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Search {
#[default]
Auto,
Ehc,
BestFirst,
EhcThenBestFirst,
}
fn default_weight_g() -> f64 {
1.0
}
fn default_weight_h() -> f64 {
5.0
}
fn default_true() -> bool {
true
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Options {
#[serde(default)]
pub mode: Mode,
#[serde(default)]
pub search: Search,
#[serde(default = "default_true")]
pub helpful_actions: bool,
#[serde(default = "default_weight_g")]
pub weight_g: f64,
#[serde(default = "default_weight_h")]
pub weight_h: f64,
#[serde(default)]
pub threads: usize,
#[serde(default)]
pub max_evaluated: Option<usize>,
#[serde(default = "default_true")]
pub optimize: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
mode: Mode::Auto,
search: Search::Auto,
helpful_actions: true,
weight_g: default_weight_g(),
weight_h: default_weight_h(),
threads: 0,
max_evaluated: None,
optimize: true,
}
}
}
impl Options {
fn search_cfg(&self) -> crate::search::SearchCfg {
crate::search::SearchCfg::from_weights(self.weight_g, self.weight_h, self.max_evaluated)
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Step {
pub index: usize,
pub action: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Plan {
pub steps: Vec<Step>,
pub length: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub metric: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub makespan: Option<f64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Statistics {
pub grounded_facts: usize,
pub grounded_actions: usize,
pub evaluated_states: usize,
pub threads: usize,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Solution {
pub solved: bool,
pub mode: Mode,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan: Option<Plan>,
pub statistics: Statistics,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Contract {
pub index: usize,
pub goal: String,
pub steps: Vec<Step>,
pub makespan: f64,
pub offset: f64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Decomposition {
pub solved: bool,
pub contracts: Vec<Contract>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan: Option<Plan>,
pub monolithic: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ParseReport {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requirements: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<DomainSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub problem: Option<ProblemSummary>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DomainSummary {
pub types: Vec<String>,
pub predicates: Vec<String>,
pub functions: Vec<String>,
pub actions: Vec<String>,
pub durative_actions: Vec<String>,
pub derived: usize,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ProblemSummary {
pub domain: String,
pub objects: usize,
pub init_facts: usize,
pub init_fluents: usize,
pub timed_initial_literals: usize,
pub has_goal: bool,
pub has_metric: bool,
}
pub fn parse(src: &str) -> ParseReport {
let up = src.to_ascii_uppercase();
let is_problem = match (up.find("(PROBLEM"), up.find("(DOMAIN")) {
(Some(p), Some(d)) => p < d,
(Some(_), None) => true,
_ => false,
};
if is_problem {
match parser::parse_problem(src) {
Ok(p) => ParseReport {
ok: true,
kind: Some("problem".into()),
name: Some(p.name.to_lowercase()),
requirements: Vec::new(), error: None,
domain: None,
problem: Some(ProblemSummary {
domain: p.domain_name.to_lowercase(),
objects: p.objects.len(),
init_facts: p.init_atoms.len(),
init_fluents: p.init_fluents.len(),
timed_initial_literals: p.til.len(),
has_goal: !matches!(p.goal, crate::types::Formula::True),
has_metric: p.metric.is_some(),
}),
},
Err(e) => parse_err("problem", e),
}
} else {
match parser::parse_domain(src) {
Ok(d) => ParseReport {
ok: true,
kind: Some("domain".into()),
name: Some(d.name.to_lowercase()),
requirements: d
.requirements
.iter()
.map(|r| format!(":{}", r.trim_start_matches(':').to_lowercase()))
.collect(),
error: None,
domain: Some(DomainSummary {
types: d.types.iter().map(|t| t.to_lowercase()).collect(),
predicates: d.predicates.iter().map(|(n, a)| render_sig(n, a)).collect(),
functions: d.functions.iter().map(|(n, a)| render_sig(n, a)).collect(),
actions: d.actions.iter().map(|a| a.name.to_lowercase()).collect(),
durative_actions: d
.durative_actions
.iter()
.map(|a| a.name.to_lowercase())
.collect(),
derived: d.derived.len(),
}),
problem: None,
},
Err(e) => parse_err("domain", e),
}
}
}
fn render_sig(name: &str, arg_types: &[String]) -> String {
if arg_types.is_empty() {
name.to_lowercase()
} else {
let args = arg_types
.iter()
.map(|t| t.to_lowercase())
.collect::<Vec<_>>()
.join(" ");
format!("{} {}", name.to_lowercase(), args)
}
}
fn parse_err(kind: &str, e: crate::types::ParseError) -> ParseReport {
ParseReport {
ok: false,
kind: Some(kind.to_string()),
name: None,
requirements: Vec::new(),
error: Some(e.to_string()),
domain: None,
problem: None,
}
}
pub type Metric = f64;
#[derive(thiserror::Error, Debug)]
pub enum SolveError {
#[error("domain parse error: {0}")]
DomainParse(crate::types::ParseError),
#[error("problem parse error: {0}")]
ProblemParse(crate::types::ParseError),
#[error("{kind} {pred} uses an unknown or empty type {ty}")]
EmptyType {
kind: String,
pred: String,
ty: String,
},
#[error("derived predicate error: {0}")]
Derived(String),
#[error("unsupported feature: {0}")]
Unsupported(String),
}
enum Grounded {
Task(Box<PackedTask>),
Trivial,
Unsolvable,
}
fn do_ground(
domain: &crate::types::Domain,
problem: &crate::types::Problem,
threads: usize,
) -> Result<Grounded, SolveError> {
match ground(domain, problem, threads) {
Outcome::Task(t) => Ok(Grounded::Task(Box::new(t))),
Outcome::GoalTrue => Ok(Grounded::Trivial),
Outcome::GoalFalse | Outcome::GoalUndefinedFluent => Ok(Grounded::Unsolvable),
Outcome::EmptyType { kind, pred, ty } => Err(SolveError::EmptyType {
kind: kind.to_string(),
pred,
ty,
}),
}
}
pub(crate) fn steps_of(
task: &PackedTask,
ops: &[usize],
synthetic: Option<&HashSet<String>>,
) -> Vec<Step> {
let mut steps = Vec::new();
let mut idx = 0;
for &oi in ops {
let disp = &task.op_display[oi];
let mut it = disp.split_whitespace();
let action = it.next().unwrap_or("").to_string();
if action == "REACH-GOAL" || synthetic.is_some_and(|s| s.contains(&action)) {
continue;
}
steps.push(Step {
index: idx,
action,
args: it.map(|s| s.to_string()).collect(),
time: None,
duration: None,
});
idx += 1;
}
steps
}
fn strip_end_steps(steps: Vec<Step>, constrained: bool) -> Vec<Step> {
if !constrained {
return steps;
}
steps
.into_iter()
.filter(|s| s.action != crate::constraints::END_ACTION)
.enumerate()
.map(|(i, mut s)| {
s.index = i;
s
})
.collect()
}
fn timed_steps(tp: &crate::temporal::TimedPlan) -> Vec<Step> {
tp.steps
.iter()
.enumerate()
.map(|(i, s)| {
let mut it = s.action.split_whitespace();
Step {
index: i,
action: it.next().unwrap_or("").to_string(),
args: it.map(|x| x.to_string()).collect(),
time: Some(s.time),
duration: s.duration,
}
})
.collect()
}
pub(crate) fn stats(task: &PackedTask, evaluated: usize, threads: usize) -> Statistics {
Statistics {
grounded_facts: task.n_reach_facts,
grounded_actions: task.n_reach_actions,
evaluated_states: evaluated,
threads,
}
}
fn trivial(mode: Mode, threads: usize) -> Solution {
Solution {
solved: true,
mode,
plan: Some(Plan {
steps: Vec::new(),
length: 0,
metric: None,
makespan: None,
}),
statistics: Statistics {
threads,
..Default::default()
},
notes: vec!["goal already satisfied; the empty plan solves it".into()],
}
}
fn unsolved(mode: Mode, stats: Statistics, notes: Vec<String>) -> Solution {
Solution {
solved: false,
mode,
plan: None,
statistics: stats,
notes,
}
}
pub fn solve(domain_src: &str, problem_src: &str, opts: &Options) -> Result<Solution, SolveError> {
let domain = parser::parse_domain(domain_src).map_err(SolveError::DomainParse)?;
let problem = parser::parse_problem(problem_src).map_err(SolveError::ProblemParse)?;
let (domain, problem) =
crate::derived::compile(&domain, &problem).map_err(SolveError::Derived)?;
let (domain, problem, constrained) = match crate::constraints::gate(&domain, &problem) {
Ok(Some((d, p))) => (d, p, true),
Ok(None) => (domain, problem, false),
Err(reason) => return Err(SolveError::Unsupported(reason)),
};
let threads = if opts.threads == 0 {
crate::par::num_threads()
} else {
opts.threads
};
let mode = match opts.mode {
Mode::Auto => {
if crate::temporal::is_temporal(&domain) {
Mode::Temporal
} else if pddl3::has_preferences(&problem) {
Mode::Pddl3
} else {
Mode::Ff
}
}
m => m,
};
let mode = if mode == Mode::Portfolio
&& (crate::temporal::is_temporal(&domain) || pddl3::has_preferences(&problem))
{
if crate::temporal::is_temporal(&domain) {
Mode::Temporal
} else {
Mode::Pddl3
}
} else {
mode
};
match mode {
Mode::Temporal => solve_temporal(&domain, &problem, threads),
Mode::Pddl3 => solve_pddl3(&domain, &problem, opts, threads, constrained),
_ => solve_classic(
&domain,
&problem,
opts,
threads,
mode,
Vec::new(),
constrained,
),
}
}
pub fn decompose(
domain_src: &str,
problem_src: &str,
opts: &Options,
) -> Result<Decomposition, SolveError> {
let domain = parser::parse_domain(domain_src).map_err(SolveError::DomainParse)?;
let problem = parser::parse_problem(problem_src).map_err(SolveError::ProblemParse)?;
let (domain, problem) =
crate::derived::compile(&domain, &problem).map_err(SolveError::Derived)?;
let (domain, problem, constrained) = match crate::constraints::gate(&domain, &problem) {
Ok(Some((d, p))) => (d, p, true),
Ok(None) => (domain, problem, false),
Err(reason) => return Err(SolveError::Unsupported(reason)),
};
let threads = if opts.threads == 0 {
crate::par::num_threads()
} else {
opts.threads
};
let mut notes = Vec::new();
if !crate::temporal::is_temporal(&domain) {
notes.push(
"decomposition targets temporal (durative-action) goals; this domain has none".into(),
);
}
match crate::tresolve::decompose(&domain, &problem, threads) {
Some(d) => {
let contracts = d
.contracts
.iter()
.enumerate()
.map(|(i, cr)| Contract {
index: i,
goal: cr.goal.clone(),
steps: strip_end_steps(timed_steps(&cr.plan), constrained),
makespan: cr.plan.makespan,
offset: cr.offset,
})
.collect();
let steps = strip_end_steps(timed_steps(&d.plan), constrained);
if d.monolithic {
notes.push(
"goal could not be split into independent contracts; solved monolithically"
.into(),
);
}
Ok(Decomposition {
solved: true,
contracts,
plan: Some(Plan {
length: steps.len(),
steps,
metric: None,
makespan: Some(d.plan.makespan),
}),
monolithic: d.monolithic,
notes,
})
}
None => {
notes.push("no plan found (decomposed or monolithic)".into());
Ok(Decomposition {
solved: false,
contracts: Vec::new(),
plan: None,
monolithic: false,
notes,
})
}
}
}
fn solve_temporal(
domain: &crate::types::Domain,
problem: &crate::types::Problem,
threads: usize,
) -> Result<Solution, SolveError> {
let result = if crate::features::tdecomp() {
crate::tresolve::solve(domain, problem, threads)
} else {
crate::temporal::solve(domain, problem, threads)
};
match result {
Some(tp) => {
let steps = timed_steps(&tp);
Ok(Solution {
solved: true,
mode: Mode::Temporal,
plan: Some(Plan {
length: steps.len(),
steps,
metric: None,
makespan: Some(tp.makespan),
}),
statistics: Statistics {
threads,
..Default::default()
},
notes: Vec::new(),
})
}
None => Ok(unsolved(
Mode::Temporal,
Statistics {
threads,
..Default::default()
},
Vec::new(),
)),
}
}
fn solve_classic(
domain: &crate::types::Domain,
problem: &crate::types::Problem,
opts: &Options,
threads: usize,
mode: Mode,
extra_notes: Vec<String>,
strip_end: bool,
) -> Result<Solution, SolveError> {
let mut notes = extra_notes;
let task = match do_ground(domain, problem, threads)? {
Grounded::Task(t) => t,
Grounded::Trivial => return Ok(trivial(mode, threads)),
Grounded::Unsolvable => {
return Ok(unsolved(
mode,
Statistics {
threads,
..Default::default()
},
notes,
))
}
};
let (ops, evaluated) = if mode == Mode::Portfolio {
let o = crate::portfolio::solve(&task, threads, opts.search_cfg());
if let Some(w) = o.winner {
notes.push(format!("portfolio: solved by member `{w}`"));
}
(o.ops, o.evaluated)
} else if mode == Mode::Partition {
let groups = crate::invariants::synthesize(domain, &task);
match resolve::solve(&task, threads, opts.search_cfg(), &groups) {
Solved::Plan(ops, _) => (Some(ops), 0),
Solved::Unsolvable => (None, 0),
}
} else {
let ehc_first = opts.search != Search::BestFirst;
let o = search::plan(&task, threads, opts.search_cfg(), ehc_first);
if o.ehc_fell_back && o.ops.is_some() {
notes.push("EHC found no improving state; used weighted best-first".into());
}
(o.ops, o.evaluated)
};
match ops {
Some(mut ops) => {
let mut metric = None;
let mut sweep_evals = 0;
if let Some(cf) =
crate::costs::metric_fluent(problem).and_then(|disp| task.fluent_id(&disp))
{
match crate::costs::plan_cost(&task, cf, &ops) {
Some(c0) if opts.optimize => {
let r = crate::costs::improve(
&task,
cf,
ops,
c0,
threads,
opts.search_cfg(),
evaluated,
);
ops = r.ops;
metric = Some(r.cost);
sweep_evals = r.evaluated;
if r.improved {
notes.push(format!(
"anytime cost sweep improved plan cost {} -> {}",
c0, r.cost
));
}
if r.proven {
notes.push("plan cost proven optimal".into());
}
}
Some(c0) => {
metric = Some(c0);
notes.push("cost metric reported, not optimized (--satisfice)".into());
}
None => notes
.push("metric fluent undefined at plan end; metric not reported".into()),
}
} else if problem.metric.is_none() && opts.optimize {
let len0 = ops.len();
let (better, evals, improved) =
crate::costs::improve_length(&task, ops, threads, opts.search_cfg(), evaluated);
ops = better;
sweep_evals = evals;
if improved {
notes.push(format!(
"iterated-weight sweep shortened plan {} -> {} steps",
len0,
ops.len()
));
}
}
if strip_end {
crate::constraints::strip_end(&task, &mut ops);
}
let steps = steps_of(&task, &ops, None);
Ok(Solution {
solved: true,
mode,
plan: Some(Plan {
length: steps.len(),
steps,
metric,
makespan: None,
}),
statistics: stats(&task, evaluated + sweep_evals, threads),
notes,
})
}
None => Ok(unsolved(mode, stats(&task, evaluated, threads), notes)),
}
}
fn solve_pddl3(
domain: &crate::types::Domain,
problem: &crate::types::Problem,
opts: &Options,
threads: usize,
strip_end: bool,
) -> Result<Solution, SolveError> {
if !opts.optimize {
let note = "PDDL3 metric not optimized (optimize = false); satisficing plan".to_string();
return solve_classic(
domain,
problem,
opts,
threads,
Mode::Pddl3,
vec![note],
strip_end,
);
}
let mut c = pddl3::compile(domain, problem);
if strip_end {
c.synthetic
.insert(crate::constraints::END_ACTION.to_string());
}
if let Some(reason) = c.unsupported.clone() {
let note = format!(
"PDDL3 metric not optimized ({}); returning a satisficing plan",
reason
);
return solve_classic(
domain,
problem,
opts,
threads,
Mode::Pddl3,
vec![note],
strip_end,
);
}
let task = match do_ground(&c.domain, &c.problem, threads)? {
Grounded::Task(t) => t,
Grounded::Trivial => return Ok(trivial(Mode::Pddl3, threads)),
Grounded::Unsolvable => {
return Ok(unsolved(
Mode::Pddl3,
Statistics {
threads,
..Default::default()
},
Vec::new(),
))
}
};
let cf = task
.fluent_id(pddl3::COST_DISP)
.expect("compile() always injects the total-cost fluent");
let forgos: Vec<(usize, f64)> = c
.forgos
.iter()
.filter_map(|(name, w)| {
task.op_display
.iter()
.position(|d| d == name)
.map(|oi| (oi, *w))
})
.collect();
let groups = crate::invariants::synthesize(&c.domain, &task);
match pddl3::metric_optimize(&task, cf, &forgos, &groups, c.folded_metric, threads) {
Some(r) => {
let mut notes = Vec::new();
if c.warn_other {
notes.push(
"metric has terms beyond is-violated/total-cost; optimized the supported part"
.into(),
);
}
if c.maximized {
notes.push(
"maximize metric normalized to minimize; reported metric is the original \
(maximized) value"
.into(),
);
}
if !r.proven {
notes.push("search bound hit; metric is best-found, not proven optimal".into());
}
let steps = steps_of(&task, &r.ops, Some(&c.synthetic));
Ok(Solution {
solved: true,
mode: Mode::Pddl3,
plan: Some(Plan {
length: steps.len(),
steps,
metric: Some(c.display_metric(r.cost)),
makespan: None,
}),
statistics: stats(&task, 0, threads),
notes,
})
}
None => Ok(unsolved(Mode::Pddl3, stats(&task, 0, threads), Vec::new())),
}
}