Skip to content

Commit f4c80b5

Browse files
authored
Merge pull request #157 from plaans/feat/num-sym-break
feat(planning): Extend symmetry breaking to numeric planning problems
2 parents b936047 + a273208 commit f4c80b5

File tree

7 files changed

+260
-91
lines changed

7 files changed

+260
-91
lines changed

ci/ipc.py

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import subprocess # nosec: B404
55
import sys
66
import time
7-
from typing import List, Optional, Tuple
7+
from typing import Dict, List, Optional, Tuple
88

99
from unified_planning.engines.results import PlanGenerationResultStatus as Status
1010
from unified_planning.io.pddl_reader import PDDLReader
@@ -39,6 +39,7 @@
3939
"blink": 5,
4040
"invert": 7,
4141
}
42+
SLOW_THRESHOLD = 5
4243
VALID_STATUS = {Status.SOLVED_SATISFICING, Status.SOLVED_OPTIMALLY}
4344

4445
IS_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true"
@@ -57,7 +58,14 @@ def write(text: str = "", **markup: bool) -> None:
5758
esc = [ESC_TABLE[name] for name, value in markup.items() if value]
5859
if esc:
5960
text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m"
60-
print(text)
61+
print(text, end="")
62+
63+
64+
def write_line(text: str = "", **markup: bool) -> None:
65+
"""Write a line of text with ANSI escape sequences for formatting."""
66+
write(text, **markup)
67+
if not text.endswith("\n"):
68+
print()
6169

6270

