diff --git a/README.md b/README.md index 36ab0e5..e285761 100644 --- a/README.md +++ b/README.md @@ -315,9 +315,13 @@ There are wonderful material at the the University of Edinburgh: * Concurrent temporal planning (required action overlap, e.g. match/cellar). * Heuristic improvements for the reference planners. -* ADL (conditional effects, quantifiers) and a full and/or/not precondition tree. +* ADL on the goal side (`or`/`forall`/`exists` inside `(:goal ...)`) and + `(either ...)` union types. -Done recently: case-insensitive keywords, `:requirements` capture/enforcement, +Done recently: ADL support (#10) — conditional/universal effects, quantified +and full-boolean preconditions and `=` equality, evaluated end-to-end by the +reference planners — plus case-insensitive keywords, `:requirements` +capture/enforcement, a planner interface with BFS/A*/GBFS/UCS reference planners, numeric fluents (`:functions`, numeric preconditions/effects), action costs (`total-cost` + cost-aware search), durative-action recovery into the object model, a diff --git a/docs/object-model.md b/docs/object-model.md index 1b88281..237c8f0 100644 --- a/docs/object-model.md +++ b/docs/object-model.md @@ -52,10 +52,34 @@ tuple. **`Atom` has no value equality** — `initialstate()`/`goals()` hold tuples. Normalize with `pddlpy.planning.atom_tuple` (or compare `repr`s). ### `Operator` -An instantaneous action, grounded or not. Fields: `operator_name`, -`variable_list`, `precondition_pos`/`precondition_neg`, +An instantaneous action, grounded or not. The flat STRIPS-style fields — +`operator_name`, `variable_list`, `precondition_pos`/`precondition_neg`, `precondition_connective` (`'and'`/`'or'`, #13), `effect_pos`/`effect_neg`, -and the numeric `precondition_num`/`effect_num` (#11). +and the numeric `precondition_num`/`effect_num` (#11) — summarise the +unconditional conjunctive part of an action and remain the fast path for plain +STRIPS domains. + +**ADL (#10).** For full boolean / quantified / equality preconditions and +conditional / universal effects, an `Operator` also carries structured trees: + +- `precondition_tree` (lifted) and `precondition` (grounded) — a `Condition` + tree of `Lit` / `Not` / `And` / `Or` / `Equality` / `NumericCond` / + `Exists` / `Forall`. `condition.holds(atoms, fluents)` evaluates the whole + tree, so `or`, `imply`, `=` and quantifiers are **evaluated**, not merely + preserved. `State.applicable` uses the grounded tree when present. +- `effect_tree` (lifted) and `conditional_effects` (grounded) — an `Effect` + tree (`AddDel` / `NumEff` / `EffAnd` / `When` / `Universal`). Grounding + compiles it into the unconditional `effect_pos`/`effect_neg`/`effect_num` + plus a list of `CondEffect` guards that `State.apply` fires only when their + condition holds in the pre-state. + +Quantifiers are grounded by **expansion over the world objects**: +`(forall (?x - t) φ)` becomes the `And` of `φ` instantiated for every object of +type `t` (vacuously *true* when there are none), and `(exists …)` the +corresponding `Or` (vacuously *false*). The `simple_conjunction` flag records +whether the precondition is a plain conjunction of literals/numerics; anything +richer makes the grounder fall back from static pruning to the full cartesian +product so no applicable binding is dropped. ### Numeric fluents (#11) `Expr` tree — `Num`, `Fluent`, `BinOp`, `Neg` — each with @@ -164,8 +188,11 @@ a follow-up to this planner. - **Union types:** the `:types` hierarchy is supported (#22) — a parameter typed with a supertype binds objects of any transitive subtype — but `(either ...)` union types are not handled. -- **Disjunctive/ADL preconditions:** the `or` connective is preserved but not - evaluated; the reference planners reject such domains via capability checks. +- **ADL (#10):** conditional/universal effects, quantified and full-boolean + preconditions and `=` equality are parsed, grounded and **solved** by the + reference planners. One gap remains: ADL structure on the **goal** side of a + problem (`or`/`forall`/`exists` in `(:goal …)`) is still flattened to a + conjunction of atoms. - **Durative actions:** solved sequentially by the `temporal` planner (#84); the reference STRIPS planners remain non-temporal. **Required concurrency** (mandatory action overlap) and PDDL+ processes/events are out of scope. diff --git a/pddl.g4 b/pddl.g4 index 9595236..763cf82 100644 --- a/pddl.g4 +++ b/pddl.g4 @@ -223,6 +223,7 @@ goalDesc | '(' 'imply' goalDesc goalDesc ')' | '(' 'exists' '(' typedVariableList ')' goalDesc ')' | '(' 'forall' '(' typedVariableList ')' goalDesc ')' + | '(' '=' term term ')' | fComp ; diff --git a/pddlpy/binding.py b/pddlpy/binding.py index 19489e3..aaf3c08 100644 --- a/pddlpy/binding.py +++ b/pddlpy/binding.py @@ -88,10 +88,12 @@ class StaticPrunedBinder(VariableBinder): def bind(self, dp, operator) -> Iterator[Dict[str, str]]: # Pruning assumes the positive preconditions must all hold (a - # conjunction). For a disjunctive precondition (#13) the flattened atoms - # are alternatives, not conjuncts, so static joining would be unsound — - # fall back to the full cartesian product. - if operator.precondition_connective != 'and': + # conjunction). If the precondition is anything richer than a plain + # conjunction of literals/numerics — a disjunction (#13), a quantifier, + # an equality, or a nested negation (#10) — the flat positive atoms are + # not guaranteed conjuncts, so static joining would be unsound: fall + # back to the full cartesian product. + if not getattr(operator, 'simple_conjunction', True): yield from CartesianBinder().bind(dp, operator) return diff --git a/pddlpy/diagnostics.py b/pddlpy/diagnostics.py index 7ef1604..63bbd12 100644 --- a/pddlpy/diagnostics.py +++ b/pddlpy/diagnostics.py @@ -57,9 +57,14 @@ def _condition_atoms(dp: DomainProblem) -> Iterator[Tuple[str, Any]]: for atom in dp.goals(): yield ":goal", atom for name, op in dp.domain.operators.items(): - for group in (op.precondition_pos, op.precondition_neg, - op.effect_pos, op.effect_neg): - for atom in group: + # Walk the ADL precondition/effect trees so atoms nested inside + # or/forall/exists/when/= are checked too (#10), not just the flat + # unconditional summary sets. + if op.precondition_tree is not None: + for atom in op.precondition_tree.atoms(): + yield "action %r" % name, atom + if op.effect_tree is not None: + for atom in op.effect_tree.atoms(): yield "action %r" % name, atom for name, dop in dp.domain.durative_operators.items(): for timed in (dop.condition_pos, dop.condition_neg): diff --git a/pddlpy/pddl.py b/pddlpy/pddl.py index 4ea7baf..c04d744 100644 --- a/pddlpy/pddl.py +++ b/pddlpy/pddl.py @@ -28,8 +28,20 @@ """ from __future__ import annotations +import itertools import operator as _operator -from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple, cast +from typing import ( + AbstractSet, + Any, + Dict, + Iterator, + List, + Optional, + Sequence, + Set, + Tuple, + cast, +) from antlr4 import CommonTokenStream, FileStream, ParseTreeWalker @@ -229,6 +241,485 @@ def _parse_fcomp(ctx: Any) -> NumericConstraint: _parse_fexp(_nth_fexp(ctx, 1))) +# -------------------------------------------------------------------------- +# ADL condition trees (#10): full boolean / quantified / equality +# preconditions. A Condition grounds its variables — expanding forall/exists +# over the world objects into And/Or — and then evaluates against a set of +# ground atoms plus a numeric valuation. ``State.applicable`` evaluates a +# grounded operator's precondition tree directly, so and/or/not/imply/=/ +# forall/exists are all honoured, not just a top-level connective (#13). +# -------------------------------------------------------------------------- + +class Condition(): + """Base class for a precondition / goal condition node.""" + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "Condition": + raise NotImplementedError # pragma: no cover - abstract + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + raise NotImplementedError # pragma: no cover - abstract + + def features(self) -> Set[str]: + """PDDL feature keywords exercised by this subtree (for #9 checks).""" + return set() + + def atoms(self) -> Iterator[Atom]: + """Predicate literals referenced anywhere in this subtree (diagnostics).""" + return iter(()) + + +class Lit(Condition): + """A predicate literal, e.g. ``(on ?x ?y)``.""" + + def __init__(self, atom: Atom) -> None: + self.atom = atom + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "Lit": + return Lit(Atom(self.atom.ground(varvals))) + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + return tuple(self.atom.predicate) in atoms + + def atoms(self) -> Iterator[Atom]: + yield self.atom + + def __repr__(self) -> str: + return repr(self.atom) + + +class Not(Condition): + """Negation of a condition. Wraps a negative literal or an arbitrary tree.""" + + def __init__(self, child: Condition) -> None: + self.child = child + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "Not": + return Not(self.child.ground(varvals, dp)) + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + return not self.child.holds(atoms, fluents) + + def features(self) -> Set[str]: + return {"negative-preconditions"} | self.child.features() + + def atoms(self) -> Iterator[Atom]: + yield from self.child.atoms() + + def __repr__(self) -> str: + return "(not %r)" % (self.child,) + + +class And(Condition): + """Conjunction: holds iff every child holds (vacuously true when empty).""" + + def __init__(self, children: Sequence[Condition]) -> None: + self.children = list(children) + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "And": + return And([c.ground(varvals, dp) for c in self.children]) + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + return all(c.holds(atoms, fluents) for c in self.children) + + def features(self) -> Set[str]: + return set().union(*(c.features() for c in self.children)) if self.children else set() + + def atoms(self) -> Iterator[Atom]: + for c in self.children: + yield from c.atoms() + + def __repr__(self) -> str: + return "(and %s)" % " ".join(repr(c) for c in self.children) + + +class Or(Condition): + """Disjunction: holds iff some child holds (vacuously false when empty).""" + + def __init__(self, children: Sequence[Condition]) -> None: + self.children = list(children) + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "Or": + return Or([c.ground(varvals, dp) for c in self.children]) + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + return any(c.holds(atoms, fluents) for c in self.children) + + def features(self) -> Set[str]: + feats = {"disjunctive-preconditions"} + for c in self.children: + feats |= c.features() + return feats + + def atoms(self) -> Iterator[Atom]: + for c in self.children: + yield from c.atoms() + + def __repr__(self) -> str: + return "(or %s)" % " ".join(repr(c) for c in self.children) + + +class Equality(Condition): + """Object equality ``(= t1 t2)`` (#10, :equality).""" + + def __init__(self, left: str, right: str) -> None: + self.left = left + self.right = right + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "Equality": + return Equality(varvals.get(self.left, self.left), + varvals.get(self.right, self.right)) + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + return self.left == self.right + + def features(self) -> Set[str]: + return {"equality"} + + def __repr__(self) -> str: + return "(= %s %s)" % (self.left, self.right) + + +class NumericCond(Condition): + """A numeric comparison precondition, e.g. ``(>= (fuel ?v) 10)``, wrapped + as a condition-tree node so it evaluates alongside the symbolic literals.""" + + def __init__(self, constraint: NumericConstraint) -> None: + self.constraint = constraint + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> "NumericCond": + return NumericCond(self.constraint.ground(varvals)) + + def holds(self, atoms: AbstractSet[GroundAtom], fluents: Valuation) -> bool: + return self.constraint.holds(fluents) + + def __repr__(self) -> str: + return repr(self.constraint) + + +class _Quantifier(Condition): + """Shared machinery for forall/exists: enumerate the bound variables over + their type-compatible objects and instantiate the body once per tuple.""" + + def __init__(self, varlist: Dict[str, Optional[str]], body: Condition) -> None: + self.varlist = dict(varlist) + self.body = body + + def _instances(self, varvals: VarVals, dp: "DomainProblem") -> List[Condition]: + names = list(self.varlist.keys()) + domains = [dp.candidate_objects(self.varlist[v]) for v in names] + out: List[Condition] = [] + for combo in itertools.product(*domains): + bound = dict(varvals) + bound.update(zip(names, combo)) + out.append(self.body.ground(bound, dp)) + return out + + def atoms(self) -> Iterator[Atom]: + yield from self.body.atoms() + + +class Exists(_Quantifier): + """``(exists (vars) body)`` — grounds to the Or over its instances.""" + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> Condition: + return Or(self._instances(varvals, dp)) + + def features(self) -> Set[str]: + return {"existential-preconditions"} | self.body.features() + + +class Forall(_Quantifier): + """``(forall (vars) body)`` — grounds to the And over its instances.""" + + def ground(self, varvals: VarVals, dp: "DomainProblem") -> Condition: + return And(self._instances(varvals, dp)) + + def features(self) -> Set[str]: + return {"universal-preconditions"} | self.body.features() + + +# -------------------------------------------------------------------------- +# ADL effect trees (#10): conditional (when) and universal (forall) effects, +# alongside the plain add/delete/numeric effects. Grounding an effect tree +# compiles it into unconditional add/delete/numeric sets plus a list of +# CondEffect guards evaluated against the pre-state by ``State.apply``. +# -------------------------------------------------------------------------- + +class CondEffect(): + """A grounded conditional effect: when ``condition`` holds in the pre-state, + contribute ``add`` / ``dele`` / ``num`` to the successor.""" + + def __init__(self, condition: Condition) -> None: + self.condition = condition + self.add: Set[GroundAtom] = set() + self.dele: Set[GroundAtom] = set() + self.num: List[NumericEffect] = [] + + +class GroundEffects(): + """Accumulator for a compiled (grounded) effect tree.""" + + def __init__(self) -> None: + self.add: Set[GroundAtom] = set() + self.dele: Set[GroundAtom] = set() + self.num: List[NumericEffect] = [] + self.cond: List[CondEffect] = [] + + +class Effect(): + """Base class for an effect node.""" + + def compile(self, varvals: VarVals, dp: "DomainProblem", + out: GroundEffects, guard: Optional[Condition]) -> None: + raise NotImplementedError # pragma: no cover - abstract + + def predicates(self) -> Set[str]: + """Predicate names this effect may add or delete (static analysis).""" + return set() + + def features(self) -> Set[str]: + return set() + + def atoms(self) -> Iterator[Atom]: + return iter(()) + + +class AddDel(Effect): + """An add (``(p ...)``) or delete (``(not (p ...))``) of a single atom.""" + + def __init__(self, atom: Atom, add: bool) -> None: + self.atom = atom + self.add = add + + def compile(self, varvals: VarVals, dp: "DomainProblem", + out: GroundEffects, guard: Optional[Condition]) -> None: + g = self.atom.ground(varvals) + if guard is None: + (out.add if self.add else out.dele).add(g) + else: + ce = CondEffect(guard) + (ce.add if self.add else ce.dele).add(g) + out.cond.append(ce) + + def predicates(self) -> Set[str]: + return {self.atom.predicate[0]} + + def atoms(self) -> Iterator[Atom]: + yield self.atom + + +class NumEff(Effect): + """A numeric assignment effect, e.g. ``(decrease (fuel ?v) 5)``.""" + + def __init__(self, effect: NumericEffect) -> None: + self.effect = effect + + def compile(self, varvals: VarVals, dp: "DomainProblem", + out: GroundEffects, guard: Optional[Condition]) -> None: + g = self.effect.ground(varvals) + if guard is None: + out.num.append(g) + else: + ce = CondEffect(guard) + ce.num.append(g) + out.cond.append(ce) + + +class EffAnd(Effect): + """A conjunction of effects — ``(and e1 e2 ...)``.""" + + def __init__(self, children: Sequence[Effect]) -> None: + self.children = list(children) + + def compile(self, varvals: VarVals, dp: "DomainProblem", + out: GroundEffects, guard: Optional[Condition]) -> None: + for c in self.children: + c.compile(varvals, dp, out, guard) + + def predicates(self) -> Set[str]: + return set().union(*(c.predicates() for c in self.children)) if self.children else set() + + def features(self) -> Set[str]: + return set().union(*(c.features() for c in self.children)) if self.children else set() + + def atoms(self) -> Iterator[Atom]: + for c in self.children: + yield from c.atoms() + + +class When(Effect): + """A conditional effect ``(when condition effect)``. + + The grammar's ``condEffect`` body is a flat list of atomic/numeric effects + (no nested when/forall), so a when is never itself nested under another + guard — its grounded condition becomes the guard for each leaf effect. + """ + + def __init__(self, condition: Condition, effect: Effect) -> None: + self.condition = condition + self.effect = effect + + def compile(self, varvals: VarVals, dp: "DomainProblem", + out: GroundEffects, guard: Optional[Condition]) -> None: + cond = self.condition.ground(varvals, dp) + self.effect.compile(varvals, dp, out, cond) + + def predicates(self) -> Set[str]: + return self.effect.predicates() + + def features(self) -> Set[str]: + return {"conditional-effects"} | self.condition.features() | self.effect.features() + + def atoms(self) -> Iterator[Atom]: + yield from self.condition.atoms() + yield from self.effect.atoms() + + +class Universal(Effect): + """A universal effect ``(forall (vars) effect)`` — the effect is applied for + every type-compatible tuple of the bound variables.""" + + def __init__(self, varlist: Dict[str, Optional[str]], effect: Effect) -> None: + self.varlist = dict(varlist) + self.effect = effect + + def compile(self, varvals: VarVals, dp: "DomainProblem", + out: GroundEffects, guard: Optional[Condition]) -> None: + names = list(self.varlist.keys()) + domains = [dp.candidate_objects(self.varlist[v]) for v in names] + for combo in itertools.product(*domains): + bound = dict(varvals) + bound.update(zip(names, combo)) + self.effect.compile(bound, dp, out, guard) + + def predicates(self) -> Set[str]: + return self.effect.predicates() + + def features(self) -> Set[str]: + return {"conditional-effects"} | self.effect.features() + + def atoms(self) -> Iterator[Atom]: + yield from self.effect.atoms() + + +# -- builders: turn a parse-tree context into a Condition / Effect tree ----- + +def _atom_from_formula(ctx: Any) -> Atom: + """Build an Atom from an atomicTermFormula/atomicNameFormula context.""" + pred = [c.getText() for c in ctx.getChildren() if c.getText() not in ('(', ')')] + return Atom(pred) + + +def _typed_var_list(ctx: Any) -> Dict[str, Optional[str]]: + """Read a typedVariableList context into an ordered {var: type} dict.""" + varlist: Dict[str, Optional[str]] = {} + for v in ctx.VARIABLE(): + varlist[v.getText()] = None + for vs in ctx.singleTypeVarList(): + t = vs.r_type().getText() + for v in vs.VARIABLE(): + varlist[v.getText()] = t + return varlist + + +def _build_condition(gd: Any) -> Condition: + """Recursively build a Condition tree from a goalDesc context.""" + if gd.atomicTermFormula() is not None: + return Lit(_atom_from_formula(gd.atomicTermFormula())) + if gd.fComp() is not None: + return NumericCond(_parse_fcomp(gd.fComp())) + kw = gd.getChild(1).getText().lower() + subs = gd.goalDesc() + if kw == 'and': + return And([_build_condition(g) for g in subs]) + if kw == 'or': + return Or([_build_condition(g) for g in subs]) + if kw == 'not': + return Not(_build_condition(subs[0])) + if kw == 'imply': + return Or([Not(_build_condition(subs[0])), _build_condition(subs[1])]) + if kw == '=': + terms = gd.term() + return Equality(terms[0].getText(), terms[1].getText()) + body = _build_condition(subs[0]) + varlist = _typed_var_list(gd.typedVariableList()) + return Exists(varlist, body) if kw == 'exists' else Forall(varlist, body) + + +def _is_simple_conjunction(cond: Condition) -> bool: + """True if ``cond`` is a plain conjunction of literals / negated literals / + numeric constraints — the shape the static-pruning binder can join safely. + Anything with a disjunction, quantifier, equality or nested negation forces + the binder onto the full cartesian product.""" + if isinstance(cond, (Lit, NumericCond)): + return True + if isinstance(cond, Not): + return isinstance(cond.child, Lit) + if isinstance(cond, And): + return all(_is_simple_conjunction(c) for c in cond.children) + return False + + +def _build_peffect(pe: Any) -> Effect: + """Build an Effect from a pEffect context (assignment / add / delete).""" + if pe.assignOp() is not None: + return NumEff(NumericEffect(pe.assignOp().getText().lower(), + Fluent(_parse_fhead(pe.fHead())), + _parse_fexp(_nth_fexp(pe, 0)))) + atom = _atom_from_formula(pe.atomicTermFormula()) + add = not (pe.getChildCount() >= 2 and pe.getChild(1).getText().lower() == 'not') + return AddDel(atom, add) + + +def _build_condeffect(ce: Any) -> Effect: + """Build an Effect from a condEffect context (the body of a when).""" + pes = ce.pEffect() + if ce.getChildCount() >= 2 and ce.getChild(1).getText().lower() == 'and': + return EffAnd([_build_peffect(p) for p in pes]) + return _build_peffect(pes[0]) + + +def _build_ceffect(ce: Any) -> Effect: + """Build an Effect from a cEffect context (plain / forall / when).""" + if ce.pEffect() is not None: + return _build_peffect(ce.pEffect()) + kw = ce.getChild(1).getText().lower() + if kw == 'forall': + return Universal(_typed_var_list(ce.typedVariableList()), + _build_effect(ce.effect())) + return When(_build_condition(ce.goalDesc()), _build_condeffect(ce.condEffect())) + + +def _build_effect(ctx: Any) -> Effect: + """Recursively build an Effect tree from an effect context.""" + ces = ctx.cEffect() + if ctx.getChildCount() >= 2 and ctx.getChild(1).getText().lower() == 'and': + return EffAnd([_build_ceffect(c) for c in ces]) + return _build_ceffect(ces[0]) + + +def _unconditional_effects(eff: Effect) -> Tuple[Set[Atom], Set[Atom], List[NumericEffect]]: + """The add / delete / numeric effects that apply unconditionally (i.e. not + guarded by a when and not under a forall). Used for the backward-compatible + flat ``effect_pos`` / ``effect_neg`` / ``effect_num`` summary.""" + pos: Set[Atom] = set() + neg: Set[Atom] = set() + nums: List[NumericEffect] = [] + + def walk(e: Effect) -> None: + if isinstance(e, EffAnd): + for c in e.children: + walk(c) + elif isinstance(e, AddDel): + (pos if e.add else neg).add(e.atom) + elif isinstance(e, NumEff): + nums.append(e.effect) + # When / Universal are conditional: not part of the flat summary. + + walk(eff) + return pos, neg, nums + + class Scope(): def __init__(self): self.atoms = [] @@ -286,6 +777,16 @@ def __init__(self, name: Optional[str]) -> None: self.effect_neg: set = set() self.precondition_num: List[NumericConstraint] = [] self.effect_num: List[NumericEffect] = [] + # ADL (#10). Lifted operators carry the full precondition/effect trees + # and a flag marking whether the precondition is a simple conjunction + # (so the static-pruning binder knows when it may join). Grounded + # operators carry the grounded precondition tree and the list of + # conditional effects; both default to the STRIPS-friendly empty forms. + self.precondition_tree: Optional[Condition] = None + self.effect_tree: Optional[Effect] = None + self.simple_conjunction: bool = True + self.precondition: Optional[Condition] = None + self.conditional_effects: List[CondEffect] = [] def __str__(self) -> str: templ = "Operator Name: %s\n\tVariables: %s\n\t" + \ @@ -502,10 +1003,16 @@ def enterPrecondition(self, ctx): def exitPrecondition(self, ctx): scope = self.scopes.pop() - self.scopes[-1].precondition_pos = set( scope.atoms ) - self.scopes[-1].precondition_neg = set( scope.negatoms ) - self.scopes[-1].precondition_connective = self._connective( ctx.goalDesc() ) - self.scopes[-1].precondition_num = list( scope.numerics ) + op = self.scopes[-1] + op.precondition_pos = set( scope.atoms ) + op.precondition_neg = set( scope.negatoms ) + op.precondition_connective = self._connective( ctx.goalDesc() ) + op.precondition_num = list( scope.numerics ) + # ADL (#10): the authoritative precondition is the full tree; the flat + # sets above remain a backward-compatible conjunctive summary. + tree = _build_condition( ctx.goalDesc() ) + op.precondition_tree = tree + op.simple_conjunction = _is_simple_conjunction( tree ) def enterFComp(self, ctx): # A numeric comparison precondition, e.g. (>= (fuel ?v) 10). @@ -527,10 +1034,18 @@ def enterEffect(self, ctx): self.scopes.append(Scope()) def exitEffect(self, ctx): - scope = self.scopes.pop() - self.scopes[-1].effect_pos = set( scope.atoms ) - self.scopes[-1].effect_neg = set( scope.negatoms ) - self.scopes[-1].effect_num = list( scope.numeffects ) + # The scope only holds the negativescopes bookkeeping while walking; the + # effect atoms are read structurally from the tree so that conditional + # (when) and universal (forall) effects do not leak into the flat + # unconditional sets (#10). + self.scopes.pop() + tree = _build_effect( ctx ) + op = self.scopes[-1] + op.effect_tree = tree + pos, neg, nums = _unconditional_effects( tree ) + op.effect_pos = pos + op.effect_neg = neg + op.effect_num = nums def enterGoalDesc(self, ctx): negscope = bool(self.negativescopes and self.negativescopes[-1]) @@ -779,6 +1294,18 @@ def ground_operator(self, op_name: str) -> Iterator[Operator]: gop.effect_neg = set( [ a.ground( st ) for a in op.effect_neg ] ) gop.precondition_num = [ c.ground( st ) for c in op.precondition_num ] gop.effect_num = [ e.ground( st ) for e in op.effect_num ] + # ADL (#10): ground the full precondition tree (expanding + # quantifiers over the world objects) and compile the effect tree + # into unconditional + conditional grounded effects. + if op.precondition_tree is not None: + gop.precondition = op.precondition_tree.ground( st, self ) + if op.effect_tree is not None: + out = GroundEffects() + op.effect_tree.compile( st, self, out, None ) + gop.effect_pos = out.add + gop.effect_neg = out.dele + gop.effect_num = out.num + gop.conditional_effects = out.cond yield gop def ground_durative_operator(self, op_name: str) -> Iterator[DurativeAction]: @@ -825,8 +1352,11 @@ def static_predicates(self) -> set: """ modified = set() for op in self.domain.operators.values(): - for a in op.effect_pos | op.effect_neg: - modified.add(a.predicate[0]) + # Walk the effect tree so predicates touched only inside a + # conditional (when) or universal (forall) effect are still counted + # as modified, hence non-static (#10). + if op.effect_tree is not None: + modified |= op.effect_tree.predicates() for da in self.domain.durative_operators.values(): for time in da.EFFECT_TIMES: for a in da.effect_pos[time] | da.effect_neg[time]: diff --git a/pddlpy/planning/base.py b/pddlpy/planning/base.py index f169b91..e64e40c 100644 --- a/pddlpy/planning/base.py +++ b/pddlpy/planning/base.py @@ -34,26 +34,37 @@ class UnsupportedRequirementsError(PlannerError): # Map a detectable feature to the requirement keywords that permit it. -# ':adl' is the umbrella requirement implying typing + negative + disjunctive. +# ':adl' is the umbrella requirement implying the full ADL feature set. _FEATURE_REQUIREMENTS: Dict[str, Set[str]] = { "typing": {":typing", ":adl"}, "negative-preconditions": {":negative-preconditions", ":adl"}, "disjunctive-preconditions": {":disjunctive-preconditions", ":adl"}, + "equality": {":equality", ":adl"}, + "existential-preconditions": + {":existential-preconditions", ":quantified-preconditions", ":adl"}, + "universal-preconditions": + {":universal-preconditions", ":quantified-preconditions", ":adl"}, + "conditional-effects": {":conditional-effects", ":adl"}, } def _used_features(domainproblem: "DomainProblem") -> Set[str]: - """Detect which (enforceable) PDDL features a domain actually uses.""" + """Detect which (enforceable) PDDL features a domain actually uses. + + Beyond typing, the ADL features (#10) are read off the operators' + precondition and effect trees: negation, disjunction, equality, + quantifiers, and conditional/universal effects. + """ features: Set[str] = set() if domainproblem.domain.typesdef or any( t is not None for t in domainproblem.worldobjects().values() ): features.add("typing") for op in domainproblem.domain.operators.values(): - if op.precondition_neg: - features.add("negative-preconditions") - if op.precondition_connective == "or": - features.add("disjunctive-preconditions") + if op.precondition_tree is not None: + features |= op.precondition_tree.features() + if op.effect_tree is not None: + features |= op.effect_tree.features() return features diff --git a/pddlpy/planning/search.py b/pddlpy/planning/search.py index f3ed6be..05aaf36 100644 --- a/pddlpy/planning/search.py +++ b/pddlpy/planning/search.py @@ -39,6 +39,17 @@ ":fluents", ":numeric-fluents", ":action-costs"} ) +#: ADL (#10) adds full boolean/quantified/equality preconditions and +#: conditional/universal effects. State.applicable evaluates the grounded +#: precondition tree and State.apply fires conditional effects, so the +#: reference planners can now solve ADL domains. Durative actions +#: (:durative-actions) and derived predicates remain out of scope. +ADL_CAPABILITIES: FrozenSet[str] = STRIPS_CAPABILITIES | frozenset( + {":adl", ":disjunctive-preconditions", ":conditional-effects", + ":universal-preconditions", ":existential-preconditions", + ":quantified-preconditions"} +) + def _reconstruct(came_from: _CameFrom, state: State) -> List["Operator"]: """Walk parent pointers back to the start, returning the action list.""" @@ -60,7 +71,7 @@ def goal_count(goal_atoms: "FrozenSet[GroundAtom]", state: State) -> int: class BFSPlanner(Planner): """Breadth-first search. Returns a shortest (fewest-action) plan.""" - capabilities = STRIPS_CAPABILITIES + capabilities = ADL_CAPABILITIES def solve(self, domainproblem: "DomainProblem") -> Optional[Plan]: task = self.prepare(domainproblem) @@ -88,7 +99,7 @@ class _BestFirstPlanner(Planner): """Shared best-first core; subclasses define the node priority and the per-step cost.""" - capabilities = STRIPS_CAPABILITIES + capabilities = ADL_CAPABILITIES def _priority(self, g: float, h: float) -> float: raise NotImplementedError # pragma: no cover - abstract diff --git a/pddlpy/planning/state.py b/pddlpy/planning/state.py index 5bd5549..9f13c28 100644 --- a/pddlpy/planning/state.py +++ b/pddlpy/planning/state.py @@ -90,12 +90,16 @@ def from_problem(cls, domainproblem: "DomainProblem") -> "State": def applicable(self, operator: "Operator") -> bool: """True if a grounded ``operator`` is applicable in this state. - Conjunctive semantics: all positive preconditions hold, no negative - precondition holds, and every numeric precondition is satisfied under - the current fluent valuation. Disjunctive preconditions - (``precondition_connective == 'or'``) are not modeled here; the - reference planners gate such domains via capability negotiation. + When the operator carries a grounded ADL precondition tree (#10) it is + the authoritative test: the full and/or/not/imply/=/forall/exists + structure — and any numeric comparison — is evaluated against this + state. Operators built without a tree fall back to the legacy STRIPS + conjunctive check (all positive preconditions hold, no negative one + holds, every numeric precondition satisfied). """ + precondition = getattr(operator, "precondition", None) + if precondition is not None: + return bool(precondition.holds(self._atoms, self._fluents)) pos = {atom_tuple(a) for a in operator.precondition_pos} neg = {atom_tuple(a) for a in operator.precondition_neg} if not (pos <= self._atoms and neg.isdisjoint(self._atoms)): @@ -111,9 +115,17 @@ def apply(self, operator: "Operator") -> "State": """ add = {atom_tuple(a) for a in operator.effect_pos} delete = {atom_tuple(a) for a in operator.effect_neg} + num_effects = list(operator.effect_num) + # ADL conditional effects (#10): a (when C E) / universal effect fires + # only if its guard C holds in the pre-state (simultaneous semantics). + for ce in getattr(operator, "conditional_effects", ()): + if ce.condition.holds(self._atoms, self._fluents): + add |= {atom_tuple(a) for a in ce.add} + delete |= {atom_tuple(a) for a in ce.dele} + num_effects.extend(ce.num) fluents: Valuation = self._fluents - if operator.effect_num: - updates = [eff.apply(self._fluents) for eff in operator.effect_num] + if num_effects: + updates = [eff.apply(self._fluents) for eff in num_effects] fluents = dict(self._fluents) for key, value in updates: fluents[key] = value diff --git a/tests/corpus/briefcase-domain.pddl b/tests/corpus/briefcase-domain.pddl new file mode 100644 index 0000000..33e249c --- /dev/null +++ b/tests/corpus/briefcase-domain.pddl @@ -0,0 +1,27 @@ +;; The classic "briefcase world" (Pednault) — the canonical showcase for +;; conditional and universal effects (:conditional-effects). Moving the +;; briefcase relocates every item that happens to be inside it. +(define (domain briefcase) + (:requirements :typing :negative-preconditions :conditional-effects) + (:types item location) + (:predicates + (at ?i - item ?l - location) ; item ?i is at location ?l + (in ?i - item) ; item ?i is inside the briefcase + (b-at ?l - location)) ; the briefcase is at location ?l + (:action move + :parameters (?from - location ?to - location) + :precondition (b-at ?from) + :effect (and + (b-at ?to) + (not (b-at ?from)) + (forall (?i - item) + (when (in ?i) + (and (at ?i ?to) (not (at ?i ?from))))))) + (:action put-in + :parameters (?i - item ?l - location) + :precondition (and (at ?i ?l) (b-at ?l)) + :effect (in ?i)) + (:action take-out + :parameters (?i - item) + :precondition (in ?i) + :effect (not (in ?i)))) diff --git a/tests/corpus/briefcase-problem.pddl b/tests/corpus/briefcase-problem.pddl new file mode 100644 index 0000000..79c6ea0 --- /dev/null +++ b/tests/corpus/briefcase-problem.pddl @@ -0,0 +1,14 @@ +;; Carry o1 to the office in the briefcase while o2 stays home. +;; Optimal plan: (put-in o1 home) (move home office). +(define (problem briefcase-01) + (:domain briefcase) + (:objects home office - location + o1 o2 - item) + (:init + (b-at home) + (at o1 home) + (at o2 home)) + (:goal (and + (b-at office) + (at o1 office) + (at o2 home)))) diff --git a/tests/corpus/rooms-domain.pddl b/tests/corpus/rooms-domain.pddl new file mode 100644 index 0000000..1e87c27 --- /dev/null +++ b/tests/corpus/rooms-domain.pddl @@ -0,0 +1,24 @@ +;; A tiny cleaning domain exercising quantified preconditions +;; (:universal-preconditions) and object equality (:equality): the robot may +;; only declare the job done once *every* room is clean, and it may only move +;; between two *distinct* rooms. +(define (domain rooms) + (:requirements :typing :negative-preconditions + :universal-preconditions :equality) + (:types room) + (:predicates + (clean ?r - room) + (robot-in ?r - room) + (done)) + (:action clean-room + :parameters (?r - room) + :precondition (and (robot-in ?r) (not (clean ?r))) + :effect (clean ?r)) + (:action move + :parameters (?from - room ?to - room) + :precondition (and (robot-in ?from) (not (= ?from ?to))) + :effect (and (robot-in ?to) (not (robot-in ?from)))) + (:action declare-done + :parameters () + :precondition (forall (?r - room) (clean ?r)) + :effect (done))) diff --git a/tests/corpus/rooms-problem.pddl b/tests/corpus/rooms-problem.pddl new file mode 100644 index 0000000..a0bd109 --- /dev/null +++ b/tests/corpus/rooms-problem.pddl @@ -0,0 +1,7 @@ +;; Two rooms, robot starts in r1. A valid plan cleans both rooms (moving +;; between them) and then declares the job done. +(define (problem rooms-01) + (:domain rooms) + (:objects r1 r2 - room) + (:init (robot-in r1)) + (:goal (done))) diff --git a/tests/test_adl.py b/tests/test_adl.py new file mode 100644 index 0000000..7beb535 --- /dev/null +++ b/tests/test_adl.py @@ -0,0 +1,352 @@ +"""ADL (#10): conditional & universal effects, quantified & full-boolean +preconditions, and object equality — parsed, grounded, evaluated end-to-end. + +The two corpus domains are the canonical showcases: ``briefcase`` for +conditional/universal effects and ``rooms`` for a universal precondition plus +equality. The remaining cases pin down each condition/effect node with small +inline domains. +""" +import os + +import pytest + +from pddlpy import DomainProblem +from pddlpy.diagnostics import diagnose +from pddlpy.planning import ( + AStarPlanner, + BFSPlanner, + GBFSPlanner, + GroundedTask, + State, + UnsupportedRequirementsError, +) +from pddlpy.planning.base import _used_features, validate_requirements + +CORPUS = os.path.join(os.path.dirname(__file__), "corpus") +PLANNERS = [BFSPlanner, AStarPlanner, GBFSPlanner] + + +def _files(name): + return (os.path.join(CORPUS, "%s-domain.pddl" % name), + os.path.join(CORPUS, "%s-problem.pddl" % name)) + + +def _dp(name): + return DomainProblem(*_files(name)) + + +def _inline(tmp_path, domain, problem): + d = tmp_path / "d.pddl" + p = tmp_path / "p.pddl" + d.write_text(domain) + p.write_text(problem) + return DomainProblem(str(d), str(p)), str(d), str(p) + + +def _execute(dp, plan): + task = GroundedTask(dp) + state = task.initial + for action in plan: + assert state.applicable(action), "inapplicable %r" % (action.operator_name,) + state = state.apply(action) + return state, task + + +# -- corpus: full solve + plan validity ----------------------------------- + +@pytest.mark.parametrize("planner_cls", PLANNERS) +@pytest.mark.parametrize("name", ["briefcase", "rooms"]) +def test_adl_corpus_solves(planner_cls, name): + dp = _dp(name) + plan = planner_cls().solve(dp) + assert plan is not None, "%s found no plan for %s" % (planner_cls.__name__, name) + final, task = _execute(dp, plan) + assert task.is_goal(final) + + +def test_briefcase_optimal_plan(): + plan = BFSPlanner().solve(_dp("briefcase")) + assert [a.operator_name for a in plan] == ["put-in", "move"] + + +def test_rooms_optimal_length(): + # clean r1, move, clean r2, declare-done + assert len(BFSPlanner().solve(_dp("rooms"))) == 4 + + +# -- conditional / universal effect semantics ----------------------------- + +def _action(task, name, **binding): + return next(a for a in task.actions + if a.operator_name == name + and all(a.variable_list[k] == v for k, v in binding.items())) + + +def test_conditional_effect_moves_only_contained_items(): + dp = _dp("briefcase") + task = GroundedTask(dp) + state = task.initial.apply(_action(task, "put-in", **{"?i": "o1"})) + state = state.apply(_action(task, "move", **{"?from": "home", "?to": "office"})) + assert ("at", "o1", "office") in state # o1 was inside -> moved + assert ("at", "o1", "home") not in state + assert ("at", "o2", "home") in state # o2 was not inside -> stays + assert ("at", "o2", "office") not in state + + +def test_universal_effect_moves_all_contained_items(): + dp = _dp("briefcase") + task = GroundedTask(dp) + state = task.initial + state = state.apply(_action(task, "put-in", **{"?i": "o1"})) + state = state.apply(_action(task, "put-in", **{"?i": "o2"})) + state = state.apply(_action(task, "move", **{"?from": "home", "?to": "office"})) + assert ("at", "o1", "office") in state + assert ("at", "o2", "office") in state + + +# -- quantified precondition ---------------------------------------------- + +def test_forall_precondition_gates_action(): + dp = _dp("rooms") + task = GroundedTask(dp) + declare = _action(task, "declare-done") + assert not task.initial.applicable(declare) # nothing clean yet + assert declare.precondition is not None + all_clean = State([("clean", "r1"), ("clean", "r2"), ("robot-in", "r1")]) + assert all_clean.applicable(declare) # forall satisfied + one_clean = State([("clean", "r1"), ("robot-in", "r1")]) + assert not one_clean.applicable(declare) + + +# -- equality -------------------------------------------------------------- + +def test_equality_excludes_identity_binding(): + dp = _dp("rooms") + state = State([("robot-in", "r1")]) + applicable = [m for m in dp.ground_operator("move") if state.applicable(m)] + assert applicable + assert all(m.variable_list["?from"] != m.variable_list["?to"] for m in applicable) + assert any(m.variable_list == {"?from": "r1", "?to": "r2"} for m in applicable) + + +def test_equality_true_branch(tmp_path): + dp, _, _ = _inline( + tmp_path, + "(define (domain eq) (:requirements :equality)\n" + " (:predicates (p ?x))\n" + " (:action a :parameters (?x ?y) :precondition (= ?x ?y) :effect (p ?x)))", + "(define (problem e) (:domain eq) (:objects o1 o2) (:init) (:goal (p o1)))", + ) + task = GroundedTask(dp) + applicable = [a for a in task.actions if task.initial.applicable(a)] + assert applicable + assert all(a.variable_list["?x"] == a.variable_list["?y"] for a in applicable) + + +# -- disjunction / implication -------------------------------------------- + +def test_or_precondition(tmp_path): + dp, dfile, pfile = _inline( + tmp_path, + "(define (domain d) (:requirements :strips :disjunctive-preconditions)\n" + " (:predicates (p ?x) (q ?x) (done ?x))\n" + " (:action a :parameters (?x) :precondition (or (p ?x) (q ?x))" + " :effect (done ?x)))", + "(define (problem pr) (:domain d) (:objects o1 o2 o3)\n" + " (:init (p o1) (q o2)) (:goal (and (done o1) (done o2))))", + ) + plan = BFSPlanner().solve(dp) + assert plan is not None and len(plan) == 2 + task = GroundedTask(dp) + # o3 satisfies neither disjunct -> the Or is false -> inapplicable. + assert not task.initial.applicable(_action(task, "a", **{"?x": "o3"})) + assert "or" in repr(_action(task, "a", **{"?x": "o1"}).precondition) + assert diagnose(dfile, pfile)["valid"] + + +def test_imply_precondition(tmp_path): + dp, _, _ = _inline( + tmp_path, + "(define (domain d)" + " (:requirements :disjunctive-preconditions :negative-preconditions)\n" + " (:predicates (p ?x) (q ?x) (ok ?x))\n" + " (:action a :parameters (?x) :precondition (imply (p ?x) (q ?x))" + " :effect (ok ?x)))", + "(define (problem pr) (:domain d) (:objects o1 o2)\n" + " (:init (q o1)) (:goal (and (ok o1) (ok o2))))", + ) + # o1: q holds; o2: p false so (imply p q) is vacuously true. Both apply. + plan = BFSPlanner().solve(dp) + assert plan is not None and len(plan) == 2 + + +# -- existential precondition --------------------------------------------- + +_EXISTS_DOMAIN = ( + "(define (domain d) (:requirements :typing :existential-preconditions)\n" + " (:types t)\n" + " (:predicates (p ?x - t) (ok))\n" + " (:action a :parameters () :precondition (exists (?x - t) (p ?x))" + " :effect (ok)))" +) + + +def test_exists_precondition_true(tmp_path): + dp, dfile, pfile = _inline( + tmp_path, _EXISTS_DOMAIN, + "(define (problem pr) (:domain d) (:objects o1 o2 - t)" + " (:init (p o2)) (:goal (ok)))", + ) + plan = BFSPlanner().solve(dp) + assert plan is not None and len(plan) == 1 + assert diagnose(dfile, pfile)["valid"] + + +def test_exists_precondition_false_is_unsolvable(tmp_path): + dp, _, _ = _inline( + tmp_path, _EXISTS_DOMAIN, + "(define (problem pr) (:domain d) (:objects o1 o2 - t)" + " (:init) (:goal (ok)))", + ) + # No witness for the exists and nothing establishes one -> no plan. + assert BFSPlanner().solve(dp) is None + + +def test_untyped_quantifier_variable(tmp_path): + # A forall with an *untyped* bound variable ranges over every object. + dp, _, _ = _inline( + tmp_path, + "(define (domain d) (:requirements :universal-preconditions)\n" + " (:predicates (p ?x) (ok))\n" + " (:action a :parameters () :precondition (forall (?x) (p ?x))" + " :effect (ok)))", + "(define (problem pr) (:domain d) (:objects o1 o2)" + " (:init (p o1) (p o2)) (:goal (ok)))", + ) + plan = BFSPlanner().solve(dp) + assert plan is not None and len(plan) == 1 + + +def test_quantifier_over_empty_domain(tmp_path): + dp, _, _ = _inline( + tmp_path, + "(define (domain d)" + " (:requirements :typing :universal-preconditions" + " :existential-preconditions)\n" + " (:types thing widget)\n" + " (:predicates (p ?x - widget) (ok) (bad))\n" + " (:action a :parameters () :precondition (forall (?x - widget) (p ?x))" + " :effect (ok))\n" + " (:action b :parameters () :precondition (exists (?x - widget) (p ?x))" + " :effect (bad)))", + "(define (problem pr) (:domain d) (:objects t1 - thing)" + " (:init) (:goal (ok)))", + ) + task = GroundedTask(dp) + # No widget objects: forall is vacuously true, exists is vacuously false. + assert task.initial.applicable(_action(task, "a")) + assert not task.initial.applicable(_action(task, "b")) + assert BFSPlanner().solve(dp) is not None + + +# -- conditional-effect edge shapes --------------------------------------- + +def test_single_effect_when_and_universal_add(tmp_path): + dp, _, _ = _inline( + tmp_path, + "(define (domain d) (:requirements :typing :conditional-effects)\n" + " (:types t)\n" + " (:predicates (flag ?x - t) (marked ?x - t) (touched ?x - t))\n" + " (:action a :parameters (?x - t) :precondition (flag ?x)\n" + " :effect (when (flag ?x) (marked ?x)))\n" + " (:action b :parameters () :precondition ()\n" + " :effect (forall (?x - t) (touched ?x))))", + "(define (problem pr) (:domain d) (:objects o1 o2 - t)\n" + " (:init (flag o1))" + " (:goal (and (marked o1) (touched o1) (touched o2))))", + ) + final, task = _execute(dp, BFSPlanner().solve(dp)) + assert task.is_goal(final) + # action b has an empty precondition -> the legacy (tree-less) applicability + # path is exercised, and its universal effect touches every item. + assert ("touched", "o1") in final and ("touched", "o2") in final + + +def test_conditional_numeric_effect(tmp_path): + dp, _, _ = _inline( + tmp_path, + "(define (domain d) (:requirements :conditional-effects :numeric-fluents)\n" + " (:predicates (flag) (go))\n" + " (:functions (n))\n" + " (:action a :parameters () :precondition (go)\n" + " :effect (when (flag) (increase (n) 5))))", + "(define (problem pr) (:domain d)" + " (:init (flag) (go) (= (n) 0)) (:goal (go)))", + ) + task = GroundedTask(dp) + a = _action(task, "a") + fired = task.initial.apply(a) + assert fired.fluents[("n",)] == 5.0 # flag holds -> +5 + not_fired = State([("go",)], {("n",): 0.0}).apply(a) + assert not_fired.fluents.get(("n",), 0.0) == 0.0 # flag absent -> no change + + +# -- requirements (#9) + capability negotiation --------------------------- + +def test_adl_features_detected(): + assert "conditional-effects" in _used_features(_dp("briefcase")) + rooms = _used_features(_dp("rooms")) + assert {"universal-preconditions", "equality", "negative-preconditions"} <= rooms + + +def test_undeclared_conditional_effect_rejected(tmp_path): + dp, _, _ = _inline( + tmp_path, + "(define (domain d) (:requirements :strips)\n" + " (:predicates (p) (q))\n" + " (:action a :parameters () :precondition (p) :effect (when (p) (q))))", + "(define (problem pr) (:domain d) (:init (p)) (:goal (q)))", + ) + with pytest.raises(UnsupportedRequirementsError): + validate_requirements(dp) + + +def test_planners_accept_adl(): + for name in ("briefcase", "rooms"): + BFSPlanner().check_capabilities(_dp(name)) # no raise + + +# -- diagnostics + repr walk the new node types --------------------------- + +@pytest.mark.parametrize("name", ["briefcase", "rooms", "numeric-transport"]) +def test_diagnose_adl_domains_clean(name): + result = diagnose(*_files(name)) + assert result["valid"], result + + +def test_simple_conjunction_flags(): + assert _dp("blocksworld").domain.operators["pick-up"].simple_conjunction + assert _dp("briefcase").domain.operators["put-in"].simple_conjunction + assert _dp("rooms").domain.operators["clean-room"].simple_conjunction # and + not-lit + assert not _dp("rooms").domain.operators["move"].simple_conjunction # not (= ..) + assert not _dp("rooms").domain.operators["declare-done"].simple_conjunction # forall + + +def test_legacy_conjunctive_fallback(): + # An Operator built by hand (no precondition tree) still evaluates via the + # legacy STRIPS conjunctive path in State.applicable. + from pddlpy.pddl import Operator + + op = Operator("x") + op.precondition_pos = {("p", "a")} + assert not State([("q", "a")]).applicable(op) # missing positive -> False + assert State([("p", "a")]).applicable(op) + + +def test_precondition_repr(): + move = next(m for m in _dp("rooms").ground_operator("move") + if m.variable_list == {"?from": "r1", "?to": "r2"}) + text = repr(move.precondition) + assert "and" in text and "not" in text and "=" in text and "robot-in" in text + drive = next(_dp("numeric-transport").ground_operator("drive")) + assert "fuel" in repr(drive.precondition) # includes the numeric comparison node diff --git a/tests/test_cli.py b/tests/test_cli.py index 50bede2..57f243f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -128,8 +128,10 @@ def test_solve_no_plan(capsys, tmp_path): def test_solve_unsupported_requirements(capsys, tmp_path): domain = tmp_path / "d.pddl" problem = tmp_path / "p.pddl" + # :durative-actions is beyond the blind planners' capabilities (ADL is now + # supported, #10); the solve must still fail fast. domain.write_text( - "(define (domain toy) (:requirements :adl) (:predicates (p))" + "(define (domain toy) (:requirements :durative-actions) (:predicates (p))" " (:action a :parameters () :precondition (p) :effect (p)))" ) problem.write_text( diff --git a/tests/test_planners.py b/tests/test_planners.py index 0a16106..2137e94 100644 --- a/tests/test_planners.py +++ b/tests/test_planners.py @@ -100,12 +100,12 @@ def test_registry_solves(): def test_planner_rejects_unsupported_domain(tmp_path): - # A domain declaring :disjunctive-preconditions is beyond the STRIPS + # ADL is now supported (#10); :durative-actions is still beyond the blind # planners' capabilities -> fail fast. domain = tmp_path / "d.pddl" problem = tmp_path / "p.pddl" domain.write_text( - "(define (domain d) (:requirements :strips :disjunctive-preconditions)\n" + "(define (domain d) (:requirements :strips :durative-actions)\n" " (:predicates (p ?x) (q ?x))\n" " (:action a :parameters (?x) :precondition (or (p ?x) (q ?x)) :effect (not (p ?x))))" )