diff --git a/CHANGES.md b/CHANGES.md index a58da8f..86df990 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,15 @@ # Release notes UP Fast Downward 0.3.3 -- fix bug in fast-downward-reachability grounder +- fix bug in fast-downward-reachability grounder (number of parameters) +- Transform task to avoid axioms for goals in Grounder and optimal solver: + If the goal is not a conjunction of literals, Fast Downward introduces + axioms to handle it. To avoid this, we instead introduce an artificial + goal action (removed from plans or when mapping back in grounders). +- With fast-downward-grounder, there can be several ground representatives of + the artificial goal action in the compiled task (e.g. from disjunctive + preconditions). We now give each of them an individual name. - silence output in grounders -- support result status MEMOUT, TIMEOUT and UNSUPPORTED_PROBLEM +- support result status MEMOUT, TIMEOUT and UNSUPPORTED_PROBLEM in solvers UP Fast Downward 0.3.2 - support UP problem kind version 2 diff --git a/setup.py b/setup.py index fbe1acb..5001c3c 100644 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def run(self): package_data={ "": ['fast_downward.py', 'fast_downward_grounder.py', + 'utils.py', 'downward/fast-downward.py', 'downward/README.md', 'downward/LICENSE.md', 'downward/builds/release/bin/*', diff --git a/up_fast_downward/fast_downward.py b/up_fast_downward/fast_downward.py index 3930321..3bc9a85 100644 --- a/up_fast_downward/fast_downward.py +++ b/up_fast_downward/fast_downward.py @@ -2,12 +2,14 @@ import sys import unified_planning as up from typing import Callable, Iterator, IO, List, Optional, Tuple, Union -from unified_planning.model import ProblemKind +from unified_planning.model import ProblemKind, InstantaneousAction from unified_planning.engines import OptimalityGuarantee from unified_planning.engines import PlanGenerationResultStatus as ResultStatus from unified_planning.engines import PDDLAnytimePlanner, PDDLPlanner from unified_planning.engines import OperationMode, Credits +from unified_planning.shortcuts import BoolType, MinimizeActionCosts from unified_planning.engines.results import LogLevel, LogMessage, PlanGenerationResult +from up_fast_downward import utils credits = { "name": "Fast Downward", @@ -265,3 +267,59 @@ def supports(problem_kind: "ProblemKind") -> bool: @staticmethod def satisfies(optimality_guarantee: OptimalityGuarantee) -> bool: return True + + # To avoid the introduction of axioms with complicated goals, we introduce + # a separate goal action (later to be removed from the plan) + def _solve( + self, + problem: "up.model.AbstractProblem", + heuristic: Optional[Callable[["up.model.state.State"], Optional[float]]] = None, + timeout: Optional[float] = None, + output_stream: Optional[Union[Tuple[IO[str], IO[str]], IO[str]]] = None, + ) -> "up.engines.results.PlanGenerationResult": + assert isinstance(problem, up.model.Problem) + + # add a new goal atom (initially false) plus an action that has the + # original goal as precondition and sets the new goal atom + modified_problem, _, _ = utils.introduce_artificial_goal_action(problem) + return super()._solve(modified_problem, heuristic, timeout, output_stream) + + # overwrite plan extraction to remove the newly introduced goal action from + # the end of the plan + def _plan_from_file( + self, + problem: "up.model.Problem", + plan_filename: str, + get_item_named: Callable[ + [str], + Union[ + "up.model.Type", + "up.model.Action", + "up.model.Fluent", + "up.model.Object", + "up.model.Parameter", + "up.model.Variable", + "up.model.multi_agent.Agent", + ], + ], + ) -> "up.plans.Plan": + """ + Takes a problem, a filename and a map of renaming and returns the plan parsed from the file. + :param problem: The up.model.problem.Problem instance for which the plan is generated. + :param plan_filename: The path of the file in which the plan is written. + :param get_item_named: A function that takes a name and returns the original up.model element instance + linked to that renaming. + :return: The up.plans.Plan corresponding to the parsed plan from the file + """ + with open(plan_filename) as plan: + plan_string = plan.read() + # Remove all comments from the plan string. + plan_string = [ + line + for line in plan_string.split("\n") + if not line.strip().startswith(";") + ] + # Remove the last line (= goal action) from the plan string. + plan_string = plan_string[:-2] + plan_string = "\n".join(plan_string) + return self._plan_from_str(problem, plan_string, get_item_named) diff --git a/up_fast_downward/fast_downward_grounder.py b/up_fast_downward/fast_downward_grounder.py index 1934ca8..9c8b380 100644 --- a/up_fast_downward/fast_downward_grounder.py +++ b/up_fast_downward/fast_downward_grounder.py @@ -1,22 +1,28 @@ from collections import defaultdict from io import StringIO +from itertools import count import os.path import sys import unified_planning as up from functools import partial -from typing import Callable, Optional, Union +from typing import Callable, Mapping, Optional, Union, Set, Tuple from unified_planning.model import FNode, Problem, ProblemKind, MinimizeActionCosts from unified_planning.model.walkers import Simplifier from unified_planning.model.action import InstantaneousAction +from unified_planning.model.operators import OperatorKind from unified_planning.engines.compilers.utils import lift_action_instance -from unified_planning.engines.compilers.grounder import Grounder, ground_minimize_action_costs_metric +from unified_planning.engines.compilers.grounder import ( + Grounder, + ground_minimize_action_costs_metric, +) from unified_planning.engines.engine import Engine from unified_planning.engines import Credits from unified_planning.engines.mixins.compiler import CompilationKind from unified_planning.engines.mixins.compiler import CompilerMixin from unified_planning.engines.results import CompilerResult from unified_planning.exceptions import UPUnsupportedProblemTypeError +from up_fast_downward import utils credits = Credits( @@ -104,6 +110,8 @@ def _compile( pddl_problem = writer.get_problem().split("\n") pddl_domain = writer.get_domain().split("\n") + # perform Fast Downward translation until (and including) + # the reachability analysis orig_path = list(sys.path) orig_stdout = sys.stdout sys.stdout = StringIO() @@ -126,6 +134,13 @@ def _compile( prog = prolog_program(task) model = compute_model(prog) sys.stdout = orig_stdout + sys.path = orig_path + + # The model contains an overapproximation of the reachable components + # of the task, in particular also of the reachable ground actions. + # We retreive the parameters from these actions and hand them over to + # the Grounder from the UP, which performs the instantiation on the + # side of the UP. grounding_action_map = defaultdict(list) exp_manager = problem.environment.expression_manager for atom in model: @@ -134,23 +149,17 @@ def _compile( schematic_up_action = writer.get_item_named(action.name) params = ( writer.get_item_named(p) - for p in atom.args[:action.num_external_parameters] + for p in atom.args[: action.num_external_parameters] ) up_params = tuple(exp_manager.ObjectExp(p) for p in params) grounding_action_map[schematic_up_action].append(up_params) - sys.path = orig_path up_grounder = Grounder(grounding_actions_map=grounding_action_map) up_res = up_grounder.compile(problem, compilation_kind) new_problem = up_res.problem new_problem.name = f"{self.name}_{problem.name}" - return CompilerResult( - new_problem, up_res.map_back_action_instance, self.name - ) - - def destroy(self): - pass + return CompilerResult(new_problem, up_res.map_back_action_instance, self.name) class FastDownwardGrounder(Engine, CompilerMixin): @@ -212,6 +221,7 @@ def resulting_problem_kind( ) -> ProblemKind: resulting_problem_kind = problem_kind.clone() resulting_problem_kind.unset_conditions_kind("DISJUNCTIVE_CONDITIONS") + resulting_problem_kind.unset_conditions_kind("EXISTENTIAL_CONDITIONS") return resulting_problem_kind def _get_fnode( @@ -230,6 +240,7 @@ def _get_fnode( ], ], ) -> FNode: + """Translates a Fast Downward fact back into a FNode.""" exp_manager = problem.environment.expression_manager fluent = get_item_named(fact.predicate) args = [problem.object(o) for o in fact.args] @@ -254,15 +265,28 @@ def _transform_action( "up.model.Variable", ], ], + used_action_names: Set[str], ) -> InstantaneousAction: + """Takes a Fast Downward ground actions and builds it with the + vobabulary of the UP.""" + def fnode(fact): return self._get_fnode(fact, problem, get_item_named) + exp_manager = problem.environment.expression_manager name_and_args = fd_action.name[1:-1].split() name = get_item_named(name_and_args[0]).name name_and_args[0] = name - action = InstantaneousAction("_".join(name_and_args)) + full_name = "_".join(name_and_args) + if full_name in used_action_names: + for num in count(): + candidate = f"{full_name}_{num}" + if candidate not in used_action_names: + full_name = candidate + break + used_action_names.add(full_name) + action = InstantaneousAction(full_name) for fact in fd_action.precondition: action.add_precondition(fnode(fact)) for cond, fact in fd_action.add_effects: @@ -273,25 +297,51 @@ def fnode(fact): action.add_effect(fnode(fact), False, c) return action - def _compile( - self, problem: "up.model.AbstractProblem", compilation_kind: "CompilationKind" - ) -> CompilerResult: + def _add_goal_action_if_complicated_goal( + self, problem: "up.model.AbstractProblem" + ) -> Tuple[ + "up.model.AbstractProblem", + Optional["up.model.InstantaneousAction"], + Optional[ + Mapping["up.model.InstantaneousAction", "up.model.InstantaneousAction"] + ], + ]: """ - Takes an instance of a :class:`~up.model.Problem` and the `GROUNDING` - :class:`~up.engines.CompilationKind` and returns a `CompilerResult` - where the problem does not have actions with parameters; so every - action is grounded. - :param problem: The instance of the `Problem` that must be grounded. - :param compilation_kind: The `CompilationKind` that must be applied on - the given problem; only `GROUNDING` is supported by this compiler - :return: The resulting `CompilerResult` data structure. + Tests whether the given problem has a complicated goal (not just + a conjunction of positive and negative fluents). If yes, it returns + a transformed problem with an artificial goal action and a single goal + fluent, where the existing actions are modified to delete the goal fluent. + The second return value is the new goal action. The third return value + maps the actions of the modified problem to the actions of the original + problem. If the goal was not complicated, it returns the original + problem, None, and None. """ - assert isinstance(problem, Problem) + COMPLICATED_KINDS = ( + OperatorKind.EXISTS, + OperatorKind.FORALL, + OperatorKind.IFF, + OperatorKind.IMPLIES, + OperatorKind.OR, + ) - writer = up.io.PDDLWriter(problem) - pddl_problem = writer.get_problem().split("\n") - pddl_domain = writer.get_domain().split("\n") + def is_complicated_goal(fnode): + if fnode.node_type in COMPLICATED_KINDS or ( + fnode.node_type == OperatorKind.NOT + and fnode.args[0].node_type != OperatorKind.FLUENT_EXP + ): + return True + return any(is_complicated_goal(arg) for arg in fnode.args) + + if not any(is_complicated_goal(g) for g in problem.goals): + # no complicated goal + return problem, None, None + else: + # To avoid the introduction of axioms with complicated goals, we + # introduce a separate goal action (later to be removed by + # map_back) + return utils.introduce_artificial_goal_action(problem, True) + def _instantiate_with_fast_downward(self, pddl_problem, pddl_domain): orig_path = list(sys.path) orig_stdout = sys.stdout sys.stdout = StringIO() @@ -312,32 +362,65 @@ def _compile( _, _, actions, goals, axioms, _ = fd_instantiate.explore(task) sys.stdout = orig_stdout + sys.path = orig_path + return actions, goals, axioms + + def _compile( + self, problem: "up.model.AbstractProblem", compilation_kind: "CompilationKind" + ) -> CompilerResult: + assert isinstance(problem, Problem) + # If necessary, perform goal transformation to avoid the introduction + # of axioms. + ( + problem, + artificial_goal_action, + modified_to_orig_action, + ) = self._add_goal_action_if_complicated_goal(problem) + + # Ground the problem with Fast Downward + writer = up.io.PDDLWriter(problem) + pddl_problem = writer.get_problem().split("\n") + pddl_domain = writer.get_domain().split("\n") + + actions, goals, axioms = self._instantiate_with_fast_downward( + pddl_problem, pddl_domain + ) if axioms: raise UPUnsupportedProblemTypeError(axioms_msg) + # Rebuild the ground problem from Fast Downward in the UP new_problem = problem.clone() new_problem.name = f"{self.name}_{problem.name}" new_problem.clear_actions() new_problem.clear_goals() trace_back_map = dict() - + used_action_names = set() exp_manager = problem.environment.expression_manager + # Construct Fast Downward ground actions in the UP and remember the + # mapping from the ground actions to the original actions. for a in actions: - inst_action = self._transform_action(a, new_problem, writer.get_item_named) + inst_action = self._transform_action( + a, new_problem, writer.get_item_named, used_action_names + ) name_and_args = a.name[1:-1].split() - schematic_up_action = writer.get_item_named(name_and_args[0]) - params = (writer.get_item_named(p) for p in name_and_args[1:]) - up_params = tuple(exp_manager.ObjectExp(p) for p in params) - trace_back_map[inst_action] = (schematic_up_action, up_params) + schematic_up_act = writer.get_item_named(name_and_args[0]) + if schematic_up_act == artificial_goal_action: + trace_back_map[inst_action] = None + else: + if modified_to_orig_action is not None: + schematic_up_act = modified_to_orig_action[schematic_up_act] + params = (writer.get_item_named(p) for p in name_and_args[1:]) + up_params = tuple(exp_manager.ObjectExp(p) for p in params) + trace_back_map[inst_action] = (schematic_up_act, up_params) new_problem.add_action(inst_action) + # Construct Fast Downward goals in the UP for g in goals: fnode = self._get_fnode(g, new_problem, writer.get_item_named) new_problem.add_goal(fnode) - sys.path = orig_path new_problem.clear_quality_metrics() for qm in problem.quality_metrics: @@ -347,11 +430,18 @@ def _compile( else: new_problem.add_quality_metric(qm) + # We need to use a more complicated function for mapping back the + # actions because "partial(lift_action_instance, map=trace_back_map)" + # cannot map an action to None (we need this for the artificial goal + # action). + mbai = lambda x: ( + None + if trace_back_map[x.action] is None + else partial(lift_action_instance, map=trace_back_map)(x) + ) + return CompilerResult( new_problem, - partial(lift_action_instance, map=trace_back_map), + mbai, self.name, ) - - def destroy(self): - pass diff --git a/up_fast_downward/utils.py b/up_fast_downward/utils.py new file mode 100644 index 0000000..05188e7 --- /dev/null +++ b/up_fast_downward/utils.py @@ -0,0 +1,65 @@ +from itertools import count +from unified_planning.shortcuts import BoolType, MinimizeActionCosts +from unified_planning.model import InstantaneousAction +from typing import Mapping, Optional, Tuple + + +def introduce_artificial_goal_action( + problem: "up.model.AbstractProblem", other_actions_destroy_goal=False +) -> Tuple[ + "up.model.AbstractProblem", + Optional["up.model.InstantaneousAction"], + Optional[Mapping["up.model.InstantaneousAction", "up.model.InstantaneousAction"]], +]: + """ + Clones the task and adds an artificial goal atom and an additional action + that achieves it when the original goal is satisfied. Parameter + other_actions_destroy_goal indicates whether all original actions should + destroy the artificial goal atom. This is not necessary if we run our + planner on the transformed problem (because search terminates once it finds + a plan). If we use this in grounding, we do not know what others do with + the task. Thus we need this option to ensure that for every plan of the + transformed task, omitting all occurrences of the artificial goal action + gives a plan for the original task. + """ + + def get_new_name(problem, prefix): + for num in count(): + candidate = f"{prefix}{num}" + if not problem.has_name(candidate): + return candidate + + modified_problem = problem.clone() + # add a new goal atom (initially false) plus an action that has the + # original goal as precondition and sets the new goal atom + goal_fluent_name = get_new_name(modified_problem, "goal") + goal_fluent = modified_problem.add_fluent( + goal_fluent_name, BoolType(), default_initial_value=False + ) + + if other_actions_destroy_goal: + for action in modified_problem.actions: + action.add_effect(goal_fluent, False) + modified_to_orig_action = dict( + elem for elem in zip(modified_problem.actions, problem.actions) + ) + + goal_action_name = get_new_name(modified_problem, "reach_goal") + goal_action = InstantaneousAction(goal_action_name) + for goal in modified_problem.goals: + goal_action.add_precondition(goal) + goal_action.add_effect(goal_fluent, True) + modified_problem.add_action(goal_action) + modified_problem.clear_goals() + modified_problem.add_goal(goal_fluent) + if modified_problem.quality_metrics and isinstance( + modified_problem.quality_metrics[0], MinimizeActionCosts + ): + m = modified_problem.quality_metrics[0] + action_costs = m.costs + action_costs[goal_action] = 1 + metric = MinimizeActionCosts(action_costs, m.default, m.environment) + modified_problem.clear_quality_metrics() + modified_problem.add_quality_metric(metric) + + return modified_problem, goal_action, modified_to_orig_action