6371
# pylint: disable=too-many-arguments
@@ -80,8 +88,8 @@ def separator(
8088
if len(line) + len(sep.rstrip()) <= width:
8189
line += sep.rstrip()
8290
if github_group and IS_GITHUB_ACTIONS:
83-
write(f"::group::{title}")
84-
write(f"{before}{line}{after}", **markup)
91+
write_line(f"::group::{title}")
92+
write_line(f"{before}{line}{after}", **markup)
8593

8694

8795
def big_separator(
@@ -180,11 +188,22 @@ def validate_plan_with_val(pb: Path, dom: Path, plan: Path) -> bool:
180188
if len(sys.argv) > 1:
181189
problems = [pb for pb in problems if pb.stem in sys.argv[1:]]
182190

191+
last_results: Dict[str, Tuple[str, float]] = {}
192+
last_results_file = Path("/tmp/aries_ci_ipc_last_best_results.csv") # nosec: B108
193+
if last_results_file.exists():
194+
lines = last_results_file.read_text(encoding="utf-8").splitlines()
195+
for _line in lines:
196+
pb, s, t = _line.split(",")
197+
last_results[pb] = (s, float(t))
198+
183199
valid: List[str] = []
184200
invalid: List[str] = []
185201
unsolved: List[Tuple[str, str]] = []
186202
update_depth: List[str] = []
187203

204+
timing: Dict[str, float] = {}
205+
status: Dict[str, str] = {}
206+
188207
try:
189208
for i, folder in enumerate(problems):
190209
separator(
@@ -194,7 +213,7 @@ def validate_plan_with_val(pb: Path, dom: Path, plan: Path) -> bool:
194213
blue=True,
195214
)
196215

197-
out_file = f"/tmp/aries-{folder.stem}.log"
216+
out_file = f"/tmp/aries-{folder.stem}.log" # nosec: B108
198217

199218
try:
200219
domain = folder / "domain.pddl"
@@ -207,32 +226,33 @@ def validate_plan_with_val(pb: Path, dom: Path, plan: Path) -> bool:
207226
max_depth = 100 # pylint: disable=invalid-name
208227

209228
with open(out_file, mode="w+", encoding="utf-8") as output:
210-
write(f"Output log: {output.name}\n")
229+
write_line(f"Output log: {output.name}\n")
211230

212231
with OneshotPlanner(
213232
name="aries",
214233
params={"max-depth": max_depth},
215234
) as planner:
216235
# Problem Kind
217-
write("Problem Kind", cyan=True)
218-
write(str(upf_pb.kind), light=True)
219-
write()
236+
write_line("Problem Kind", cyan=True)
237+
write_line(str(upf_pb.kind), light=True)
238+
write_line()
220239

221240
# Unsupported features
222241
unsupported = upf_pb.kind.features.difference(
223242
planner.supported_kind().features
224243
)
225244
if unsupported:
226-
write("Unsupported Features", cyan=True)
227-
write("\n".join(unsupported), light=True)
228-
write()
245+
write_line("Unsupported Features", cyan=True)
246+
write_line("\n".join(unsupported), light=True)
247+
write_line()
229248

230249
# Resolution
231250
start = time.time()
232251
result = planner.solve(upf_pb, output_stream=output)
233-
write("Resolution status", cyan=True)
234-
write(f"Elapsed time: {time.time() - start:.3f} s", light=True)
235-
write(f"Status: {result.status}", light=True)
252+
write_line("Resolution status", cyan=True)
253+
timing[folder.stem] = time.time() - start
254+
write_line(f"Elapsed time: {timing[folder.stem]:.3f} s", light=True)
255+
write_line(f"Status: {result.status}", light=True)
236256

237257
# Update max depth
238258
if not has_max_depth:
@@ -243,57 +263,96 @@ def validate_plan_with_val(pb: Path, dom: Path, plan: Path) -> bool:
243263

244264
# Solved problem
245265
if result.status in VALID_STATUS:
246-
write("\nValidating plan by Val", cyan=True)
266+
write_line("\nValidating plan by Val", cyan=True)
247267
out_path = Path(output.name)
248268
extract_plan_for_val(result.plan, domain, problem, out_path)
249269
if validate_plan_with_val(problem, domain, out_path):
250-
write("Plan is valid", bold=True, green=True)
270+
write_line("Plan is valid", bold=True, green=True)
251271
valid.append(folder.stem)
272+
status[folder.stem] = "valid"
252273
else:
253-
write("Plan is invalid", bold=True, red=True)
274+
write_line("Plan is invalid", bold=True, red=True)
254275
invalid.append(folder.stem)
276+
status[folder.stem] = "invalid"
255277

256278
# Unsolved problem
257279
else:
280+
write_line(
281+
Path(out_file).read_text(encoding="utf-8"), yellow=True
282+
)
258283
unsolved.append((folder.stem, result.status.name))
259-
write("Unsolved problem", bold=True, red=True)
284+
status[folder.stem] = result.status.name.lower()
285+
write_line("Unsolved problem", bold=True, red=True)
260286

261287
except Exception as e: # pylint: disable=broad-except
262288
unsolved.append((folder.stem, "Error"))
263-
write(f"Error: {e}", bold=True, red=True)
264-
write()
265-
write(Path(out_file).read_text(encoding="utf-8"), yellow=True)
289+
status[folder.stem] = "error"
290+
write_line(f"Error: {e}", bold=True, red=True)
291+
write_line()
292+
write_line(Path(out_file).read_text(encoding="utf-8"), yellow=True)
266293

267294
finally:
268295
if IS_GITHUB_ACTIONS:
269-
write("::endgroup::")
296+
write_line("::endgroup::")
270297

271298
except KeyboardInterrupt:
272299
pass
273300
finally:
274301
# Summary
275302
separator(title="Summary", bold=True, blue=True)
276303

304+
csv_data = "" # pylint: disable=invalid-name
305+
for folder in problems:
306+
t = min( # type: ignore
307+
timing[folder.stem],
308+
(
309+
last_results[folder.stem][1]
310+
if folder.stem in last_results
311+
else float("inf")
312+
),
313+
)
314+
csv_data += f"{folder.stem},{status[folder.stem]},{t}\n"
315+
last_results_file.write_text(csv_data, encoding="utf-8")
316+
277317
if valid:
278318
big_separator(title=f"Valid: {len(valid)}", bold=True, green=True)
319+
offset = max(map(len, valid)) + 3
279320
for res in valid:
280-
print(res)
321+
time_str = f" {last_results[res][1]:.3f} -> " if res in last_results else ""
322+
time_str += f"{timing[res]:.3f}"
323+
write(f"{res:<{offset}} ")
324+
if res in last_results and timing[res] < last_results[res][1]:
325+
write_line(time_str, bold=True, green=True)
326+
elif (
327+
res in last_results
328+
and timing[res] - last_results[res][1] > SLOW_THRESHOLD
329+
):
330+
write_line(time_str, red=True)
331+
else:
332+
write_line(time_str)
333+
334+
slow = list(filter(lambda t: t[1] > SLOW_THRESHOLD, timing.items()))
335+
if slow:
336+
big_separator(title=f"Slow: {len(slow)}", bold=True, yellow=True)
337+
offset = max(map(len, map(lambda t: t[0], slow))) + 3
338+
for res, t in sorted(slow, key=lambda t: t[1], reverse=True): # type: ignore
339+
write_line(f"{res:<{offset}} {t:.3f}")
281340

282341
if invalid:
283342
big_separator(title=f"Invalid: {len(invalid)}", bold=True, red=True)
284343
for res in invalid:
285-
print(res)
344+
write_line(res)
286345

287346
if unsolved:
288347
big_separator(title=f"Unsolved: {len(unsolved)}", bold=True, red=True)
289348
offset = max(map(len, map(lambda t: t[0], unsolved))) + 3
290349
for res, reason in unsolved:
291-
print(f"{res:<{offset}} {reason}")
350+
write_line(f"{res:<{offset}} {reason}")
292351

293352
if update_depth:
294353
big_separator(title=f"Updated depth: {len(update_depth)}", bold=True)
295354
for res in update_depth:
296-
print(res)
355+
write_line(res)
297356

298357
EXIT_CODE = 0 if not invalid and not unsolved else 1
299358
big_separator(title=f"End with code {EXIT_CODE}", bold=True)

planning/planners/src/encode.rs

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use aries::core::*;
1212
use aries::model::extensions::{AssignmentExt, Shaped};
1313
use aries::model::lang::linear::LinearSum;
1414
use aries::model::lang::mul::EqVarMulLit;
15-
use aries::model::lang::{expr::*, Atom, IVar, Kind, Type};
15+
use aries::model::lang::{expr::*, Atom, IVar, Type};
1616
use aries::model::lang::{FAtom, FVar, IAtom, Variable};
1717
use aries_planning::chronicles::constraints::encode_constraint;
1818
use aries_planning::chronicles::*;
@@ -486,16 +486,6 @@ pub struct EncodedProblem {
486486
pub encoding: Encoding,
487487
}
488488

489-
/// Returns whether the effect is an assignment.
490-
fn is_assignment(eff: &Effect) -> bool {
491-
matches!(eff.operation, EffectOp::Assign(_))
492-
}
493-
494-
/// Returns whether the state variable is numeric.
495-
fn is_integer(sv: &StateVar) -> bool {
496-
matches!(sv.fluent.return_type().into(), Kind::Int)
497-
}
498-
499489
/// Returns whether two state variables are unifiable.
500490
fn unifiable_sv(model: &Model, sv1: &StateVar, sv2: &StateVar) -> bool {
501491
sv1.fluent == sv2.fluent && model.unifiable_seq(&sv1.args, &sv2.args)
@@ -664,7 +654,7 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
664654

665655
for &(cond_id, prez_cond, cond) in &conds {
666656
// skip conditions on numeric state variables, they are supported by numeric support constraints
667-
if is_integer(&cond.state_var) {
657+
if is_numeric(&cond.state_var) {
668658
continue;
669659
}
670660
if solver.model.entails(!prez_cond) {
@@ -736,7 +726,7 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
736726
let mut num_numeric_assignment_coherence_constraints = 0;
737727

738728
for &(_, prez, ass) in &assigns {
739-
if !is_integer(&ass.state_var) {
729+
if !is_numeric(&ass.state_var) {
740730
continue;
741731
}
742732
let Type::Int { lb, ub } = ass.state_var.fluent.return_type() else {
@@ -745,25 +735,31 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
745735
let EffectOp::Assign(val) = ass.operation else {
746736
unreachable!()
747737
};
748-
let Atom::Int(val) = val else { unreachable!() };
749-
solver.enforce(geq(val, lb), [prez]);
750-
solver.enforce(leq(val, ub), [prez]);
738+
if let Atom::Int(val) = val {
739+
solver.enforce(geq(val, lb), [prez]);
740+
solver.enforce(leq(val, ub), [prez]);
741+
} else if let Atom::Fixed(val) = val {
742+
solver.enforce(f_geq(val, FAtom::new((lb * val.denom).into(), val.denom)), [prez]);
743+
solver.enforce(f_leq(val, FAtom::new((ub * val.denom).into(), val.denom)), [prez]);
744+
} else {
745+
unreachable!();
746+
}
751747
num_numeric_assignment_coherence_constraints += 1;
752748
}
753749

754750
tracing::debug!(%num_numeric_assignment_coherence_constraints);
755751
solver.propagate()?;
756752
}
757753

758-
let mut increase_coherence_conditions: Vec<(Lit, Condition)> = Vec::with_capacity(incs.len());
754+
let mut increase_coherence_conditions: Vec<(CondID, Lit, Condition)> = Vec::with_capacity(incs.len());
759755
{
760756
// numeric increase coherence constraints
761757
let span = tracing::span!(tracing::Level::TRACE, "numeric increase coherence");
762758
let _span = span.enter();
763759
let mut num_numeric_increase_coherence_constraints = 0;
764760

765761
for &(inc_id, prez, inc) in &incs {
766-
assert!(is_integer(&inc.state_var));
762+
assert!(is_numeric(&inc.state_var));
767763
assert!(
768764
inc.transition_start + FAtom::EPSILON == inc.transition_end && inc.min_mutex_end.is_empty(),
769765
"Only instantaneous increases are supported"
@@ -772,12 +768,17 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
772768
let Type::Int { lb, ub } = inc.state_var.fluent.return_type() else {
773769
unreachable!()
774770
};
771+
772+
if lb == INT_CST_MIN && ub == INT_CST_MAX {
773+
continue;
774+
}
775775
let var = solver
776776
.model
777777
.new_ivar(lb, ub, Container::Instance(inc_id.instance_id) / VarType::Reification);
778778
// Check that the state variable value is equals to the new variable `var`.
779779
// It will force the state variable to be in the bounds of the new variable after the increase.
780780
increase_coherence_conditions.push((
781+
CondID::new_post_increase(inc_id.instance_id, inc_id.eff_id),
781782
prez,
782783
Condition {
783784
start: inc.transition_end,
@@ -801,21 +802,24 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
801802

802803
let numeric_conds: Vec<_> = conds
803804
.iter()
804-
.filter(|(_, _, cond)| is_integer(&cond.state_var))
805-
.map(|&(_, prez, cond)| (prez, cond.clone()))
805+
.filter(|(_, _, cond)| is_numeric(&cond.state_var))
806+
.map(|&(id, prez, cond)| (id, prez, cond.clone()))
806807
.chain(increase_coherence_conditions)
807808
.collect();
808809

809-
for (cond_prez, cond) in &numeric_conds {
810+
for (cond_id, cond_prez, cond) in &numeric_conds {
810811
// skip conditions on non-numeric state variables, they have already been supported by support constraints
811-
assert!(is_integer(&cond.state_var));
812+
assert!(is_numeric(&cond.state_var));
812813
if solver.model.entails(!*cond_prez) {
813814
continue;
814815
}
815-
let Atom::Int(cond_val) = cond.value else {
816-
unreachable!()
816+
let cond_val = match cond.value {
817+
Atom::Int(val) => FAtom::new(val, 1),
818+
Atom::Fixed(val) => val,
819+
_ => unreachable!(),
817820
};
818821
let mut supported: Vec<Lit> = Vec::with_capacity(128);
822+
let mut inc_support: HashMap<EffID, Vec<Lit>> = HashMap::new();
819823

820824
for &(ass_id, ass_prez, ass) in &assigns {
821825
if solver.model.entails(!ass_prez) {
@@ -851,11 +855,12 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
851855
if solver.model.entails(!supported_by) {
852856
continue;
853857
}
858+
encoding.tag(supported_by, Tag::Support(*cond_id, ass_id));
854859

855860
// the expected condition value
856861
let mut cond_val_sum = LinearSum::from(ass_val) - cond_val;
857862

858-
for &(_, inc_prez, inc) in &incs {
863+
for &(inc_id, inc_prez, inc) in &incs {
859864
if solver.model.entails(!inc_prez) {
860865
continue;
861866
}
@@ -895,6 +900,7 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
895900
if solver.model.entails(!active_inc) {
896901
continue;
897902
}
903+
inc_support.entry(inc_id).or_default().push(active_inc);
898904
for term in inc_val.terms() {
899905
// compute some static implication for better propagation
900906
let p = solver.model.presence_literal(term.var().into());
@@ -922,6 +928,11 @@ pub fn encode(pb: &FiniteProblem, metric: Option<Metric>) -> std::result::Result
922928
num_numeric_support_constraints += 1;
923929
}
924930

931+
for (inc_id, inc_support) in inc_support {
932+
let supported_by_inc = solver.reify(or(inc_support));
933+
encoding.tag(supported_by_inc, Tag::Support(*cond_id, inc_id));
934+
}
935+
925936
solver.enforce(or(supported), [*cond_prez]);
926937
}
927938

0 commit comments

Comments
 (0)