use std::collections::HashMap;
use crate::pddl3::{combos, subst_formula};
use crate::types::{Action, Constraint, Domain, Effect, Formula, Problem, Sym};
pub const END_ACTION: &str = "TRAJ-END";
#[derive(Clone, Debug)]
pub enum Traj {
Always(Formula),
Sometime(Formula),
AtMostOnce(Formula),
SometimeAfter(Formula, Formula),
SometimeBefore(Formula, Formula),
AtEnd(Formula),
}
pub struct Expanded {
pub hard: Vec<Traj>,
pub soft: Vec<(String, Vec<Traj>)>,
}
pub fn expand(domain: &Domain, problem: &Problem) -> Result<Expanded, String> {
let objs = crate::ground::objects_by_type(domain, problem);
let mut out = Expanded {
hard: Vec::new(),
soft: Vec::new(),
};
let mut anon = 0usize;
for c in domain.constraints.iter().chain(problem.constraints.iter()) {
walk(c, &objs, &HashMap::new(), &mut anon, &mut out)?;
}
Ok(out)
}
pub(crate) fn expand_quantifiers(f: &Formula, objs: &HashMap<Sym, Vec<Sym>>) -> Formula {
match f {
Formula::Forall(vars, inner) => Formula::And(
combos(vars, objs)
.into_iter()
.map(|b| expand_quantifiers(&subst_formula(inner, &b), objs))
.collect(),
),
Formula::Exists(vars, inner) => Formula::Or(
combos(vars, objs)
.into_iter()
.map(|b| expand_quantifiers(&subst_formula(inner, &b), objs))
.collect(),
),
Formula::And(v) => Formula::And(v.iter().map(|x| expand_quantifiers(x, objs)).collect()),
Formula::Or(v) => Formula::Or(v.iter().map(|x| expand_quantifiers(x, objs)).collect()),
Formula::Not(a) => Formula::Not(Box::new(expand_quantifiers(a, objs))),
Formula::Pref(n, a) => Formula::Pref(n.clone(), Box::new(expand_quantifiers(a, objs))),
other => other.clone(),
}
}
fn timed_err(op: &str) -> String {
format!(
"PDDL3 trajectory constraint `{op}` is time-bounded and not yet \
enforced (untimed operators — always / sometime / at-most-once / \
sometime-after / sometime-before / at-end — are). Remove it, or \
model the requirement without a clock."
)
}
fn walk(
c: &Constraint,
objs: &HashMap<Sym, Vec<Sym>>,
binding: &HashMap<Sym, Sym>,
anon: &mut usize,
out: &mut Expanded,
) -> Result<(), String> {
match c {
Constraint::And(v) => {
for x in v {
walk(x, objs, binding, anon, out)?;
}
}
Constraint::Forall(vars, inner) => {
for combo in combos(vars, objs) {
let mut b = binding.clone();
b.extend(combo);
walk(inner, objs, &b, anon, out)?;
}
}
Constraint::Pref(name, inner) => {
let name = name.clone().unwrap_or_else(|| {
let s = format!("TRAJPREF{anon}");
*anon += 1;
s
});
let mut members = Vec::new();
walk_members(inner, objs, binding, &mut members)?;
out.soft.push((name, members));
}
_ => {
let mut members = Vec::new();
walk_members(c, objs, binding, &mut members)?;
out.hard.extend(members);
}
}
Ok(())
}
fn walk_members(
c: &Constraint,
objs: &HashMap<Sym, Vec<Sym>>,
binding: &HashMap<Sym, Sym>,
members: &mut Vec<Traj>,
) -> Result<(), String> {
let sub = |f: &Formula| expand_quantifiers(&subst_formula(f, binding), objs);
match c {
Constraint::And(v) => {
for x in v {
walk_members(x, objs, binding, members)?;
}
}
Constraint::Forall(vars, inner) => {
for combo in combos(vars, objs) {
let mut b = binding.clone();
b.extend(combo);
walk_members(inner, objs, &b, members)?;
}
}
Constraint::Pref(_, _) => {
return Err(
"malformed (:constraints ...): a preference nested inside a \
preference has no PDDL3 semantics"
.into(),
)
}
Constraint::Always(f) => members.push(Traj::Always(sub(f))),
Constraint::Sometime(f) => members.push(Traj::Sometime(sub(f))),
Constraint::AtMostOnce(f) => members.push(Traj::AtMostOnce(sub(f))),
Constraint::SometimeAfter(a, b) => members.push(Traj::SometimeAfter(sub(a), sub(b))),
Constraint::SometimeBefore(a, b) => members.push(Traj::SometimeBefore(sub(a), sub(b))),
Constraint::AtEnd(f) => members.push(Traj::AtEnd(sub(f))),
Constraint::Within(_, _) => return Err(timed_err("within")),
Constraint::AlwaysWithin(_, _, _) => return Err(timed_err("always-within")),
Constraint::HoldDuring(_, _, _) => return Err(timed_err("hold-during")),
Constraint::HoldAfter(_, _) => return Err(timed_err("hold-after")),
}
Ok(())
}
pub struct Fold<'a> {
traj: &'a Traj,
ok: bool,
seen: bool, holding: bool, pending: bool, safe: bool, last: bool, }
impl<'a> Fold<'a> {
pub fn new(traj: &'a Traj) -> Self {
Fold {
traj,
ok: true,
seen: false,
holding: false,
pending: false,
safe: false,
last: false,
}
}
pub fn step(&mut self, holds: &mut dyn FnMut(&Formula) -> bool) {
match self.traj {
Traj::Always(f) => {
if !holds(f) {
self.ok = false;
}
}
Traj::Sometime(f) => {
if holds(f) {
self.seen = true;
}
}
Traj::AtMostOnce(f) => {
let now = holds(f);
if now && !self.holding {
if self.seen {
self.ok = false; }
self.seen = true;
}
self.holding = now;
}
Traj::SometimeAfter(a, b) => {
let (fa, fb) = (holds(a), holds(b));
if fb {
self.pending = false;
} else if fa {
self.pending = true;
}
}
Traj::SometimeBefore(a, b) => {
if holds(a) && !self.safe {
self.ok = false;
}
if holds(b) {
self.safe = true;
}
}
Traj::AtEnd(f) => {
self.last = holds(f);
}
}
}
pub fn accepted(&self) -> bool {
match self.traj {
Traj::Always(_) => self.ok,
Traj::Sometime(_) => self.seen,
Traj::AtMostOnce(_) => self.ok,
Traj::SometimeAfter(_, _) => !self.pending,
Traj::SometimeBefore(_, _) => self.ok,
Traj::AtEnd(_) => self.last,
}
}
pub fn op_name(&self) -> &'static str {
match self.traj {
Traj::Always(_) => "always",
Traj::Sometime(_) => "sometime",
Traj::AtMostOnce(_) => "at-most-once",
Traj::SometimeAfter(_, _) => "sometime-after",
Traj::SometimeBefore(_, _) => "sometime-before",
Traj::AtEnd(_) => "at-end",
}
}
}
fn simplify_static(exp: &mut Expanded, domain: &Domain, problem: &Problem) {
if std::env::var("FF_PREF_NO_STATIC").is_ok() {
return;
}
let statics = crate::pddl3::static_predicates(domain);
let init: std::collections::HashSet<(Sym, Vec<Sym>)> =
problem.init_atoms.iter().cloned().collect();
let peval = |f: &Formula| crate::pddl3::peval_static(f, &statics, &init);
let t = |f: &Formula| matches!(f, Formula::True);
let fa = |f: &Formula| matches!(f, Formula::False);
let simp = |traj: &Traj| -> Option<Traj> {
match traj {
Traj::Always(f) => match peval(f) {
f if t(&f) => None,
f => Some(Traj::Always(f)),
},
Traj::Sometime(f) => match peval(f) {
f if t(&f) => None,
f => Some(Traj::Sometime(f)),
},
Traj::AtMostOnce(f) => match peval(f) {
f if t(&f) || fa(&f) => None,
f => Some(Traj::AtMostOnce(f)),
},
Traj::SometimeAfter(a, b) => {
let (a, b) = (peval(a), peval(b));
if fa(&a) || t(&b) {
None
} else {
Some(Traj::SometimeAfter(a, b))
}
}
Traj::SometimeBefore(a, b) => {
let (a, b) = (peval(a), peval(b));
if fa(&a) {
None
} else {
Some(Traj::SometimeBefore(a, b))
}
}
Traj::AtEnd(f) => match peval(f) {
f if t(&f) => None,
f => Some(Traj::AtEnd(f)),
},
}
};
let h0 = exp.hard.len();
let m0: usize = exp.soft.iter().map(|(_, ms)| ms.len()).sum();
exp.hard = exp.hard.iter().filter_map(&simp).collect();
for (_, members) in exp.soft.iter_mut() {
*members = members.iter().filter_map(&simp).collect();
}
let m1: usize = exp.soft.iter().map(|(_, ms)| ms.len()).sum();
if std::env::var("FF_RES_DEBUG").is_ok() && (exp.hard.len(), m1) != (h0, m0) {
eprintln!(
"[P3] constraint static simplification: dropped {} of {} hard, {} of {} soft member(s)",
h0 - exp.hard.len(),
h0,
m0 - m1,
m0
);
}
}
fn reject_reserved_names(domain: &Domain, problem: &Problem) -> Result<(), String> {
let monitor_fact = |n: &str| -> bool {
if n == "TRAJ-PLANNING" || n == "TRAJ-ENDED" {
return true;
}
let Some(rest) = n.strip_prefix("TRAJ") else {
return false;
};
let mut it = rest.splitn(2, '-');
let (num, suf) = (it.next().unwrap_or(""), it.next().unwrap_or(""));
!num.is_empty()
&& num.bytes().all(|b| b.is_ascii_digit())
&& matches!(suf, "VIOL" | "SEEN" | "HOLD" | "PEND" | "SAFE" | "ACC")
};
let anon_pref = |n: &str| -> bool {
n.strip_prefix("TRAJPREF")
.is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
};
for (n, _) in &domain.predicates {
if monitor_fact(n) {
return Err(format!(
"predicate `{n}` collides with ferroplan's reserved trajectory-monitor \
namespace (TRAJ{{n}}-VIOL/SEEN/HOLD/PEND/SAFE/ACC, TRAJ-PLANNING, \
TRAJ-ENDED) used to compile (:constraints ...); rename the predicate"
));
}
}
if let Some(a) = domain.actions.iter().find(|a| a.name == END_ACTION) {
return Err(format!(
"action `{}` collides with ferroplan's reserved trajectory \
end-action name (`{END_ACTION}`) used to compile \
(:constraints ...); rename the action",
a.name
));
}
fn names_c(c: &Constraint, out: &mut Vec<String>) {
match c {
Constraint::And(v) => v.iter().for_each(|x| names_c(x, out)),
Constraint::Forall(_, i) => names_c(i, out),
Constraint::Pref(n, i) => {
if let Some(n) = n {
out.push(n.clone());
}
names_c(i, out);
}
_ => {}
}
}
fn names_f(f: &Formula, out: &mut Vec<String>) {
match f {
Formula::And(v) | Formula::Or(v) => v.iter().for_each(|x| names_f(x, out)),
Formula::Not(a) | Formula::Forall(_, a) | Formula::Exists(_, a) => names_f(a, out),
Formula::Pref(n, a) => {
if let Some(n) = n {
out.push(n.clone());
}
names_f(a, out);
}
_ => {}
}
}
let mut user = Vec::new();
for c in domain.constraints.iter().chain(problem.constraints.iter()) {
names_c(c, &mut user);
}
names_f(&problem.goal, &mut user);
if let Some(n) = user.iter().find(|n| anon_pref(n)) {
return Err(format!(
"preference name `{n}` collides with ferroplan's reserved \
TRAJPREF{{n}} namespace (generated for anonymous constraint \
preferences); rename the preference"
));
}
Ok(())
}
pub(crate) fn strip_end(task: &crate::packed::PackedTask, ops: &mut Vec<usize>) {
ops.retain(|&oi| task.op_display[oi] != END_ACTION);
}
pub fn gate(domain: &Domain, problem: &Problem) -> Result<Option<(Domain, Problem)>, String> {
if domain.constraints.is_empty() && problem.constraints.is_empty() {
return Ok(None);
}
if std::env::var("FF_CONSTRAINTS_REJECT").is_ok() {
return Err(crate::pddl3::unsupported_constraints(domain, problem)
.unwrap_or_else(|| "trajectory constraints rejected (hatch)".into()));
}
if crate::temporal::is_temporal(domain) {
return Err(
"trajectory constraints on durative-action (temporal) domains are \
not yet enforced (the untimed classical path is); remove the \
(:constraints ...) block or the durative actions"
.into(),
);
}
compile(domain, problem).map(Some)
}
pub fn compile(domain: &Domain, problem: &Problem) -> Result<(Domain, Problem), String> {
reject_reserved_names(domain, problem)?;
let mut exp = expand(domain, problem)?;
simplify_static(&mut exp, domain, problem);
let mut d = domain.clone();
let mut p = problem.clone();
if exp.hard.is_empty() && exp.soft.is_empty() {
d.constraints.clear();
p.constraints.clear();
return Ok((d, p));
}
let mut goal_conj: Vec<Formula> = vec![p.goal.clone()];
let mut transitions: Vec<Effect> = Vec::new();
fn emit(
i: usize,
t: &Traj,
d: &mut Domain,
p: &mut Problem,
transitions: &mut Vec<Effect>,
problem: &Problem,
) -> Vec<Formula> {
let init_holds = |f: &Formula| eval_static(f, problem);
let atom = |n: &str| Formula::Atom(n.to_string(), vec![]);
let add = |n: &str| Effect::Add(n.to_string(), vec![]);
let del = |n: &str| Effect::Del(n.to_string(), vec![]);
let declare = |d: &mut Domain, p: &mut Problem, n: &str, init_true: bool| {
d.predicates.push((n.to_string(), vec![]));
if init_true {
p.init_atoms.push((n.to_string(), vec![]));
}
};
let mut acc: Vec<Formula> = Vec::new();
match t {
Traj::Always(f) => {
let viol = format!("TRAJ{i}-VIOL");
declare(d, p, &viol, !init_holds(f));
transitions.push(Effect::When(
Formula::Not(Box::new(f.clone())),
Box::new(add(&viol)),
));
acc.push(Formula::Not(Box::new(atom(&viol))));
acc.push(f.clone()); }
Traj::Sometime(f) => {
let seen = format!("TRAJ{i}-SEEN");
declare(d, p, &seen, init_holds(f));
transitions.push(Effect::When(f.clone(), Box::new(add(&seen))));
acc.push(Formula::Or(vec![atom(&seen), f.clone()]));
}
Traj::AtMostOnce(f) => {
let hold = format!("TRAJ{i}-HOLD");
let seen = format!("TRAJ{i}-SEEN");
let viol = format!("TRAJ{i}-VIOL");
let f0 = init_holds(f);
declare(d, p, &hold, f0);
declare(d, p, &seen, f0);
declare(d, p, &viol, false);
transitions.push(Effect::When(
Formula::And(vec![
f.clone(),
Formula::Not(Box::new(atom(&hold))),
atom(&seen),
]),
Box::new(add(&viol)),
));
transitions.push(Effect::When(
Formula::And(vec![f.clone(), Formula::Not(Box::new(atom(&hold)))]),
Box::new(Effect::And(vec![add(&seen), add(&hold)])),
));
transitions.push(Effect::When(
Formula::And(vec![Formula::Not(Box::new(f.clone())), atom(&hold)]),
Box::new(del(&hold)),
));
acc.push(Formula::Not(Box::new(atom(&viol))));
acc.push(Formula::Not(Box::new(Formula::And(vec![
f.clone(),
Formula::Not(Box::new(atom(&hold))),
atom(&seen),
]))));
}
Traj::SometimeAfter(a, b) => {
let pend = format!("TRAJ{i}-PEND");
declare(d, p, &pend, init_holds(a) && !init_holds(b));
transitions.push(Effect::When(b.clone(), Box::new(del(&pend))));
transitions.push(Effect::When(
Formula::And(vec![a.clone(), Formula::Not(Box::new(b.clone()))]),
Box::new(add(&pend)),
));
acc.push(Formula::Or(vec![
b.clone(),
Formula::And(vec![
Formula::Not(Box::new(atom(&pend))),
Formula::Not(Box::new(a.clone())),
]),
]));
}
Traj::SometimeBefore(a, b) => {
let safe = format!("TRAJ{i}-SAFE");
let viol = format!("TRAJ{i}-VIOL");
declare(d, p, &safe, init_holds(b));
declare(d, p, &viol, init_holds(a)); transitions.push(Effect::When(
Formula::And(vec![a.clone(), Formula::Not(Box::new(atom(&safe)))]),
Box::new(add(&viol)),
));
transitions.push(Effect::When(b.clone(), Box::new(add(&safe))));
acc.push(Formula::Not(Box::new(atom(&viol))));
acc.push(Formula::Or(vec![
Formula::Not(Box::new(a.clone())),
atom(&safe),
]));
}
Traj::AtEnd(f) => {
acc.push(f.clone());
}
}
acc
}
let mut idx = 0usize;
let mut hard_acc: Vec<Vec<Formula>> = Vec::new();
for t in &exp.hard {
hard_acc.push(emit(idx, t, &mut d, &mut p, &mut transitions, problem));
idx += 1;
}
for (name, members) in &exp.soft {
let mut acc: Vec<Formula> = Vec::new();
for t in members {
acc.extend(emit(idx, t, &mut d, &mut p, &mut transitions, problem));
idx += 1;
}
let body = match acc.len() {
0 => Formula::True,
1 => acc.pop().unwrap(),
_ => Formula::And(acc),
};
goal_conj.push(Formula::Pref(Some(name.clone()), Box::new(body)));
}
if !transitions.is_empty() {
if std::env::var("FF_NO_COND_SHARE").is_ok() {
for act in &mut d.actions {
let mut v = vec![act.effect.clone()];
v.extend(transitions.iter().cloned());
act.effect = Effect::And(v);
}
} else {
for act in &mut d.actions {
act.monitored = true;
}
d.monitors = transitions.clone();
}
}
if !hard_acc.is_empty() {
if std::env::var("FF_NO_TRAJ_END").is_ok() {
for acc in hard_acc {
goal_conj.extend(acc);
}
} else {
let atom = |n: &str| Formula::Atom(n.to_string(), vec![]);
d.predicates.push(("TRAJ-PLANNING".to_string(), vec![]));
d.predicates.push(("TRAJ-ENDED".to_string(), vec![]));
p.init_atoms.push(("TRAJ-PLANNING".to_string(), vec![]));
for act in &mut d.actions {
act.precond = Formula::And(vec![act.precond.clone(), atom("TRAJ-PLANNING")]);
}
let mut end_eff: Vec<Effect> = vec![
Effect::Del("TRAJ-PLANNING".to_string(), vec![]),
Effect::Add("TRAJ-ENDED".to_string(), vec![]),
];
for (k, acc) in hard_acc.into_iter().enumerate() {
let accf = format!("TRAJ{k}-ACC");
d.predicates.push((accf.clone(), vec![]));
let cond = match acc.len() {
1 => acc.into_iter().next().unwrap(),
_ => Formula::And(acc),
};
end_eff.push(Effect::When(
cond,
Box::new(Effect::Add(accf.clone(), vec![])),
));
goal_conj.push(atom(&accf));
}
goal_conj.push(atom("TRAJ-ENDED"));
d.actions.push(Action {
name: END_ACTION.to_string(),
params: vec![],
precond: atom("TRAJ-PLANNING"),
effect: Effect::And(end_eff),
monitored: false,
});
}
}
p.goal = Formula::And(goal_conj);
d.constraints.clear();
p.constraints.clear();
Ok((d, p))
}
fn eval_static(f: &Formula, p: &Problem) -> bool {
match f {
Formula::True => true,
Formula::False => false,
Formula::And(v) => v.iter().all(|x| eval_static(x, p)),
Formula::Or(v) => v.iter().any(|x| eval_static(x, p)),
Formula::Not(a) => !eval_static(a, p),
Formula::Pref(_, a) => eval_static(a, p),
Formula::Forall(_, a) | Formula::Exists(_, a) => eval_static(a, p),
Formula::Eq(a, b) => a == b,
Formula::Atom(name, args) => p.init_atoms.iter().any(|(n, a)| {
n.eq_ignore_ascii_case(name)
&& a.len() == args.len()
&& a.iter().zip(args).all(|(x, t)| match t {
crate::types::Term::Const(c) => x.eq_ignore_ascii_case(c),
crate::types::Term::Var(_) => false,
})
}),
Formula::Comp(op, l, r) => {
let ev = |e: &crate::types::Expr| eval_init_expr(e, p);
match (ev(l), ev(r)) {
(Some(l), Some(r)) => match op {
crate::types::CompOp::Lt => l < r,
crate::types::CompOp::Le => l <= r,
crate::types::CompOp::Eq => (l - r).abs() < 1e-6,
crate::types::CompOp::Ge => l >= r,
crate::types::CompOp::Gt => l > r,
},
_ => false,
}
}
}
}
fn eval_init_expr(e: &crate::types::Expr, p: &Problem) -> Option<f64> {
use crate::types::Expr::*;
Some(match e {
Num(n) => *n,
Fluent(name, args) => {
let ((_, _), v) = p.init_fluents.iter().find(|((n, a), _)| {
n.eq_ignore_ascii_case(name)
&& a.len() == args.len()
&& a.iter().zip(args).all(|(x, t)| match t {
crate::types::Term::Const(c) => x.eq_ignore_ascii_case(c),
crate::types::Term::Var(_) => false,
})
})?;
*v
}
Add(a, b) => eval_init_expr(a, p)? + eval_init_expr(b, p)?,
Sub(a, b) => eval_init_expr(a, p)? - eval_init_expr(b, p)?,
Mul(a, b) => eval_init_expr(a, p)? * eval_init_expr(b, p)?,
Div(a, b) => eval_init_expr(a, p)? / eval_init_expr(b, p)?,
Neg(a) => -eval_init_expr(a, p)?,
})
}
#[cfg(test)]
mod grounding_cost {
fn measure(dom: &str, prob: &str, label: &str) -> (usize, usize, usize, u128) {
let d = crate::parser::parse_domain(dom).expect("domain");
let p = crate::parser::parse_problem(prob).expect("problem");
let (d, p) = crate::derived::compile(&d, &p).expect("derived");
let monitors = super::expand(&d, &p).expect("expand").hard.len();
let (d, p) = match super::gate(&d, &p).expect("gate") {
Some(pair) => pair,
None => (d, p),
};
let t0 = std::time::Instant::now();
let task = crate::ground::ground_task(&d, &p, 1).expect("ground");
let ms = t0.elapsed().as_millis();
let cond: usize = (0..task.n_ops).map(|oi| task.n_cond_effs(oi)).sum();
let goal_ops = (0..task.n_ops)
.filter(|&oi| task.op_display[oi].starts_with("REACH-GOAL"))
.count();
println!(
"{label}: {} monitors, {} ops ({} REACH-GOAL), {} facts, \
{} conditional effects, ground {} ms",
monitors, task.n_ops, goal_ops, task.n_facts, cond, ms
);
(task.n_ops, task.n_facts, cond, ms)
}
fn overlay(prob: &str, constraints: &str) -> String {
let i = prob.rfind(')').expect("problem has a closing paren");
format!("{}(:constraints {}){}", &prob[..i], constraints, &prob[i..])
}
#[test]
#[ignore = "heavy: grounding-cost measurement (docs/roadmap-0.7.md Phase 1)"]
fn storage_p05_hard_overlay() {
let base = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../benchmarks/ipc/pref/storage"
);
let dom = std::fs::read_to_string(format!("{base}/domain.pddl")).unwrap();
let prob = std::fs::read_to_string(format!("{base}/p05.pddl")).unwrap();
let (o0, f0, c0, _) = measure(&dom, &prob, "storage p05 unconstrained");
let hard = overlay(
&prob,
"(forall (?h - hoist ?c - crate) (at-most-once (lifting ?h ?c)))",
);
let (o1, f1, c1, _) = measure(&dom, &hard, "storage p05 + hard overlay");
assert!(f1 > f0, "monitor facts must appear ({f0} -> {f1})");
assert!(c1 > c0, "monitor transitions must appear ({c0} -> {c1})");
assert_eq!(
o1,
o0 + 1,
"goal-DNF product must stay gone (docs/roadmap-0.8.md Phase 1)"
);
}
#[test]
#[ignore = "heavy: grounding-cost measurement (docs/roadmap-0.7.md Phase 1)"]
fn trucks_p03_hard_overlay() {
let base = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../benchmarks/ipc/pref/trucks"
);
let dom = std::fs::read_to_string(format!("{base}/domain.pddl")).unwrap();
let prob = std::fs::read_to_string(format!("{base}/p03.pddl")).unwrap();
let (o0, f0, c0, _) = measure(&dom, &prob, "trucks p03 unconstrained");
let hard = overlay(
&prob,
"(forall (?t - truck ?l - location) (at-most-once (at ?t ?l)))",
);
let (o1, f1, c1, _) = measure(&dom, &hard, "trucks p03 + hard overlay");
assert!(f1 > f0, "monitor facts must appear ({f0} -> {f1})");
assert!(c1 > c0, "monitor transitions must appear ({c0} -> {c1})");
assert_eq!(
o1,
o0 + 1,
"goal-DNF product must stay gone (docs/roadmap-0.8.md Phase 1)"
);
}
}