Skip to content

Commit 7b2b2c7

Browse files
authored
Merge pull request #218 from plaans/feat/ape-solve
feat(ape): add cli for solving finite planning problems
2 parents 162c361 + cf221f4 commit 7b2b2c7

13 files changed

Lines changed: 293 additions & 29 deletions

File tree

ci/ape-val.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ TIMEOUT="${TIMEOUT:-5s}"
1818
echo "Building..."
1919
cargo build --profile ci --bin ape
2020

21-
PLAN_FILES=$(find planning/problems/upf -name *.plan | sort)
21+
PLAN_FILES=$(find planning/problems/upf -name *.enhsp.plan | sort)
2222

2323
for PLAN_FILE in $PLAN_FILES
2424
do

planning/engine/src/encode/constraints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use aries::{model::lang::BoolExpr, prelude::*};
22
use timelines::{IntExp, constraints::HasValueAt, encoder::SchedEncoder};
33

44
/// Constraint representing a condition
5+
#[derive(Debug)]
56
pub enum ConditionConstraint {
67
HasValue(HasValueAt),
78
EqZero(IntExp),

planning/engine/src/generate.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use std::{collections::BTreeMap, time::Instant};
2+
3+
use aries::{core::state::Evaluable, prelude::*};
4+
use aries_plan_engine::{
5+
encode::{encoding::Encoding, tags::Tag},
6+
plans::{
7+
Operation,
8+
lifted_plan::{LiftedPlan, ObjectOrVariable},
9+
},
10+
};
11+
use planx::{Model, Res, Sym};
12+
use timelines::{Sched, explain::ExplainableSolver};
13+
14+
use crate::optimize_plan::{self, Objective};
15+
16+
pub type RelaxableConstraint = Tag;
17+
18+
#[derive(clap::Args, Debug, Clone)]
19+
pub struct Options {
20+
/// Defines the maximum number of instances per action template.
21+
///
22+
/// For instance, if set to 3, the resulting plan may have *at most* three instances
23+
/// of a `pick` action and at most 3 instances of a `drop` action.
24+
#[arg(short, long)]
25+
pub max_instances: usize,
26+
27+
/// Defines the objective to be minimized
28+
#[arg(short, long, default_value("original"))]
29+
pub objective: Objective,
30+
31+
/// If set, the planner will try tro find the optimal solution
32+
#[arg(long)]
33+
pub optimize: bool,
34+
}
35+
36+
pub fn solve_finite_planning_problem(model: &Model, options: &Options) -> Res<()> {
37+
// create a dummy plan with the appropriate number of actions
38+
// this is temporary a workaround to reuse the existing `optimize_plan` facilities
39+
let plan = &new_empty_lifted_plan(model, BTreeMap::new(), options.max_instances)?;
40+
41+
let start = Instant::now();
42+
let (mut solver, encoding, _sched) = encode_finite_planning_problem(model, plan, options)?;
43+
44+
let _encoding_time = start.elapsed().as_millis();
45+
46+
let objective = encoding.objective.unwrap(); //TODO: error message
47+
48+
// set the objective to a constant if we are not optimizing
49+
let solver_objective = if options.optimize { objective } else { 0.into() };
50+
51+
let print = |sol: &Solution| {
52+
println!("\n==== Plan (objective: {}) =====", objective.evaluate(sol).unwrap());
53+
println!("{}\n", encoding.plan(sol));
54+
};
55+
56+
if let Some(solution) = solver.find_optimal(solver_objective, &print) {
57+
println!("\n> Found {}solution:", if options.optimize { "optimal " } else { "" });
58+
print(&solution);
59+
} else {
60+
println!("No solution !!!!");
61+
}
62+
Ok(())
63+
}
64+
65+
fn encode_finite_planning_problem(
66+
model: &Model,
67+
lifted_plan: &LiftedPlan,
68+
options: &Options,
69+
) -> Res<(ExplainableSolver<RelaxableConstraint>, Encoding, Sched)> {
70+
// TODO: make specific function.
71+
// - ability to specify explanations vocabulary via RelaxableConstraint (Tag), including removing (pre)conditions (like in domain repair).
72+
73+
optimize_plan::encode_plan_optimization_problem(
74+
model,
75+
lifted_plan,
76+
&optimize_plan::Options {
77+
relaxation: vec![
78+
optimize_plan::Relaxation::ActionPresence,
79+
optimize_plan::Relaxation::StartTime,
80+
],
81+
objective: options.objective,
82+
},
83+
)
84+
}
85+
86+
fn new_empty_lifted_plan(
87+
model: &Model,
88+
a_instances_per_template: BTreeMap<planx::ActionRef, usize>,
89+
a_instances_default: usize,
90+
) -> Res<LiftedPlan> {
91+
let top_type = model.env.types.top_user_type();
92+
use planx::errors::*;
93+
94+
let num_instances = |a_name| *a_instances_per_template.get(a_name).unwrap_or(&a_instances_default);
95+
96+
// all actions in the plan
97+
let mut operations = Vec::with_capacity(model.actions.iter().map(|a| num_instances(&a.name)).sum());
98+
99+
// all variables appearing in the plan
100+
let mut variables = BTreeMap::new();
101+
102+
for a in model.actions.iter() {
103+
for aid in 0..num_instances(&a.name) {
104+
let mut arguments = Vec::with_capacity(a.parameters.len());
105+
106+
for param in a.parameters.iter() {
107+
let name = Sym::with_source(
108+
format!("{}.{}.{}", a.name.canonical_str(), aid, param.name().canonical_str()),
109+
param.name().span_or_default(),
110+
);
111+
let tpe = if let planx::Type::User(tpe) = param.tpe() {
112+
tpe.to_single_type().unwrap_or_else(|| top_type.clone())
113+
} else {
114+
top_type.clone()
115+
};
116+
117+
variables.insert(name.clone(), tpe);
118+
119+
arguments.push(ObjectOrVariable::Variable { name });
120+
}
121+
operations.push(Operation {
122+
start: 0,
123+
duration: 0,
124+
action_ref: a.name.clone(),
125+
arguments,
126+
span: None,
127+
});
128+
}
129+
}
130+
Ok(LiftedPlan { operations, variables })
131+
}

planning/engine/src/main.rs

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
pub(crate) mod ctags;
2+
mod generate;
23
pub(crate) mod optimize_plan;
34
mod repair;
45
mod validate;
56

6-
use std::path::PathBuf;
7+
use std::{io::IsTerminal, path::PathBuf};
78

89
use aries_plan_engine::plans::lifted_plan;
910
use clap::*;
@@ -49,6 +50,9 @@ enum Commands {
4950
Validate(Validate),
5051
/// Plan optimization: specify an input plan, metrics and relaxation options and get an optmized plan.
5152
OptimizePlan(OptimizePlan),
53+
/// (Finite) planning problem solving (plan generation):
54+
/// find a solution plan using, at most, a given finite number of action instances for each template (schema).
55+
SolveFinite(SolveFiniteProblem),
5256
/// Domain repair: proposing fixes of a domain based on a valid plan.
5357
DomRepair(DomRepair),
5458
FindDomain(FindDomain),
@@ -121,6 +125,15 @@ pub struct OptimizePlan {
121125
options: optimize_plan::Options,
122126
}
123127

128+
#[derive(Parser, Debug)]
129+
pub struct SolveFiniteProblem {
130+
/// Expanded to provide command line options to get the problem and domain
131+
#[command(flatten)]
132+
pb: Problem,
133+
#[command(flatten)]
134+
options: generate::Options,
135+
}
136+
124137
#[derive(Parser, Debug)]
125138
pub struct DomRepair {
126139
/// Expanded to provide command line options to get the plan, problem and domain
@@ -140,9 +153,10 @@ fn main() -> Res<()> {
140153
// set up logger
141154
let subscriber = tracing_subscriber::fmt()
142155
.with_timer(tracing_subscriber::fmt::time::Uptime::from(std::time::Instant::now()))
156+
.with_ansi(std::io::stdout().is_terminal()) // deactivate color when not printing to a terminal (e.g. redirected to a file)
143157
// .without_time() // if activated, no time will be printed on logs (useful for counting events with `counts`)
144158
// .with_thread_ids(true)
145-
.with_max_level(args.log_level)
159+
.with_max_level(args.log_level) // set max level (not that in release, debug and trace logs are not compiled)
146160
.finish();
147161
tracing::subscriber::set_global_default(subscriber)?;
148162

@@ -153,6 +167,7 @@ fn main() -> Res<()> {
153167
Commands::FindProblem(command) => find_problem(command)?,
154168
Commands::Validate(command) => validate_plan(command)?,
155169
Commands::OptimizePlan(command) => optimize_plan(command)?,
170+
Commands::SolveFinite(command) => solve_finite_problem(command)?,
156171
Commands::DomRepair(command) => repair(command)?,
157172
}
158173

@@ -276,6 +291,16 @@ fn optimize_plan(command: &OptimizePlan) -> Res<()> {
276291
optimize_plan::optimize_plan(&model, &plan, &command.options)
277292
}
278293

294+
fn solve_finite_problem(command: &SolveFiniteProblem) -> Res<()> {
295+
let (dom, pb) = command.pb.parse()?;
296+
297+
// processed model (from planx)
298+
let model = pddl::build_model(&dom, &pb)?;
299+
println!("{model}");
300+
301+
generate::solve_finite_planning_problem(&model, &command.options)
302+
}
303+
279304
fn repair(command: &DomRepair) -> Res<()> {
280305
let (dom, pb, plan) = command.plan_pb.parse()?;
281306

@@ -351,3 +376,38 @@ impl PlanAndProblem {
351376
Ok((dom, pb, plan))
352377
}
353378
}
379+
380+
/// Structure that specifies a problem file and (optionnally) a domain file.
381+
#[derive(::clap::Args, Debug)]
382+
pub struct Problem {
383+
/// Path to the PDDL problem file.
384+
problem: PathBuf,
385+
/// Path to the PDDL domain file.
386+
/// If not specified, we will attempt to automatically infer it based on the problem file.
387+
#[arg(short, long)]
388+
domain: Option<PathBuf>,
389+
}
390+
impl Problem {
391+
/// Parses the domain and problem and returns them.
392+
/// If the the domain is not specified, the method will attempt to infer
393+
/// it from naming conventions.
394+
pub fn parse(&self) -> Res<(pddl::Domain, pddl::Problem)> {
395+
let pb = &self.problem;
396+
if !self.problem.exists() {
397+
return Err(Message::error(format!("Problem file does not exist: {}", pb.display())));
398+
}
399+
let dom = if let Some(dom) = &self.domain {
400+
dom
401+
} else {
402+
&pddl::find_domain_of(pb)?
403+
};
404+
405+
println!("> Domain: {}", dom.display());
406+
println!("> Problem: {}", pb.display());
407+
408+
// raw PDDL model
409+
let dom = pddl::parse_pddl_domain(Input::from_file(dom)?)?;
410+
let pb = pddl::parse_pddl_problem(Input::from_file(pb)?)?;
411+
Ok((dom, pb))
412+
}
413+
}

planning/engine/src/optimize_plan.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ pub enum Relaxation {
4242
pub enum Objective {
4343
/// The objective value defined in the domain
4444
Original,
45+
/// Number of actions in the plan
4546
PlanLength,
47+
/// End time of the latest action
4648
Makespan,
4749
}
4850

planning/timelines/src/constraints.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use aries::core::literals::ConjunctionBuilder;
22
use aries::model::lang::element::Element;
33
use aries::model::lang::exclusive_choice::exclu_choice;
4-
use aries::model::lang::expr::{And, geq, leq, lin_eq, lin_neq, lt};
4+
use aries::model::lang::expr::{And, geq, implies, leq, lin_eq, lin_geq, lin_leq, lt};
55
use aries::prelude::*;
66
use aries::{
77
core::{literals::DisjunctionBuilder, views::Dom},
@@ -17,10 +17,13 @@ use crate::{boxes::Segment, effects::EffectOp, *};
1717
/// maximum end time of tasks, or zero in the absence of tasks.
1818
///
1919
/// It is encorced by default in [`Sched`].
20+
#[derive(Debug)]
2021
pub(crate) struct MakespanIsMaxTaskEnd;
2122

2223
impl BoolExpr<SchedEncoder> for MakespanIsMaxTaskEnd {
2324
fn enforce_if(&self, l: Lit, ctx: &mut SchedEncoder) {
25+
let _span = tracing::debug_span!("MakespanIsMaxTaskEnd");
26+
let _span = _span.enter();
2427
let mut ends = ctx.sched.tasks.iter().map(|t| t.end).collect_vec();
2528
ends.push(IAtom::ZERO); // default value when no task is present
2629
EqMax::new(ctx.sched.makespan, ends).enforce_if(l, ctx);
@@ -76,10 +79,13 @@ impl BoolExpr<SchedEncoder> for Mutex {
7679
/// This requires to conditions
7780
/// - that no two assignments have overlapping exclusitivity periods
7881
/// - that every step is within an assignment validity period
82+
#[derive(Debug)]
7983
pub(crate) struct EffectCoherence;
8084

8185
impl BoolExpr<SchedEncoder> for EffectCoherence {
8286
fn enforce_if(&self, l: Lit, ctx: &mut SchedEncoder) {
87+
let _span = tracing::debug_span!("EffectCoherence");
88+
let _span = _span.enter();
8389
let sched = ctx.sched.clone();
8490
for e in sched.effects.iter() {
8591
// WARN: this is not guarded by the effect presence (assumption is that this is always true in an effect)
@@ -204,6 +210,7 @@ struct StepContributor {
204210

205211
impl BoolExpr<SchedEncoder> for HasValueAt {
206212
fn enforce_if(&self, l: Lit, ctx: &mut SchedEncoder) {
213+
ctx.add_assertion(implies(ctx.presence_literal(l), self.prez));
207214
let _span = tracing::debug_span!("HasValueAt");
208215
let _span = _span.enter();
209216
tracing::debug!("{l:?} => {self:?}");
@@ -270,7 +277,9 @@ impl BoolExpr<SchedEncoder> for HasValueAt {
270277
conjuncts.push(geq(self.timepoint, eff.effective_start()).implicant(ctx));
271278
conjuncts.push(leq(self.timepoint, eff.mutex_end).implicant(ctx));
272279
for (arg1, arg2) in self.state_var.args.iter().zip_eq(eff.state_var.args.iter()) {
273-
conjuncts.push(lin_eq(*arg1, *arg2).implicant(ctx))
280+
// note we use the conjunctive form with bot leq and geq to avoid reification of the equality
281+
conjuncts.push(lin_leq(*arg1, *arg2).implicant(ctx));
282+
conjuncts.push(lin_geq(*arg1, *arg2).implicant(ctx));
274283
}
275284
if !conjuncts.absurd() {
276285
let conjuncts: And = and(conjuncts.build().into_lits().into_boxed_slice()); // TODO: make And = Conjunction
@@ -377,8 +386,8 @@ impl<'a, Ctx: Store + Dom> BoolExpr<Ctx> for Exclusive<'a> {
377386
let mut disjuncts = DisjunctionBuilder::new();
378387
for (x1, x2) in a.state_var.args.iter().zip_eq(b.state_var.args.iter()) {
379388
// TODO: this reifies the value even though it could be decomposed into the two disjuncts
380-
let opt = lin_neq(*x1, *x2).implicant(ctx);
381-
disjuncts.push(opt.implicant(ctx));
389+
disjuncts.push(lin_leq(*x1, *x2).implicant(ctx));
390+
disjuncts.push(lin_geq(*x1, *x2).implicant(ctx));
382391
if disjuncts.tautological() {
383392
return;
384393
}

planning/timelines/src/explain.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ impl<T: Ord + Clone> ExplainableSolver<T> {
2828
let mut trigger = BTreeMap::new();
2929

3030
for (cid, c) in sched.constraints.iter().enumerate() {
31+
tracing::debug!("Adding constraint: {c:?}");
3132
if let Some(tag) = project(cid) {
3233
let l = if let Some(l) = trigger.get(&tag) {
3334
*l

planning/timelines/src/lib.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,14 @@ pub enum Tag {
9696
TaskEnd(TaskId),
9797
}
9898

99-
type Constraint = std::sync::Arc<dyn BoolExpr<SchedEncoder> + Send + Sync>;
99+
/// Trait capturing the requirements of constraitns posted to a [`Sched`]
100+
///
101+
/// It is automatically derived for any element providing the requirements,
102+
/// but needed for making the element dyn-compatible.
103+
pub trait SchedConstraint: BoolExpr<SchedEncoder> + Send + Sync + Debug {}
104+
impl<C> SchedConstraint for C where C: BoolExpr<SchedEncoder> + Send + Sync + Debug {}
105+
106+
type Constraint = std::sync::Arc<dyn SchedConstraint>;
100107
pub type ConstraintID = usize;
101108

102109
#[derive(Clone)]
@@ -149,10 +156,10 @@ impl Sched {
149156
pub fn new_opt_timepoint(&mut self, scope: Lit) -> Time {
150157
self.model.new_optional_ivar(0, INT_CST_MAX, scope, "_").into()
151158
}
152-
pub fn add_constraint<C: BoolExpr<SchedEncoder> + 'static + Send + Sync>(&mut self, c: C) -> ConstraintID {
159+
pub fn add_constraint<C: SchedConstraint + 'static>(&mut self, c: C) -> ConstraintID {
153160
self.add_boxed_constraint(Arc::new(c))
154161
}
155-
pub fn add_boxed_constraint(&mut self, c: Arc<dyn BoolExpr<SchedEncoder> + 'static + Send + Sync>) -> ConstraintID {
162+
pub fn add_boxed_constraint(&mut self, c: Arc<dyn SchedConstraint + 'static>) -> ConstraintID {
156163
self.constraints.push(c);
157164
self.constraints.len() - 1
158165
}

solver/src/core/literals/disjunction.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ impl DisjunctionBuilder {
220220
self.lits.first().is_some_and(|l| l.tautological())
221221
}
222222

223-
/// Adds an element to the disjunction, appropriately simplifying when submitting absurd or tautological literals.
223+
/// Adds an element to the disjunction.
224224
pub fn push(&mut self, lit: Lit) {
225225
if self.tautological() {
226226
// clause is always true no need to consider any new submitted literal
@@ -238,6 +238,12 @@ impl DisjunctionBuilder {
238238
}
239239
}
240240

241+
/// Returns the same disjunction with an additional element.
242+
pub fn with(mut self, lit: Lit) -> Self {
243+
self.push(lit);
244+
self
245+
}
246+
241247
/// Build the disjunction, reusing any allocation that the builder had.
242248
pub fn build(self) -> Disjunction {
243249
Disjunction::new(self.lits)

0 commit comments

Comments
 (0)