diff --git a/.docs/EXTENDING.md b/.docs/EXTENDING.md new file mode 100644 index 0000000..2d5b888 --- /dev/null +++ b/.docs/EXTENDING.md @@ -0,0 +1,341 @@ +# Extending jupyddl + +jupyddl is meant to be built on. This guide adds a heuristic, two planners +and an instance generator, then checks their guarantees, watches them run and +ships them as an installable plugin. Every Python block on this page is run by +`tests/test_extending.py`, in order, so the guide stays accurate. + +## The shape of the framework + +``` +PDDL text ─ parse ─▶ Domain/Problem ─ compile (PDDL 3) ─▶ ground ─▶ Task + │ + Heuristic(task) ─▶ h(state) ─────────────────┤ + ▼ + Planner.search(task, heuristic, observer, budget) ─▶ SearchResult +``` + +A **`Task`** is all a search algorithm needs. Facts are integers, and a state +is a `frozenset` of them, or a `State(facts, values)` when the domain has +numeric fluents. + +| You need | Use | +|---|---| +| The start state | `task.initial_state()` | +| Is this a goal? | `task.goal_reached(state)` | +| The actions that apply here | `task.applicable_operators(state)` | +| The successor | `task.apply(op, state)` (**not** `op.apply`: see the contract below) | +| An action's cost and name | `op.cost`, `op.name` | +| The fact set of any state | `facts_of(state)` from `jupyddl.task` | +| The goal facts | `task.goals` | +| A readable fact or state | `task.fact_name(i)`, `task.state_str(state)` | + +There are three registries, which are plain dictionaries. The CLI, the +benchmark harness and the browser workbench all read from them, so registering +an algorithm is all the wiring it needs: + +- `jupyddl.search.PLANNERS` +- `jupyddl.heuristics.HEURISTICS` +- `jupyddl.generator.GENERATORS` + +## A heuristic + +A heuristic is built once per task and then called on states. This one is +small but honest. Every goal the state is missing must be added by *some* +action, so any plan costs at least the cheapest action that adds it. That +holds for each missing goal, and so for the most expensive of them: + +```python +import math + +from jupyddl.heuristics import HEURISTICS, Heuristic +from jupyddl.task import facts_of + + +class CheapestAchiever(Heuristic): + """h = max over unmet goals of the cheapest action adding it. Admissible.""" + + name = "achiever" + admissible = True + + def __init__(self, task): + super().__init__(task) + self.cheapest = {} + for op in task.operators: + added = set(op.add) + for effect in op.cond_effects: + added |= effect.add + for fact in added: + self.cheapest[fact] = min(op.cost, self.cheapest.get(fact, math.inf)) + + def __call__(self, state): + unmet = self.task.goals - facts_of(state) + # A goal no action adds may still be derived by an axiom: it counts as + # 0 here, not inf. Returning inf would prune a state that is solvable. + return float(max((self.cheapest.get(g, 0) for g in unmet), default=0)) + + +HEURISTICS["achiever"] = CheapestAchiever +``` + +Return `math.inf` only for a state you have *proved* is a dead end. The +planners prune on it. + +## A planner from a priority function + +Every best-first planner here is the same engine, `best_first`, with a +different priority over `(g, h)`. A* uses `g + h`, but among nodes with equal +`f` it expands in insertion order. Breaking those ties towards lower `h` +heads for the goal across a plateau, and A* stays optimal, because the order +only changes *within* one `f` layer: + +```python +from jupyddl.search import PLANNERS, Planner, best_first, heuristic_name + + +class TieBreakingAStar(Planner): + """A* breaking f-ties towards lower h. Optimal with an admissible h.""" + + name = "astar-tb" + requires_heuristic = True + optimal = True + + def search(self, task, heuristic=None, observer=None, budget=None): + if observer is not None: + observer.on_start(task, self.name, heuristic_name(heuristic)) + return best_first( + task, + lambda g, h: (g + h, h), + heuristic=heuristic, + observer=observer, + budget=budget, + ) + + +PLANNERS["astar-tb"] = TieBreakingAStar +``` + +## A planner from scratch + +Writing the loop yourself means honouring the contract that every planner +here keeps: + +1. **Successors come from `task.apply(op, state)`**, never `op.apply(state)`. + The task re-closes derived predicates after each step. Calling the operator + directly silently skips that. +2. **Poll the budget once per expansion.** Python cannot interrupt a running + loop, so a `--time-limit` only works if you ask. `budget.exceeded(stats)` + sets `stats.truncated` itself. A truncated run must never be reported as + unsolvable. +3. **Touch the observer only behind `if observer is not None`.** Search without + an observer must cost exactly what it did before observers existed. With + one, it must return the same plan and statistics. +4. **Fill in `SearchStats`** (`expanded`, `generated`, `evaluated`, `runtime`), + so the benchmark harness can compare you with everything else. +5. **Claim only what holds.** Set `optimal` and `requires_heuristic` truthfully, + and cite the paper you implement in the docstring. + +Random walks make a good baseline. Monte-Carlo random walks (Nakhost and +Müller, IJCAI 2009) are a serious planner when a heuristic guides them; this +is the unguided core: + +```python +import random +import time + +from jupyddl.search import SearchResult, SearchStats + + +class RandomWalk(Planner): + """Restarting random walks (after Nakhost & Müller, 2009). Not complete.""" + + name = "walk" + + def __init__(self, walks=500, length=60, seed=0): + self.walks, self.length, self.seed = walks, length, seed + + def search(self, task, heuristic=None, observer=None, budget=None): + stats = SearchStats() + started = time.perf_counter() + rng = random.Random(self.seed) # same seed, same plan + if observer is not None: + observer.on_start(task, self.name, heuristic_name(heuristic)) + found = None + for _ in range(self.walks): + found = self._walk(task, rng, stats, observer, budget) + if found is not None or stats.truncated: + break + stats.runtime = time.perf_counter() - started + if found is None: + result = SearchResult(False, None, None, stats) + else: + plan, cost, state = found + result = SearchResult(True, plan, cost, stats) + if observer is not None: + observer.on_goal(state, cost, len(plan), stats) + if observer is not None: + observer.on_finish(result) + return result + + def _walk(self, task, rng, stats, observer, budget): + state, plan, cost = task.initial_state(), [], 0 + for _ in range(self.length): + if task.goal_reached(state): + return plan, cost, state + if budget is not None and budget.exceeded(stats): + return None # stats.truncated is now set + stats.expanded += 1 + if observer is not None: + observer.on_expand(state, g=cost, f=cost, depth=len(plan), stats=stats) + ops = list(task.applicable_operators(state)) + if not ops: + return None # stuck: the next walk starts over + op = rng.choice(ops) + state = task.apply(op, state) + stats.generated += 1 + plan.append(op) + cost += op.cost + return (plan, cost, state) if task.goal_reached(state) else None + + +PLANNERS["walk"] = RandomWalk +``` + +## An instance generator + +A generator is a function `(size, seed, **kwargs) -> (domain_text, +problem_text)`. It must be **reproducible**: draw every random choice from a +`random.Random(seed)`, never from the global generator. The first line of its +docstring is what `jupyddl generate` and the workbench show. + +```python +from jupyddl.generator import GENERATORS + +CORRIDOR = """(define (domain corridor) + (:requirements :strips :typing) + (:types cell) + (:predicates (at ?c - cell) (adjacent ?a ?b - cell)) + (:action move + :parameters (?from ?to - cell) + :precondition (and (at ?from) (adjacent ?from ?to)) + :effect (and (at ?to) (not (at ?from)))))""" + + +def generate_corridor(size=6, seed=0): + """A robot walks a corridor of `size` cells to a random goal cell.""" + rng = random.Random(seed) + cells = [f"c{i}" for i in range(size)] + start, goal = rng.sample(cells, 2) + links = " ".join( + f"(adjacent {a} {b}) (adjacent {b} {a})" for a, b in zip(cells, cells[1:]) + ) + problem = f"""(define (problem corridor-{size}-{seed}) + (:domain corridor) + (:objects {' '.join(cells)} - cell) + (:init (at {start}) {links}) + (:goal (at {goal})))""" + return CORRIDOR, problem + + +GENERATORS["corridor"] = generate_corridor +``` + +## Run it, and check what you claimed + +Everything is registered, so the normal entry points can use it by name: + +```python +from jupyddl import ground, parse, solve_task, validate_plan +from jupyddl.generator import generate + + +def make_task(kind, size, seed): + domain, problem = generate(kind, size=size, seed=seed) + return ground(parse(domain), parse(problem)) + + +task = make_task("corridor", 10, 6) +result = solve_task(task, "astar-tb", "achiever") +print(result.cost, result.plan_names()) +``` + +A guarantee is a claim, so test it the way a reviewer would. Check the +optimal planner's cost against `dijkstra`, which is optimal with no heuristic +at all. Check the admissible heuristic's estimate at the start against that +cost. And check that every plan the incomplete planner returns is valid: + +```python +for seed in range(5): + task = make_task("blocksworld", 4, seed) + optimum = solve_task(task, "dijkstra").cost + assert solve_task(task, "astar-tb", "achiever").cost == optimum + assert CheapestAchiever(task)(task.initial_state()) <= optimum + walk = solve_task(task, "walk") + assert not walk.solved or validate_plan(task, walk.plan) + +# A budget stops it, and says so rather than claiming there is no plan. +stopped = solve_task(make_task("blocksworld", 6, 0), "walk", max_expansions=50) +assert stopped.truncated and not stopped.solved +``` + +## Watch it, and explain it + +The point of putting an algorithm here is that you can *show* it. +`trace_search` records every expansion and works with any planner that +follows the observer contract, including yours: + +```python +from jupyddl import trace_search + +result, trace = trace_search(make_task("blocksworld", 5, 1), "astar-tb", "achiever") +print(trace.summary()["expanded"], "expansions") +``` + +With the `viz` extra installed, the same trace turns into charts and a video. +See [Recording and plotting a search](../README.md#recording-and-plotting-a-search) +in the README. From the terminal, `jupyddl solve ... --live` shows the search +as it runs. + +## Ship it as a plugin + +Assigning into a registry only affects the process that runs the assignment. +To make your algorithms available to everyone who installs your package, +declare **entry points** in its `pyproject.toml`: + +```toml +[project.entry-points."jupyddl.planners"] +astar-tb = "my_package.search:TieBreakingAStar" +walk = "my_package.search:RandomWalk" + +[project.entry-points."jupyddl.heuristics"] +achiever = "my_package.heuristics:CheapestAchiever" + +[project.entry-points."jupyddl.generators"] +corridor = "my_package.generators:generate_corridor" +``` + +After `pip install my-package`, they work everywhere, without anyone +importing your code: + +```bash +jupyddl plugins # what was found, and from where +jupyddl generate corridor --size 12 -o instances/ +jupyddl solve instances/corridor-12-0/domain.pddl instances/corridor-12-0/problem.pddl \ + -s astar-tb -H achiever +jupyddl benchmark instances/ --planners astar,astar-tb --heuristic achiever +``` + +Discovery is lazy and defensive (see `jupyddl/plugins.py`): + +- A name that already exists always wins, so a plugin cannot replace a + built-in. +- A plugin that fails to import is skipped with a warning. It cannot break + jupyddl. +- `JUPYDDL_NO_PLUGINS=1` switches discovery off, for a benchmark that must not + depend on what else is installed. + +## Contributing it upstream instead + +An algorithm that teaches something the existing ones do not belongs in +jupyddl itself. Open a **Propose an algorithm** issue first, then see +[CONTRIBUTING.md](../.github/CONTRIBUTING.md#adding-an-algorithm). diff --git a/.docs/PUBLISHING.md b/.docs/PUBLISHING.md index 028f4fc..965223d 100644 --- a/.docs/PUBLISHING.md +++ b/.docs/PUBLISHING.md @@ -68,7 +68,7 @@ submitting**. This list is a working copy, not a substitute. | Automated tests | ✅ CI on Python 3.10–3.14, with and without the optional extras, plus a packaging check on the built wheel | | Community guidelines | ✅ [`CONTRIBUTING.md`](../.github/CONTRIBUTING.md), [`CODE_OF_CONDUCT.md`](../.github/CODE_OF_CONDUCT.md), [`SECURITY.md`](../.github/SECURITY.md) | | API documentation | 🟡 Docstrings throughout, but no rendered API reference yet | -| A guide to extending the framework | 🟡 A paragraph in the README's Architecture section; a full guide is in progress | +| A guide to extending the framework | ✅ [EXTENDING.md](EXTENDING.md), executed by the test suite; third-party packages plug in through entry points | | Archived release with a DOI | ⏳ Waiting on the Zenodo setup above | | `paper.md` and `paper.bib` | ⏳ Not written yet. See the outline below | | Author list, with ORCIDs | ⏳ See [Authorship](#authorship) | diff --git a/.docs/README.md b/.docs/README.md index 39de28f..9e2b028 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -4,8 +4,10 @@ Everything that is not the README lives here: the research notes for the learning-for-planning line, the release runbook, and every image, video and measurement cache under [`assets/`](assets). -These notes are research documents, not user documentation — the user-facing -description of `jupyddl learn` is in the README and the module docstrings. +Most of these notes are research documents, not user documentation — the +user-facing description of `jupyddl learn` is in the README and the module +docstrings. The exception is [EXTENDING.md](EXTENDING.md), the guide to building +on the framework. The files GitHub reads for its own community features — `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` and the issue and pull-request templates — @@ -14,6 +16,7 @@ are in `.github/` instead: GitHub only looks for them in the repository root, | Note | What it covers | |---|---| +| [EXTENDING.md](EXTENDING.md) | How to add a heuristic, a planner and a generator, check their guarantees, and ship them as a plugin. Every example in it is run by the test suite | | [learned-heuristics.md](learned-heuristics.md) | The imitation stage: prior work, the design we chose, measured results on three domains, and an analysis of the one domain where it loses badly | | [rl-for-search.md](rl-for-search.md) | The reinforcement stage: the MDP that "minimise expansions" corresponds to, why the obvious policy gradient is hard here, and what we do instead | | [roadmap.md](roadmap.md) | What to build next, ordered by expected value, with the experiment that would settle each | diff --git a/AGENTS.md b/AGENTS.md index 52053d6..e20d6c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ native build step, and the core has zero runtime dependencies. - **`pages`** — bundles, refuses to deploy a stale `web/dist`, then deploys. Its job is called `bundle`, not `build`: a check named `build` has to mean the packaging workflow, and two checks with one name is a reliable way to gate - a merge on the wrong one. (Mergify used to key a rule on it; it is retired.) + a merge on the wrong one. (The repository's Mergify rules used to key on it.) - **Triggers.** `tests` and `build` run on pull requests, so a contributor's fork gets CI, and on pushes to `main`. A branch pushed without a PR gets no CI; open a draft PR. @@ -206,4 +206,11 @@ planners must go through the task rather than the operator to honour them: - Extend via the registries: `jupyddl.search.PLANNERS`, `jupyddl.heuristics.HEURISTICS` and `jupyddl.generator.GENERATORS`. The CLI, benchmark harness and web workbench all read from them, so a new entry shows - up everywhere for free. + up everywhere for free. Other packages reach the same registries through + entry points (`jupyddl/plugins.py`). Discovery is **lazy** — the CLI runs it + before building its parser, the library only on a lookup miss — so importing + jupyddl never scans installed packages, and `INFORMED_PLANNERS` / + `OPTIMAL_PLANNERS` describe the built-ins only. The suite sets + `JUPYDDL_NO_PLUGINS=1` so a developer's installed plugins cannot change it. +- **`.docs/EXTENDING.md` is executed** by `tests/test_extending.py`: change an + API it uses and that test fails until the guide is updated too. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba0bb8..cf05bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and how contributors can be considered for authorship. The README states the project's aim (a framework to build on and to explain algorithms with) and its Cite section says which version the 2021 preprint describes. +- **Plugins.** A package can now add planners, heuristics and generators by + declaring `jupyddl.planners`, `jupyddl.heuristics` or `jupyddl.generators` + entry points; once it is installed, `jupyddl solve -s ` works + without anyone importing it. Discovery is lazy (the CLI runs it before + building its parser, the library only when asked for a name it does not + know), a built-in name always wins, a plugin that fails to import is skipped + with a warning, and `JUPYDDL_NO_PLUGINS=1` turns it off. `jupyddl plugins` + lists what was found. +- **`.docs/EXTENDING.md`, a guide to building on the framework**: a heuristic, + a planner from a priority function, a planner from scratch with the full + budget/observer contract, a generator, how to test the guarantees you claim, + and how to ship it all as a plugin. Every example runs in the test suite, so + the guide cannot drift from the API. +- `best_first` accepts a **tuple priority** for tie-breaking + (`lambda g, h: (g + h, h)`); observers are given its first element as `f`. - **Community files for outside contributors**: a security policy, a pull-request template, a *Propose an algorithm* issue template, and an "Adding an algorithm" section in `CONTRIBUTING.md`. diff --git a/README.md b/README.md index 214919f..6d03198 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,9 @@ jupyddl requirements --verbose # Generate a reproducible difficulty ladder jupyddl generate gripper --size 2 --count 8 --step 2 -o instances/ +# Planners, heuristics and generators added by installed packages +jupyddl plugins + # Bound any run by nodes or by seconds jupyddl solve demos/blocksworld12/domain.pddl demos/blocksworld12/problem.pddl \ --search gbfs --heuristic hff --time-limit 10 @@ -574,6 +577,12 @@ and registering it in `jupyddl.search.PLANNERS`; a heuristic by subclassing `jupyddl.generator.GENERATORS`. All three are picked up by the CLI, the benchmark harness and the web workbench automatically. +**[Extending jupyddl](.docs/EXTENDING.md)** walks through all three with worked +examples — every one of them run by the test suite — and shows how to ship them +as a **plugin**: declare `jupyddl.planners`, `jupyddl.heuristics` or +`jupyddl.generators` entry points in your own package, and after a `pip install` +the `jupyddl` command finds them by name. `jupyddl plugins` lists what it found. + **Conditions are a formula tree in negation normal form.** The parser pushes every `not` down to the literals and rewrites `imply`; the grounder expands the quantifiers against the object pool and distributes the result into DNF, emitting diff --git a/jupyddl/cli.py b/jupyddl/cli.py index 344d0f0..7e0b96a 100644 --- a/jupyddl/cli.py +++ b/jupyddl/cli.py @@ -1,7 +1,7 @@ """Command-line interface. -``solve``, ``benchmark``, ``animate``, ``demo``, ``requirements``, ``generate`` -and ``learn``. Every long-running command accepts ``--max-expansions`` and +``solve``, ``benchmark``, ``animate``, ``demo``, ``requirements``, ``generate``, +``learn`` and ``plugins``. Every long-running command accepts ``--max-expansions`` and ``--time-limit``; when a search stops on one of those it says so rather than reporting the instance unsolvable. """ @@ -21,9 +21,14 @@ to_csv, ) from .heuristics import HEURISTICS, LOADERS -from .search import INFORMED_PLANNERS, PLANNERS +from .plugins import describe_plugins, load_plugins +from .search import PLANNERS -INFORMED = set(INFORMED_PLANNERS) + +def _needs_heuristic(planner: str) -> bool: + # Read from the registry at call time rather than INFORMED_PLANNERS, which + # is fixed at import and so cannot know about a plugin's planners. + return bool(getattr(PLANNERS.get(planner), "requires_heuristic", False)) def heuristic_spec(value: str) -> str: @@ -265,7 +270,7 @@ def _cmd_benchmark(args) -> int: configs = [] for planner in args.planners.split(","): planner = planner.strip() - configs.append((planner, args.heuristic if planner in INFORMED else None)) + configs.append((planner, args.heuristic if _needs_heuristic(planner) else None)) rows = run_benchmark( instances, @@ -316,6 +321,25 @@ def _add_requirements(sub): p.set_defaults(func=_cmd_requirements) +def _add_plugins(sub): + p = sub.add_parser( + "plugins", + help="list planners, heuristics and generators added by installed packages", + ) + p.set_defaults(func=_cmd_plugins) + + +def _cmd_plugins(args) -> int: + rows = describe_plugins() + if not rows: + print("No plugins installed. See .docs/EXTENDING.md to write one.") + return 0 + for row in rows: + where = row["source"] or f"SKIPPED: {row['error']}" + print(f"{row['kind']:<10}{row['name']:<20}{where}") + return 0 + + def _add_generate(sub): from .generator import GENERATORS @@ -645,6 +669,9 @@ def _print_summary(summary) -> None: def main(argv=None) -> int: + # Before the parser is built: ``--search`` and ``generate`` take their + # ``choices`` from the registries, which is where plugins land. + load_plugins() parser = argparse.ArgumentParser( prog="jupyddl", description="Pure-Python PDDL planning framework.", @@ -657,6 +684,7 @@ def main(argv=None) -> int: _add_requirements(sub) _add_generate(sub) _add_learn(sub) + _add_plugins(sub) args = parser.parse_args(argv) return args.func(args) diff --git a/jupyddl/generator.py b/jupyddl/generator.py index f88ab33..d31997a 100644 --- a/jupyddl/generator.py +++ b/jupyddl/generator.py @@ -613,20 +613,30 @@ def action_text(name, pre, add, delete): def generate(kind: str, size: int = 4, seed: int = 0, **kwargs): - """Generate ``(domain_text, problem_text)`` for a named generator.""" - try: - factory = GENERATORS[kind] - except KeyError: - raise ValueError( - f"Unknown generator '{kind}'. Available: {sorted(GENERATORS)}" - ) from None + """Generate ``(domain_text, problem_text)`` for a named generator. + + An unregistered ``kind`` triggers plugin discovery once (see + :mod:`jupyddl.plugins`) before it is reported as unknown. + """ + factory = GENERATORS.get(kind) + if factory is None: + from .plugins import load_plugins + + load_plugins() + factory = GENERATORS.get(kind) + if factory is None: + raise ValueError(f"Unknown generator '{kind}'. Available: {sorted(GENERATORS)}") return factory(size=size, seed=seed, **kwargs) def describe_generators() -> list: """Serialisable metadata for the CLI and the web workbench.""" return [ - {"name": name, "summary": GENERATOR_NOTES.get(name, "")} + { + "name": name, + "summary": GENERATOR_NOTES.get(name) + or (GENERATORS[name].__doc__ or "").strip().split("\n")[0], + } for name in sorted(GENERATORS) ] diff --git a/jupyddl/heuristics/__init__.py b/jupyddl/heuristics/__init__.py index 8baf757..4453af3 100644 --- a/jupyddl/heuristics/__init__.py +++ b/jupyddl/heuristics/__init__.py @@ -47,6 +47,8 @@ def make_heuristic(name, task) -> Heuristic: Accepts a registry name (``"lmcut"``), a parameterised spec (``"learned:model.json"``), or an already-built heuristic, which is passed through so callers holding a trained model need not round-trip it to disk. + An unregistered name triggers plugin discovery once (see + :mod:`jupyddl.plugins`) before it is reported as unknown. """ if callable(name) and not isinstance(name, str): return name @@ -59,13 +61,17 @@ def make_heuristic(name, task) -> Heuristic: f"Available: {sorted(LOADERS)}" ) return loader(argument, task) - try: - factory = HEURISTICS[name] - except KeyError: + factory = HEURISTICS.get(name) + if factory is None: + from ..plugins import load_plugins + + load_plugins() + factory = HEURISTICS.get(name) + if factory is None: raise ValueError( f"Unknown heuristic '{name}'. Available: {sorted(HEURISTICS)}" + (f" (or {sorted(LOADERS)} with an argument)" if LOADERS else "") - ) from None + ) return factory(task) diff --git a/jupyddl/plugins.py b/jupyddl/plugins.py new file mode 100644 index 0000000..976fc1f --- /dev/null +++ b/jupyddl/plugins.py @@ -0,0 +1,152 @@ +"""Planners, heuristics and generators from other packages, found by entry point. + +jupyddl's registries are plain dictionaries, so code that imports jupyddl can +always add to them directly:: + + from jupyddl.search import PLANNERS + PLANNERS["mine"] = MyPlanner + +That only reaches the process that ran the assignment. An *entry point* makes an +extension visible everywhere instead -- the ``jupyddl`` command line, the +benchmark harness, :func:`jupyddl.solve` -- as soon as the package declaring it +is installed. Declare them in that package's ``pyproject.toml``:: + + [project.entry-points."jupyddl.planners"] + mine = "my_package.search:MyPlanner" + + [project.entry-points."jupyddl.heuristics"] + myh = "my_package.heuristics:MyHeuristic" + + [project.entry-points."jupyddl.generators"] + corridor = "my_package.generators:generate_corridor" + +The value is what the matching registry holds: a planner class (or any +callable returning a :class:`~jupyddl.search.Planner`), a heuristic class (or +any ``task -> Heuristic`` callable), or a generator function +``(size, seed, **kwargs) -> (domain_text, problem_text)``. + +Discovery is lazy: nothing here runs when jupyddl is imported. The command line +loads plugins before it builds its argument parser, and the library loads them +the first time it is asked for a name it does not know. Three rules keep a +plugin from breaking anything it was not asked to do: + +- a name that is already registered -- a built-in, or something added by hand + -- always wins, and the clash is reported; +- a plugin that fails to import, or is not callable, is reported with a + warning and skipped; +- ``JUPYDDL_NO_PLUGINS=1`` switches discovery off, for a run that must not + depend on whatever else happens to be installed. +""" + +from __future__ import annotations + +import os +import warnings +from importlib.metadata import entry_points + +GROUPS = ("jupyddl.planners", "jupyddl.heuristics", "jupyddl.generators") + +#: What discovery registered: ``group -> {name: source}``, where ``source`` is +#: the distribution that declared it. +REGISTERED: dict = {group: {} for group in GROUPS} + +#: What discovery skipped and why: ``group -> {name: reason}``. +SKIPPED: dict = {group: {} for group in GROUPS} + +_loaded = False + + +class PluginWarning(RuntimeWarning): + """A plugin was found but could not be registered.""" + + +def disabled() -> bool: + """True when ``JUPYDDL_NO_PLUGINS`` is set to anything but ``0`` or empty.""" + return os.environ.get("JUPYDDL_NO_PLUGINS", "").strip() not in ("", "0") + + +def _registry(group: str) -> dict: + # Imported here, not at module level: the registries import this module + # lazily on a lookup miss, and a top-level import would make that a cycle. + if group == "jupyddl.planners": + from .search import PLANNERS + + return PLANNERS + if group == "jupyddl.heuristics": + from .heuristics import HEURISTICS + + return HEURISTICS + from .generator import GENERATORS + + return GENERATORS + + +def _source(entry) -> str: + dist = getattr(entry, "dist", None) + return dist.name if dist is not None else entry.value + + +def _skip(group: str, name: str, reason: str) -> None: + SKIPPED[group][name] = reason + warnings.warn( + f"jupyddl plugin '{name}' ({group}) skipped: {reason}", + PluginWarning, + stacklevel=3, + ) + + +def load_plugins(force: bool = False) -> dict: + """Register every installed plugin, once per process. + + Returns :data:`REGISTERED`. ``force=True`` scans again, which only matters + when packages were installed after the first scan. + """ + global _loaded + if disabled() or (_loaded and not force): + return REGISTERED + _loaded = True + for group in GROUPS: + registry = _registry(group) + for entry in entry_points(group=group): + name, source = entry.name, _source(entry) + if group == "jupyddl.heuristics" and ":" in name: + _skip(group, name, "':' is reserved for parameterised specs") + continue + if name in registry and REGISTERED[group].get(name) != source: + _skip(group, name, f"'{name}' is already registered") + continue + try: + factory = entry.load() + except Exception as exc: # a broken plugin must not break jupyddl + _skip(group, name, f"import of {entry.value} failed: {exc!r}") + continue + if not callable(factory): + _skip(group, name, f"{entry.value} is not callable") + continue + registry[name] = factory + REGISTERED[group][name] = source + SKIPPED[group].pop(name, None) + return REGISTERED + + +def describe_plugins() -> list: + """One row per discovered plugin, registered or skipped, for the CLI.""" + rows = [] + for group in GROUPS: + kind = group.rsplit(".", 1)[1][:-1] # "jupyddl.planners" -> "planner" + for name, source in sorted(REGISTERED[group].items()): + rows.append({"kind": kind, "name": name, "source": source, "error": ""}) + for name, reason in sorted(SKIPPED[group].items()): + rows.append({"kind": kind, "name": name, "source": "", "error": reason}) + return rows + + +__all__ = [ + "GROUPS", + "REGISTERED", + "SKIPPED", + "PluginWarning", + "disabled", + "load_plugins", + "describe_plugins", +] diff --git a/jupyddl/search/__init__.py b/jupyddl/search/__init__.py index a6832c3..81d66c3 100644 --- a/jupyddl/search/__init__.py +++ b/jupyddl/search/__init__.py @@ -51,13 +51,19 @@ def make_planner(name: str, **kwargs) -> Planner: - """Instantiate a planner by name (see :data:`PLANNERS`).""" - try: - factory = PLANNERS[name] - except KeyError: - raise ValueError( - f"Unknown planner '{name}'. Available: {sorted(PLANNERS)}" - ) from None + """Instantiate a planner by name (see :data:`PLANNERS`). + + A name that is not registered triggers plugin discovery once (see + :mod:`jupyddl.plugins`) before it is reported as unknown. + """ + factory = PLANNERS.get(name) + if factory is None: + from ..plugins import load_plugins + + load_plugins() + factory = PLANNERS.get(name) + if factory is None: + raise ValueError(f"Unknown planner '{name}'. Available: {sorted(PLANNERS)}") return factory(**kwargs) diff --git a/jupyddl/search/base.py b/jupyddl/search/base.py index 600d719..89eb2c1 100644 --- a/jupyddl/search/base.py +++ b/jupyddl/search/base.py @@ -47,7 +47,10 @@ def best_first( """Generic best-first graph search. ``priority`` maps ``(g, h)`` to the open-list key; ``heuristic`` is an - optional callable ``state -> number`` (``inf`` marks a dead end). + optional callable ``state -> number`` (``inf`` marks a dead end). The key + may be a tuple to break ties -- ``lambda g, h: (g + h, h)`` is A* that + prefers the node nearer the goal among equal ``f`` -- in which case + observers are given its first element as ``f``. ``observer`` is an optional :class:`~jupyddl.trace.SearchObserver` that is notified as the search progresses (see :mod:`jupyddl.trace`). """ @@ -97,11 +100,14 @@ def h_of(state) -> float: state = node.state if observer is not None: hv_node = hcache.get(state, 0.0) if heuristic is not None else 0.0 + f_node = priority(node.g, hv_node) + if isinstance(f_node, tuple): + f_node = f_node[0] observer.on_expand( state, g=node.g, h=hv_node, - f=priority(node.g, hv_node), + f=f_node, depth=node.depth, open_size=len(open_list), stats=stats, diff --git a/tests/conftest.py b/tests/conftest.py index 055ca07..33861c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,10 @@ import pytest +# Plugin discovery off: a developer's installed plugins must not change what the +# suite sees. tests/test_extending.py switches it back on where it tests it. +os.environ["JUPYDDL_NO_PLUGINS"] = "1" + REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) EXAMPLES = os.path.join(REPO_ROOT, "pddl-examples") diff --git a/tests/test_extending.py b/tests/test_extending.py new file mode 100644 index 0000000..4c7fd5e --- /dev/null +++ b/tests/test_extending.py @@ -0,0 +1,195 @@ +"""The extension guide and the plugin mechanism it documents. + +``.docs/EXTENDING.md`` is executable documentation: every ``python`` block in +it runs here, in order, in one namespace. A guide that drifts from the API it +describes fails this test instead of misleading the next contributor. +""" + +from __future__ import annotations + +import os +import re +import types +import warnings + +import pytest + +from jupyddl import plugins +from jupyddl.cli import main +from jupyddl.generator import GENERATORS, generate +from jupyddl.heuristics import HEURISTICS, make_heuristic +from jupyddl.search import PLANNERS, make_planner + +from conftest import REPO_ROOT + +GUIDE = os.path.join(REPO_ROOT, ".docs", "EXTENDING.md") +REGISTRIES = (PLANNERS, HEURISTICS, GENERATORS) + + +@pytest.fixture +def clean_registries(monkeypatch): + """Undo whatever a test registers, and reset plugin discovery.""" + saved = [dict(registry) for registry in REGISTRIES] + monkeypatch.setattr(plugins, "_loaded", False) + for group in plugins.GROUPS: + monkeypatch.setitem(plugins.REGISTERED, group, {}) + monkeypatch.setitem(plugins.SKIPPED, group, {}) + yield + for registry, before in zip(REGISTRIES, saved): + registry.clear() + registry.update(before) + + +def _python_blocks(path: str) -> list: + with open(path, encoding="utf-8") as handle: + text = handle.read() + return re.findall(r"^```python\n(.*?)^```", text, flags=re.S | re.M) + + +def test_the_guide_has_examples(): + assert len(_python_blocks(GUIDE)) >= 6 + + +def test_every_example_in_the_guide_runs(clean_registries, capsys): + namespace: dict = {"__name__": "extending_guide"} + for index, block in enumerate(_python_blocks(GUIDE)): + try: + exec(compile(block, f"EXTENDING.md[block {index}]", "exec"), namespace) + except Exception as exc: # pragma: no cover - the failure message + pytest.fail(f"block {index} of EXTENDING.md raised {exc!r}:\n{block}") + # What the guide registered is visible to the ordinary entry points. + assert {"astar-tb", "walk"} <= set(PLANNERS) + assert "achiever" in HEURISTICS and "corridor" in GENERATORS + assert generate("corridor", size=5, seed=1) == generate("corridor", size=5, seed=1) + + +# -- plugins ----------------------------------------------------------------- + + +class _Dist: + def __init__(self, name): + self.name = name + + +class _Entry: + """Just enough of ``importlib.metadata.EntryPoint`` for discovery.""" + + def __init__(self, name, value, target, dist="my-plugin"): + self.name, self.value, self._target = name, value, target + self.dist = _Dist(dist) + + def load(self): + if isinstance(self._target, Exception): + raise self._target + return self._target + + +def _install(monkeypatch, entries: dict): + """Pretend a package declaring ``entries`` (group -> list) is installed.""" + monkeypatch.delenv("JUPYDDL_NO_PLUGINS", raising=False) + monkeypatch.setattr( + plugins, "entry_points", lambda group: list(entries.get(group, [])) + ) + + +class _Planner: + name = "plug" + + def __init__(self, **kwargs): + self.kwargs = kwargs + + +def _heuristic(task): + return lambda state: 0.0 + + +def test_an_installed_plugin_is_found_on_first_lookup(monkeypatch, clean_registries): + _install( + monkeypatch, + { + "jupyddl.planners": [_Entry("plug", "pkg:_Planner", _Planner)], + "jupyddl.heuristics": [_Entry("zero", "pkg:_heuristic", _heuristic)], + }, + ) + assert "plug" not in PLANNERS + assert make_planner("plug", depth=3).kwargs == {"depth": 3} + assert make_heuristic("zero", task=None)(frozenset()) == 0.0 + assert plugins.REGISTERED["jupyddl.planners"] == {"plug": "my-plugin"} + + +def test_a_plugin_cannot_replace_a_builtin(monkeypatch, clean_registries): + builtin = PLANNERS["astar"] + _install( + monkeypatch, {"jupyddl.planners": [_Entry("astar", "pkg:_Planner", _Planner)]} + ) + with pytest.warns(plugins.PluginWarning, match="already registered"): + plugins.load_plugins() + assert PLANNERS["astar"] is builtin + assert "astar" in plugins.SKIPPED["jupyddl.planners"] + + +def test_a_broken_plugin_is_skipped_not_fatal(monkeypatch, clean_registries): + _install( + monkeypatch, + { + "jupyddl.planners": [ + _Entry("broken", "pkg:gone", ImportError("no module named pkg")), + _Entry("constant", "pkg:VALUE", 42), + _Entry("plug", "pkg:_Planner", _Planner), + ], + "jupyddl.heuristics": [_Entry("learned:x", "pkg:_heuristic", _heuristic)], + }, + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + plugins.load_plugins() + assert len([w for w in caught if w.category is plugins.PluginWarning]) == 3 + assert set(plugins.SKIPPED["jupyddl.planners"]) == {"broken", "constant"} + assert "learned:x" in plugins.SKIPPED["jupyddl.heuristics"] + assert "plug" in PLANNERS # the good one still lands + + +def test_discovery_runs_once_and_can_be_switched_off(monkeypatch, clean_registries): + calls = [] + + def entry_points(group): + calls.append(group) + return [] + + monkeypatch.setattr(plugins, "entry_points", entry_points) + monkeypatch.setenv("JUPYDDL_NO_PLUGINS", "1") + plugins.load_plugins() + assert calls == [] + monkeypatch.delenv("JUPYDDL_NO_PLUGINS") + plugins.load_plugins() + plugins.load_plugins() + assert calls == list(plugins.GROUPS) + + +def test_the_cli_sees_plugins(monkeypatch, clean_registries, capsys, tmp_path): + def generate_line(size=3, seed=0): + """A one-action line world.""" + domain = ( + "(define (domain line) (:requirements :strips)" + " (:predicates (a) (b)) (:action go :precondition (a) :effect (b)))" + ) + problem = "(define (problem p) (:domain line) (:init (a)) (:goal (b)))" + return domain, problem + + planner = types.new_class("Plugged", (PLANNERS["bfs"],)) + _install( + monkeypatch, + { + "jupyddl.planners": [_Entry("plugged", "pkg:Plugged", planner)], + "jupyddl.generators": [_Entry("line", "pkg:line", generate_line)], + }, + ) + assert main(["plugins"]) == 0 + listed = capsys.readouterr().out + assert "plugged" in listed and "line" in listed and "my-plugin" in listed + + assert main(["generate", "line", "-o", str(tmp_path)]) == 0 + folder = next(p for p in tmp_path.iterdir() if p.is_dir()) + domain, problem = str(folder / "domain.pddl"), str(folder / "problem.pddl") + assert main(["solve", domain, problem, "-s", "plugged", "-H", "none"]) == 0 + assert "go" in capsys.readouterr().out diff --git a/web/dist/build.json b/web/dist/build.json index ece55ff..a69ff51 100644 --- a/web/dist/build.json +++ b/web/dist/build.json @@ -1 +1 @@ -{"version": "2.4.0", "modules": 32} \ No newline at end of file +{"version": "2.4.0", "modules": 33} \ No newline at end of file diff --git a/web/dist/jupyddl-sources.json b/web/dist/jupyddl-sources.json index be6e2a6..0b07ce8 100644 --- a/web/dist/jupyddl-sources.json +++ b/web/dist/jupyddl-sources.json @@ -1 +1 @@ -{"jupyddl/__init__.py": "\"\"\"jupyddl: a pure-Python PDDL planning framework.\n\nQuickstart::\n\n from jupyddl import solve, build_task, trace_search, validate_plan\n\n result = solve(\"domain.pddl\", \"problem.pddl\", search=\"astar\", heuristic=\"lmcut\")\n print(result.solved, result.cost, result.plan_names())\n\n # ...and watch the search itself\n task = build_task(\"domain.pddl\", \"problem.pddl\")\n result, trace = trace_search(task, \"astar\", \"lmcut\")\n trace.save(\"run.json\")\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .api import build_task, solve, solve_task, trace_search, validate_plan\nfrom .grounding import ground, ground_files\nfrom .heuristics import HEURISTICS, make_heuristic\nfrom .parser import PDDLError, UnsupportedFeatureError, parse\nfrom .search import PLANNERS, SearchResult, make_planner\nfrom .task import Operator, Task\nfrom .trace import (\n MultiObserver,\n SearchEvent,\n SearchObserver,\n SearchTrace,\n TraceRecorder,\n)\n\n__version__ = \"2.4.0\"\n\n__all__ = [\n \"solve\",\n \"solve_task\",\n \"build_task\",\n \"trace_search\",\n \"validate_plan\",\n \"ground\",\n \"ground_files\",\n \"make_planner\",\n \"make_heuristic\",\n \"PLANNERS\",\n \"HEURISTICS\",\n \"SearchResult\",\n \"SearchTrace\",\n \"SearchEvent\",\n \"SearchObserver\",\n \"MultiObserver\",\n \"TraceRecorder\",\n \"Task\",\n \"Operator\",\n \"parse\",\n \"PDDLError\",\n \"UnsupportedFeatureError\",\n \"__version__\",\n]\n", "jupyddl/api.py": "\"\"\"High-level convenience API: parse + ground + plan + validate.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .grounding import ground_files\nfrom .heuristics import make_heuristic\nfrom .search import make_planner\nfrom .search.result import SearchResult, make_budget\nfrom .task import Task\nfrom .trace import TraceRecorder\n\n\ndef build_task(domain_path: str, problem_path: str) -> Task:\n \"\"\"Parse and ground a domain/problem pair into a :class:`Task`.\"\"\"\n return ground_files(domain_path, problem_path)\n\n\ndef solve_task(\n task: Task,\n search: str = \"astar\",\n heuristic=None,\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n) -> SearchResult:\n \"\"\"Run ``search`` (optionally with ``heuristic``) on an already-ground task.\n\n Pass ``observer`` (see :mod:`jupyddl.trace`) to record or live-render the\n search as it runs. ``max_expansions`` and ``time_limit`` bound the run; when\n either is hit the planner stops and sets ``result.truncated``, so an empty\n result means \"gave up\", not \"proved unsolvable\".\n \"\"\"\n planner = make_planner(search, **planner_kwargs)\n heur = None\n name = heuristic if heuristic else (\"hff\" if planner.requires_heuristic else None)\n if name is not None:\n heur = make_heuristic(name, task)\n budget = make_budget(max_expansions, time_limit)\n return planner.search(task, heur, observer=observer, budget=budget)\n\n\ndef solve(\n domain_path: str,\n problem_path: str,\n search: str = \"astar\",\n heuristic=\"lmcut\",\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n) -> SearchResult:\n \"\"\"Parse, ground and solve a PDDL instance in one call.\"\"\"\n task = build_task(domain_path, problem_path)\n return solve_task(\n task,\n search=search,\n heuristic=heuristic,\n observer=observer,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **planner_kwargs,\n )\n\n\ndef trace_search(\n task: Task,\n search: str = \"astar\",\n heuristic=None,\n max_events: int = 20000,\n record_generated: bool = False,\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n):\n \"\"\"Solve ``task`` while recording the search.\n\n Returns ``(result, trace)`` where ``trace`` is a\n :class:`~jupyddl.trace.SearchTrace` ready to plot, save or replay. An extra\n ``observer`` (a live dashboard, say) is notified alongside the recorder.\n \"\"\"\n recorder = TraceRecorder(max_events=max_events, record_generated=record_generated)\n if observer is not None:\n from .trace import MultiObserver\n\n sink = MultiObserver(recorder, observer)\n else:\n sink = recorder\n result = solve_task(\n task,\n search=search,\n heuristic=heuristic,\n observer=sink,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **planner_kwargs,\n )\n return result, recorder.trace\n\n\ndef validate_plan(task: Task, plan) -> bool:\n \"\"\"Return ``True`` iff applying ``plan`` from the initial state reaches the goal.\n\n Replays through the task rather than the raw operators, so derived\n predicates are closed and numeric fluents are carried at every step \u2014\n validating against ``task.init`` directly would see neither.\n \"\"\"\n state = task.initial_state()\n for op in plan or ():\n if not op.applicable(state):\n return False\n state = task.apply(op, state)\n return task.goal_reached(state)\n", "jupyddl/benchmark.py": "\"\"\"Comparative benchmarking of planners/heuristics over PDDL instances.\n\nExample::\n\n from jupyddl.benchmark import discover_instances, run_benchmark, to_csv\n rows = run_benchmark(discover_instances(\"pddl-examples\"),\n [(\"astar\", \"lmcut\"), (\"gbfs\", \"hff\")])\n to_csv(rows, \"results.csv\")\n\"\"\"\n\nfrom __future__ import annotations\n\nimport csv\nimport glob\nimport os\nfrom dataclasses import asdict, dataclass\nfrom typing import Optional\n\nfrom .api import build_task, solve_task, validate_plan\nfrom .parser import PDDLError\n\n\n@dataclass\nclass Instance:\n name: str\n domain: str\n problem: str\n\n\n@dataclass\nclass BenchmarkRow:\n instance: str\n planner: str\n heuristic: str\n solved: bool\n valid: bool\n cost: Optional[int]\n plan_length: Optional[int]\n expanded: int\n generated: int\n evaluated: int\n runtime: float\n error: str = \"\"\n truncated: bool = False\n\n\ndef discover_instances(root: str) -> list:\n \"\"\"Find ``/*/`` folders containing both ``domain.pddl`` and ``problem.pddl``.\"\"\"\n instances = []\n for domain in sorted(glob.glob(os.path.join(root, \"*\", \"domain.pddl\"))):\n folder = os.path.dirname(domain)\n problem = os.path.join(folder, \"problem.pddl\")\n if os.path.exists(problem):\n instances.append(Instance(os.path.basename(folder), domain, problem))\n return instances\n\n\ndef run_benchmark(instances, configs, max_expansions=None, time_limit=None) -> list:\n \"\"\"Run each ``(planner, heuristic[, kwargs])`` config on each instance.\n\n ``configs`` entries are ``(planner_name, heuristic_name_or_None)`` or\n ``(planner_name, heuristic_name_or_None, planner_kwargs)``.\n\n ``max_expansions`` and ``time_limit`` bound every individual run, which is\n what keeps one pathological instance from stalling a whole benchmark. A run\n that stops on its budget is recorded with ``truncated=True`` and does not\n count towards coverage -- \"we stopped looking\" is not \"no plan exists\".\n \"\"\"\n rows: list = []\n for inst in instances:\n try:\n task = build_task(inst.domain, inst.problem)\n except (PDDLError, ValueError) as exc:\n for cfg in configs:\n planner, heuristic = cfg[0], (cfg[1] or \"\")\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n heuristic,\n False,\n False,\n None,\n None,\n 0,\n 0,\n 0,\n 0.0,\n f\"{type(exc).__name__}: {exc}\",\n )\n )\n continue\n for cfg in configs:\n planner = cfg[0]\n heuristic = cfg[1]\n kwargs = cfg[2] if len(cfg) > 2 else {}\n try:\n result = solve_task(\n task,\n planner,\n heuristic,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **kwargs,\n )\n valid = bool(result.solved and validate_plan(task, result.plan))\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n heuristic or \"\",\n result.solved,\n valid,\n result.cost,\n result.plan_length,\n result.stats.expanded,\n result.stats.generated,\n result.stats.evaluated,\n round(result.stats.runtime, 6),\n \"\",\n result.stats.truncated,\n )\n )\n except Exception as exc: # keep the benchmark going on a single failure\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n heuristic or \"\",\n False,\n False,\n None,\n None,\n 0,\n 0,\n 0,\n 0.0,\n f\"{type(exc).__name__}: {exc}\",\n )\n )\n return rows\n\n\ndef to_csv(rows, path: str) -> None:\n fieldnames = (\n list(asdict(rows[0]).keys())\n if rows\n else [f.name for f in BenchmarkRow.__dataclass_fields__.values()]\n )\n with open(path, \"w\", newline=\"\", encoding=\"utf-8\") as handle:\n writer = csv.DictWriter(handle, fieldnames=fieldnames)\n writer.writeheader()\n for row in rows:\n writer.writerow(asdict(row))\n\n\ndef summarize(rows) -> dict:\n \"\"\"Aggregate coverage and totals per ``planner/heuristic`` configuration.\"\"\"\n summary: dict = {}\n for row in rows:\n key = f\"{row.planner}/{row.heuristic}\" if row.heuristic else row.planner\n agg = summary.setdefault(\n key, {\"coverage\": 0, \"expanded\": 0, \"runtime\": 0.0, \"instances\": 0}\n )\n agg[\"instances\"] += 1\n agg[\"coverage\"] += int(row.valid)\n agg[\"expanded\"] += row.expanded\n agg[\"runtime\"] += row.runtime\n return summary\n\n\ndef plot_summary(rows, path: str, metric: str = \"expanded\") -> None:\n \"\"\"Bar chart of a metric per configuration (requires the ``viz`` extra).\"\"\"\n import matplotlib\n\n matplotlib.use(\"Agg\")\n import matplotlib.pyplot as plt\n\n summary = summarize(rows)\n labels = list(summary.keys())\n values = [summary[k][metric] for k in labels]\n fig, ax = plt.subplots(figsize=(max(6, len(labels) * 0.9), 4))\n ax.bar(labels, values, color=\"#4C72B0\")\n ax.set_ylabel(f\"total {metric}\")\n ax.set_title(f\"Planner comparison ({metric})\")\n plt.xticks(rotation=45, ha=\"right\")\n plt.tight_layout()\n fig.savefig(path, dpi=120)\n plt.close(fig)\n", "jupyddl/cli.py": "\"\"\"Command-line interface.\n\n``solve``, ``benchmark``, ``animate``, ``demo``, ``requirements``, ``generate``\nand ``learn``. Every long-running command accepts ``--max-expansions`` and\n``--time-limit``; when a search stops on one of those it says so rather than\nreporting the instance unsolvable.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport sys\n\nfrom .api import build_task, solve_task, trace_search, validate_plan\nfrom .benchmark import (\n discover_instances,\n plot_summary,\n run_benchmark,\n summarize,\n to_csv,\n)\nfrom .heuristics import HEURISTICS, LOADERS\nfrom .search import INFORMED_PLANNERS, PLANNERS\n\nINFORMED = set(INFORMED_PLANNERS)\n\n\ndef heuristic_spec(value: str) -> str:\n \"\"\"Validate ``-H``: a registry name, ``none``, or ``kind:argument``.\n\n ``choices=`` cannot express this \u2014 a trained heuristic is identified by a\n path that does not exist until someone trains one \u2014 so the check is a type\n function instead. It still rejects typos eagerly, which is the only reason\n ``choices=`` was worth having.\n \"\"\"\n if value in HEURISTICS or value == \"none\":\n return value\n kind, sep, argument = value.partition(\":\")\n if sep and kind in LOADERS:\n if not argument:\n raise argparse.ArgumentTypeError(\n f\"'{kind}:' needs an argument, e.g. {kind}:model.json\"\n )\n return value\n raise argparse.ArgumentTypeError(\n f\"unknown heuristic '{value}'; expected one of \"\n f\"{sorted(HEURISTICS) + ['none']} \"\n f\"or {'/'.join(sorted(LOADERS))}:\"\n )\n\n\ndef _add_solve(sub):\n p = sub.add_parser(\"solve\", help=\"solve a single PDDL instance\")\n p.add_argument(\"domain\")\n p.add_argument(\"problem\")\n p.add_argument(\"-s\", \"--search\", default=\"astar\", choices=sorted(PLANNERS))\n p.add_argument(\n \"-H\",\n \"--heuristic\",\n default=\"lmcut\",\n type=heuristic_spec,\n metavar=\"NAME\",\n help=\"a heuristic name, none, or learned:\",\n )\n p.add_argument(\n \"-w\", \"--weight\", type=float, default=2.0, help=\"weight for weighted A*\"\n )\n p.add_argument(\n \"--live\",\n action=\"store_true\",\n help=\"watch the search live in the terminal (no dependencies)\",\n )\n p.add_argument(\"--trace\", default=None, help=\"write the search trace as JSON\")\n p.add_argument(\n \"--plot\",\n default=None,\n help=\"write a four-panel search-progress chart (PNG; needs the viz extra)\",\n )\n p.add_argument(\n \"--tree\", default=None, help=\"write the radial search-wavefront chart (PNG)\"\n )\n p.add_argument(\n \"--plan-plot\", default=None, help=\"write the plan timeline chart (PNG)\"\n )\n p.add_argument(\"--dark\", action=\"store_true\", help=\"render charts for dark mode\")\n p.add_argument(\n \"--max-expansions\",\n type=int,\n default=None,\n help=\"stop after this many node expansions and report what was found\",\n )\n p.add_argument(\n \"--time-limit\",\n type=float,\n default=None,\n help=\"stop after this many seconds and report what was found\",\n )\n p.add_argument(\"--quiet\", action=\"store_true\", help=\"do not print the plan\")\n p.set_defaults(func=_cmd_solve)\n\n\ndef _add_benchmark(sub):\n p = sub.add_parser(\"benchmark\", help=\"compare planners over a folder of instances\")\n p.add_argument(\"root\", help=\"folder containing /domain.pddl + problem.pddl\")\n p.add_argument(\"--planners\", default=\"bfs,dijkstra,astar,gbfs,wastar,ehc\")\n p.add_argument(\"--heuristic\", default=\"hff\", help=\"heuristic for informed planners\")\n p.add_argument(\"--csv\", default=None, help=\"write per-run results to this CSV\")\n p.add_argument(\"--plot\", default=None, help=\"write a comparison bar chart (PNG)\")\n p.add_argument(\n \"--dashboard\",\n default=None,\n help=\"write the full benchmark dashboard: coverage, effort, time, heatmap\",\n )\n p.add_argument(\"--metric\", default=\"expanded\")\n p.add_argument(\"--dark\", action=\"store_true\", help=\"render charts for dark mode\")\n p.add_argument(\n \"--max-expansions\",\n type=int,\n default=None,\n help=\"stop after this many node expansions and report what was found\",\n )\n p.add_argument(\n \"--time-limit\",\n type=float,\n default=None,\n help=\"stop after this many seconds and report what was found\",\n )\n p.set_defaults(func=_cmd_benchmark)\n\n\ndef _add_animate(sub):\n p = sub.add_parser(\"animate\", help=\"replay a search as an animation (MP4 or GIF)\")\n p.add_argument(\"domain\")\n p.add_argument(\"problem\")\n p.add_argument(\"-o\", \"--output\", default=\"search.mp4\")\n p.add_argument(\"-s\", \"--search\", default=\"astar\", choices=sorted(PLANNERS))\n p.add_argument(\n \"-H\",\n \"--heuristic\",\n default=\"lmcut\",\n type=heuristic_spec,\n metavar=\"NAME\",\n help=\"a heuristic name, none, or learned:\",\n )\n p.add_argument(\"--fps\", type=int, default=30)\n p.add_argument(\"--seconds\", type=float, default=8.0)\n p.add_argument(\"--dark\", action=\"store_true\")\n p.set_defaults(func=_cmd_animate)\n\n\ndef _add_demo(sub):\n p = sub.add_parser(\n \"demo\",\n help=\"run the bundled demo instances and write every chart to a folder\",\n )\n p.add_argument(\"-o\", \"--output\", default=\"gallery\", help=\"output folder\")\n p.add_argument(\"--root\", default=\"demos\", help=\"folder of demo instances\")\n p.add_argument(\n \"--both-modes\",\n action=\"store_true\",\n help=\"render a light and a dark variant of every chart\",\n )\n p.add_argument(\n \"--animate\", action=\"store_true\", help=\"also render the search animations\"\n )\n p.set_defaults(func=_cmd_demo)\n\n\n# --------------------------------------------------------------------------\ndef _observers(args):\n \"\"\"Build the observer for a solve, honouring --live/--trace/--plot.\"\"\"\n from .trace import MultiObserver, TraceRecorder\n\n wants_trace = bool(\n args.trace\n or args.plot\n or getattr(args, \"tree\", None)\n or getattr(args, \"plan_plot\", None)\n )\n recorder = TraceRecorder() if wants_trace else None\n dashboard = None\n if args.live:\n from .live import TerminalDashboard\n\n dashboard = TerminalDashboard()\n if recorder and dashboard:\n return MultiObserver(recorder, dashboard), recorder\n return (dashboard or recorder), recorder\n\n\ndef _cmd_solve(args) -> int:\n task = build_task(args.domain, args.problem)\n heuristic = None if args.heuristic == \"none\" else args.heuristic\n kwargs = {\"weight\": args.weight} if args.search == \"wastar\" else {}\n observer, recorder = _observers(args)\n result = solve_task(\n task,\n args.search,\n heuristic,\n observer=observer,\n max_expansions=args.max_expansions,\n time_limit=args.time_limit,\n **kwargs,\n )\n\n trace = recorder.trace if recorder else None\n if trace is not None:\n if args.trace:\n trace.save(args.trace)\n print(f\"Wrote trace to {args.trace}\")\n _write_charts(args, trace)\n\n if not result.solved:\n if not args.live:\n if result.truncated:\n print(\n \"No plan found within the budget \"\n \"(the instance may still be solvable).\"\n )\n else:\n print(\"No plan found.\")\n _print_stats(result)\n return 1\n valid = validate_plan(task, result.plan)\n visible = task.visible_plan(result.plan)\n if not args.quiet:\n header = f\"Plan ({len(visible)} steps, cost {result.cost}\"\n if task.temporal:\n header += f\", makespan {task.makespan(result.plan):g}\"\n print(header + \"):\")\n for i, op in enumerate(visible):\n print(f\" {i + 1:3d}. {op.base_name}\")\n print(f\"Valid: {valid}\")\n if not args.live:\n _print_stats(result)\n return 0 if valid else 2\n\n\ndef _write_charts(args, trace) -> None:\n targets = [\n (args.plot, \"plot_search_progress\"),\n (getattr(args, \"tree\", None), \"plot_search_tree\"),\n (getattr(args, \"plan_plot\", None), \"plot_plan_timeline\"),\n ]\n if not any(path for path, _ in targets):\n return\n try:\n from . import viz\n except ImportError as exc:\n print(f\"Charts need the viz extra: {exc}\", file=sys.stderr)\n return\n for path, function in targets:\n if not path:\n continue\n getattr(viz, function)(trace, path, dark=args.dark)\n print(f\"Wrote {path}\")\n\n\ndef _cmd_benchmark(args) -> int:\n instances = discover_instances(args.root)\n if not instances:\n print(f\"No instances found under {args.root}\", file=sys.stderr)\n return 1\n configs = []\n for planner in args.planners.split(\",\"):\n planner = planner.strip()\n configs.append((planner, args.heuristic if planner in INFORMED else None))\n\n rows = run_benchmark(\n instances,\n configs,\n max_expansions=args.max_expansions,\n time_limit=args.time_limit,\n )\n _print_summary(summarize(rows))\n if args.csv:\n to_csv(rows, args.csv)\n print(f\"\\nWrote per-run results to {args.csv}\")\n if args.plot:\n plot_summary(rows, args.plot, metric=args.metric)\n print(f\"Wrote plot to {args.plot}\")\n if args.dashboard:\n from .viz import plot_benchmark_dashboard\n\n plot_benchmark_dashboard(rows, args.dashboard, dark=args.dark)\n print(f\"Wrote dashboard to {args.dashboard}\")\n return 0\n\n\ndef _cmd_animate(args) -> int:\n from .viz import animate_search\n\n task = build_task(args.domain, args.problem)\n heuristic = None if args.heuristic == \"none\" else args.heuristic\n _, trace = trace_search(task, args.search, heuristic)\n animate_search(\n trace, args.output, dark=args.dark, fps=args.fps, seconds=args.seconds\n )\n print(f\"Wrote animation to {args.output}\")\n return 0\n\n\ndef _add_requirements(sub):\n p = sub.add_parser(\n \"requirements\",\n help=\"show which PDDL requirement flags are supported, and how\",\n )\n p.add_argument(\n \"--support\",\n default=None,\n help=\"filter by support level: native, compiled, partial or rejected\",\n )\n p.add_argument(\"--json\", action=\"store_true\", help=\"emit machine-readable JSON\")\n p.add_argument(\"--verbose\", action=\"store_true\", help=\"include the full notes\")\n p.set_defaults(func=_cmd_requirements)\n\n\ndef _add_generate(sub):\n from .generator import GENERATORS\n\n p = sub.add_parser(\n \"generate\", help=\"generate PDDL instances reproducibly from a seed\"\n )\n p.add_argument(\"kind\", choices=sorted(GENERATORS))\n p.add_argument(\"-o\", \"--output\", default=None, help=\"write into this folder\")\n p.add_argument(\"-n\", \"--size\", type=int, default=4, help=\"instance size\")\n p.add_argument(\"--seed\", type=int, default=0)\n p.add_argument(\n \"--count\",\n type=int,\n default=1,\n help=\"generate a ladder of instances with increasing size\",\n )\n p.add_argument(\n \"--step\", type=int, default=1, help=\"size increment between ladder rungs\"\n )\n p.set_defaults(func=_cmd_generate)\n\n\ndef _add_learn(sub):\n from .generator import GENERATORS\n\n p = sub.add_parser(\n \"learn\",\n help=\"train a heuristic from solved plans, then reinforce it on search cost\",\n description=(\n \"Generate a ladder of instances, solve the small ones, fit a network \"\n \"to the cost-to-go their plans reveal, and optionally tune it against \"\n \"the number of nodes search actually expands. Writes a model that \"\n \"'-H learned:' accepts everywhere.\"\n ),\n )\n p.add_argument(\"kind\", choices=sorted(GENERATORS))\n p.add_argument(\"-o\", \"--output\", default=None, help=\"write the model here\")\n p.add_argument(\n \"--sizes\",\n default=\"3-6\",\n help=\"training ladder, as 'lo-hi' (default 3-6). Keep these small: they \"\n \"have to be solvable optimally\",\n )\n p.add_argument(\n \"--seeds-per-size\",\n type=int,\n default=2,\n help=\"instances per rung; more seeds usually beats more rungs\",\n )\n p.add_argument(\"--seed\", type=int, default=0)\n p.add_argument(\"--epochs\", type=int, default=60)\n p.add_argument(\n \"--rank-weight\",\n type=float,\n default=0.8,\n help=\"share of the objective spent on ordering rather than magnitude; \"\n \"GBFS only reads the order\",\n )\n p.add_argument(\n \"--dagger\",\n type=int,\n default=0,\n metavar=\"ROUNDS\",\n help=\"retrain on the states the heuristic's own search visits\",\n )\n p.add_argument(\n \"--bootstrap\",\n default=None,\n metavar=\"LO-HI\",\n help=\"grow the corpus with harder instances as they become solvable\",\n )\n p.add_argument(\n \"--cem\",\n type=int,\n default=0,\n metavar=\"ITERATIONS\",\n help=\"optimise expansions directly (needs instances with headroom; \"\n \"see --cem-sizes)\",\n )\n p.add_argument(\n \"--cem-sizes\",\n default=None,\n metavar=\"LO-HI\",\n help=\"instances to tune search cost on; defaults to a rung above the \"\n \"training ladder, because the training ladder has no headroom left\",\n )\n p.add_argument(\n \"--evaluate\",\n default=None,\n metavar=\"LO-HI\",\n help=\"after training, benchmark against hff/goalcount on these sizes\",\n )\n p.set_defaults(func=_cmd_learn)\n\n\ndef _range(spec: str):\n \"\"\"Parse ``'3-6'`` or ``'5'`` into a range.\"\"\"\n text = str(spec).strip()\n if \"-\" in text:\n lo, _, hi = text.partition(\"-\")\n return range(int(lo), int(hi) + 1)\n return range(int(text), int(text) + 1)\n\n\ndef _cmd_learn(args) -> int:\n from .learn import RLConfig, TrainConfig, learn_heuristic\n from .learn.pipeline import (\n evaluate_transfer,\n summarise_transfer,\n tasks_from_generator,\n )\n\n output = args.output or f\"{args.kind}.heur.json\"\n try:\n bundle = learn_heuristic(\n args.kind,\n sizes=_range(args.sizes),\n seeds_per_size=args.seeds_per_size,\n seed=args.seed,\n train_config=TrainConfig(\n epochs=args.epochs, rank_weight=args.rank_weight, seed=args.seed\n ),\n rl_config=RLConfig(seed=args.seed, verbose=True),\n dagger_rounds=args.dagger,\n bootstrap_sizes=_range(args.bootstrap) if args.bootstrap else None,\n cem_iterations=args.cem,\n cem_sizes=_range(args.cem_sizes) if args.cem_sizes else None,\n verbose=True,\n )\n except RuntimeError as exc:\n print(f\"error: {exc}\", file=sys.stderr)\n return 1\n\n bundle.save(output)\n print(f\"\\nWrote {output}\")\n print(\n f\" features {bundle.space.size} over {len(bundle.space.vocabulary)} predicates\"\n )\n print(f\" parameters {bundle.model.num_parameters}\")\n metrics = bundle.metrics\n print(\n f\" held out MAE {metrics.get('mae', 0):.2f}\"\n f\" top-1 {metrics.get('top1', 0):.3f}\"\n f\" over {metrics.get('held_out_instances', 0)} instances\"\n )\n print(f\"\\nUse it:\\n jupyddl solve -s gbfs -H learned:{output}\")\n\n if args.evaluate:\n tasks = tasks_from_generator(\n args.kind, _range(args.evaluate), seed=args.seed + 7777, seeds_per_size=2\n )\n print(f\"\\nBenchmark on {len(tasks)} unseen instances (gbfs):\")\n rows = evaluate_transfer(bundle, tasks)\n summary = summarise_transfer(rows)\n print(\n f\" {'heuristic':<12}{'coverage':>10}{'expanded':>12}\"\n f\"{'seconds':>10}{'cost':>8}\"\n )\n for name in [\"learned\"] + [k for k in summary if k != \"learned\"]:\n agg = summary[name]\n print(\n f\" {name:<12}{agg['coverage']:>10.2f}{agg['mean_expanded']:>12.0f}\"\n f\"{agg['mean_seconds']:>10.3f}{agg['mean_cost']:>8.1f}\"\n )\n return 0\n\n\ndef _cmd_requirements(args) -> int:\n from .requirements import as_rows, summary\n\n rows = as_rows()\n if args.support:\n rows = [row for row in rows if row[\"support\"] == args.support]\n if not rows:\n print(\n f\"No requirements with support level '{args.support}'.\", file=sys.stderr\n )\n return 1\n\n if args.json:\n import json\n\n print(json.dumps({\"requirements\": rows, \"summary\": summary()}, indent=2))\n return 0\n\n counts = summary()\n print(\n f\"jupyddl PDDL support: {counts['native']} native, \"\n f\"{counts['compiled']} compiled, {counts['partial']} partial, \"\n f\"{counts['rejected']} rejected\\n\"\n )\n print(f\"{'requirement':<30}{'PDDL':<7}{'support':<11}summary\")\n print(\"-\" * 100)\n for row in rows:\n print(f\"{row['name']:<30}{row['pddl']:<7}{row['support']:<11}{row['summary']}\")\n if args.verbose and row[\"note\"]:\n for line in _wrap(row[\"note\"], 92):\n print(f\"{'':<48}{line}\")\n if not args.verbose:\n print(\"\\nRun with --verbose for the details of each compilation.\")\n return 0\n\n\ndef _wrap(text: str, width: int) -> list:\n words = text.split()\n lines, current = [], \"\"\n for word in words:\n if len(current) + len(word) + 1 > width:\n lines.append(current)\n current = word\n else:\n current = f\"{current} {word}\".strip()\n if current:\n lines.append(current)\n return lines\n\n\ndef _cmd_generate(args) -> int:\n from .generator import generate, write_instance\n\n sizes = [args.size + i * args.step for i in range(max(1, args.count))]\n if args.output is None:\n if len(sizes) > 1:\n print(\n \"Generating a ladder needs --output; a single instance can go \"\n \"to stdout but several cannot.\",\n file=sys.stderr,\n )\n return 1\n domain, problem = generate(args.kind, size=args.size, seed=args.seed)\n print(\";; ---------- domain.pddl ----------\")\n print(domain)\n print(\";; ---------- problem.pddl ----------\")\n print(problem)\n return 0\n\n for size in sizes:\n folder = write_instance(args.kind, args.output, size=size, seed=args.seed)\n print(f\"Wrote {folder}\")\n return 0\n\n\ndef _cmd_demo(args) -> int:\n \"\"\"Render the whole gallery: per-instance charts plus a benchmark dashboard.\"\"\"\n from .viz import (\n plot_benchmark_dashboard,\n plot_plan_timeline,\n plot_planner_comparison,\n plot_search_progress,\n plot_search_tree,\n )\n\n instances = discover_instances(args.root)\n if not instances:\n print(f\"No instances found under {args.root}\", file=sys.stderr)\n return 1\n os.makedirs(args.output, exist_ok=True)\n modes = [False, True] if args.both_modes else [False]\n\n configs = [(\"astar\", \"lmcut\"), (\"astar\", \"hmax\"), (\"gbfs\", \"hff\"), (\"bfs\", None)]\n for instance in instances:\n print(f\"-- {instance.name}\")\n try:\n task = build_task(instance.domain, instance.problem)\n except Exception as exc:\n print(f\" skipped: {type(exc).__name__}: {exc}\")\n continue\n traces = []\n for planner, heuristic in configs:\n try:\n _, trace = trace_search(task, planner, heuristic)\n traces.append(trace)\n except Exception as exc:\n print(f\" {planner}/{heuristic}: {type(exc).__name__}: {exc}\")\n if not traces:\n continue\n for dark in modes:\n suffix = \"-dark\" if dark else \"\"\n base = os.path.join(args.output, instance.name)\n plot_search_progress(traces[0], f\"{base}-progress{suffix}.png\", dark=dark)\n plot_search_tree(traces[0], f\"{base}-tree{suffix}.png\", dark=dark)\n plot_plan_timeline(traces[0], f\"{base}-plan{suffix}.png\", dark=dark)\n plot_planner_comparison(traces, f\"{base}-compare{suffix}.png\", dark=dark)\n if args.animate:\n from .viz import animate_search\n\n animate_search(\n traces[0], os.path.join(args.output, f\"{instance.name}-search.mp4\")\n )\n\n rows = run_benchmark(\n instances,\n [\n (\"astar\", \"lmcut\"),\n (\"astar\", \"hmax\"),\n (\"gbfs\", \"hff\"),\n (\"ehc\", \"hff\"),\n (\"bfs\", None),\n (\"dijkstra\", None),\n ],\n )\n to_csv(rows, os.path.join(args.output, \"benchmark.csv\"))\n for dark in modes:\n suffix = \"-dark\" if dark else \"\"\n plot_benchmark_dashboard(\n rows, os.path.join(args.output, f\"benchmark{suffix}.png\"), dark=dark\n )\n print(f\"\\nGallery written to {args.output}/\")\n return 0\n\n\ndef _print_stats(result) -> None:\n s = result.stats\n print(\n f\"Stats: expanded={s.expanded} generated={s.generated} \"\n f\"evaluated={s.evaluated} reopened={s.reopened} \"\n f\"deadends={s.deadends} runtime={s.runtime:.4f}s\"\n )\n\n\ndef _print_summary(summary) -> None:\n print(f\"{'config':<20}{'coverage':>12}{'expanded':>12}{'runtime(s)':>12}\")\n print(\"-\" * 56)\n for key, agg in summary.items():\n cov = f\"{agg['coverage']}/{agg['instances']}\"\n print(f\"{key:<20}{cov:>12}{agg['expanded']:>12}{agg['runtime']:>12.3f}\")\n\n\ndef main(argv=None) -> int:\n parser = argparse.ArgumentParser(\n prog=\"jupyddl\",\n description=\"Pure-Python PDDL planning framework.\",\n )\n sub = parser.add_subparsers(dest=\"command\", required=True)\n _add_solve(sub)\n _add_benchmark(sub)\n _add_animate(sub)\n _add_demo(sub)\n _add_requirements(sub)\n _add_generate(sub)\n _add_learn(sub)\n args = parser.parse_args(argv)\n return args.func(args)\n\n\nif __name__ == \"__main__\": # pragma: no cover\n sys.exit(main())\n", "jupyddl/compile.py": "\"\"\"Compile PDDL 3 constructs down to the classical core.\n\nPreferences, trajectory constraints, timed initial literals and object fluents\nare all rewritten here, at the AST level, *before* grounding. The grounder and\nthe search never learn they existed \u2014 which is the point: one representation to\noptimise, and every new front-end feature is a source-to-source transformation\nrather than another special case in the hot loop.\n\nEach compilation is documented in :mod:`jupyddl.requirements`, including what it\ncosts you. The synthetic actions introduced along the way are all named with a\nleading ``__``; :attr:`jupyddl.task.Task.synthetic` collects them so a printed\nplan shows only the actions the domain author wrote.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import replace\n\nfrom .parser.ast import (\n Action,\n AddEffect,\n And,\n Atom,\n Comparison,\n ConjunctiveEffect,\n DelEffect,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Predicate,\n Preference,\n Problem,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\n\n# Every fact and action this module invents is prefixed, so they cannot collide\n# with a domain's own names and are easy to filter out of a plan.\nPREFIX = \"__\"\nCLOCK = FluentRef(\"__time\", ())\n\n__all__ = [\"compile_problem\", \"SYNTHETIC_PREFIX\"]\n\nSYNTHETIC_PREFIX = PREFIX\n\n\n# --------------------------------------------------------------------------\n# small AST helpers\n# --------------------------------------------------------------------------\ndef _fact(name: str, *args) -> Atom:\n return Atom(name, tuple(args))\n\n\ndef _holds(name: str, *args) -> Literal:\n return Literal(_fact(name, *args), True)\n\n\ndef _absent(name: str, *args) -> Literal:\n return Literal(_fact(name, *args), False)\n\n\ndef _conjoin(*parts):\n \"\"\"And() over the parts, dropping trivially-true ones.\"\"\"\n kept = [p for p in parts if p is not None and not isinstance(p, Truth)]\n if not kept:\n return Truth(True)\n if len(kept) == 1:\n return kept[0]\n return And(tuple(kept))\n\n\ndef _free():\n \"\"\"An explicit zero cost.\n\n Grounding charges 1 for any action without a cost effect, which is right for\n a domain action and wrong for the bookkeeping this module invents: closing\n the plan or observing that a constraint held must not show up in the metric.\n \"\"\"\n return IncreaseCostEffect(0.0)\n\n\ndef _also(effect, *extra):\n \"\"\"Append effects to an existing effect tree.\"\"\"\n parts = list(effect.parts) if isinstance(effect, ConjunctiveEffect) else [effect]\n parts.extend(extra)\n return ConjunctiveEffect(parts)\n\n\ndef _negate(formula):\n \"\"\"Negation normal form of ``not formula``.\"\"\"\n if isinstance(formula, Truth):\n return Truth(not formula.value)\n if isinstance(formula, Literal):\n return Literal(formula.atom, not formula.positive)\n if isinstance(formula, EqualityConstraint):\n return EqualityConstraint(formula.left, formula.right, not formula.positive)\n if isinstance(formula, Comparison):\n flip = {\"<\": \">=\", \"<=\": \">\", \">\": \"<=\", \">=\": \"<\", \"=\": \"!=\", \"!=\": \"=\"}\n return Comparison(flip[formula.op], formula.left, formula.right)\n if isinstance(formula, And):\n return Or(tuple(_negate(p) for p in formula.parts))\n if isinstance(formula, Or):\n return And(tuple(_negate(p) for p in formula.parts))\n if isinstance(formula, Forall):\n return Exists(formula.params, _negate(formula.body))\n if isinstance(formula, Exists):\n return Forall(formula.params, _negate(formula.body))\n raise PDDLError(f\"cannot negate {formula!r}\")\n\n\ndef _is_domain_action(action: Action) -> bool:\n return not action.name.startswith(PREFIX)\n\n\ndef _declare(domain: Domain, name: str, params=()) -> None:\n if all(p.name != name for p in domain.predicates):\n domain.predicates.append(Predicate(name, list(params)))\n\n\ndef _add_precondition(domain: Domain, formula, only_domain_actions=True) -> None:\n \"\"\"Conjoin ``formula`` onto every action's precondition.\"\"\"\n for index, action in enumerate(domain.actions):\n if only_domain_actions and not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action, precondition=_conjoin(action.precondition, formula)\n )\n\n\ndef _add_conditional_effect(domain: Domain, condition, body) -> None:\n \"\"\"Give every domain action a ``when condition body`` effect.\n\n Used for monitors that must not be optional: the planner cannot decline to\n notice that a constraint's trigger became true.\n \"\"\"\n for index, action in enumerate(domain.actions):\n if not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action, effect=_also(action.effect, WhenEffect(condition, body))\n )\n\n\n# --------------------------------------------------------------------------\n# object fluents\n# --------------------------------------------------------------------------\ndef compile_object_fluents(domain: Domain, problem: Problem):\n \"\"\"Turn ``(location ?p) - place`` into a predicate plus a uniqueness rule.\n\n ``(= (location ?p) ?x)`` becomes ``(__fn-location ?p ?x)``, and\n ``(assign (location ?p) ?y)`` clears the old value before setting the new\n one, so the predicate stays single-valued. Object fluents used as *nested\n terms* \u2014 ``(at ?t (location ?p))`` \u2014 are refused: flattening those needs a\n fresh existential per occurrence, and the equality form above expresses the\n same thing without the guesswork.\n \"\"\"\n if not domain.object_fluents:\n return domain, problem\n\n fluents = {fluent.name: fluent for fluent in domain.object_fluents}\n\n def predicate_name(name: str) -> str:\n return f\"{PREFIX}fn-{name}\"\n\n for fluent in domain.object_fluents:\n params = list(fluent.params) + [(\"?__value\", fluent.result_type)]\n _declare(domain, predicate_name(fluent.name), params)\n\n def rewrite_condition(formula):\n if isinstance(formula, Comparison) and formula.op in (\"=\", \"!=\"):\n left, right = formula.left, formula.right\n for a, b in ((left, right), (right, left)):\n if isinstance(a, FluentRef) and a.name in fluents:\n if isinstance(b, Number):\n raise UnsupportedFeatureError(\n f\"'{a.name}' returns an object, so it cannot be \"\n \"compared with a number\"\n )\n value = b.name if isinstance(b, FluentRef) else str(b)\n if isinstance(b, FluentRef) and b.name in fluents:\n raise UnsupportedFeatureError(\n \"comparing two object fluents directly is not \"\n \"supported; introduce a variable for one of them\"\n )\n atom = _fact(predicate_name(a.name), *a.args, value)\n return Literal(atom, formula.op == \"=\")\n return formula\n if isinstance(formula, And):\n return And(tuple(rewrite_condition(p) for p in formula.parts))\n if isinstance(formula, Or):\n return Or(tuple(rewrite_condition(p) for p in formula.parts))\n if isinstance(formula, Forall):\n return Forall(formula.params, rewrite_condition(formula.body))\n if isinstance(formula, Exists):\n return Exists(formula.params, rewrite_condition(formula.body))\n return formula\n\n def rewrite_effect(effect):\n if isinstance(effect, ConjunctiveEffect):\n return ConjunctiveEffect([rewrite_effect(p) for p in effect.parts])\n if isinstance(effect, ForallEffect):\n return ForallEffect(effect.params, rewrite_effect(effect.body))\n if isinstance(effect, WhenEffect):\n return WhenEffect(\n rewrite_condition(effect.condition), rewrite_effect(effect.body)\n )\n if isinstance(effect, NumericEffect) and effect.target.name in fluents:\n if effect.op != \"assign\":\n raise UnsupportedFeatureError(\n f\"'{effect.op}' is arithmetic, but '{effect.target.name}' \"\n \"returns an object; only 'assign' is meaningful\"\n )\n fluent = fluents[effect.target.name]\n name = predicate_name(fluent.name)\n value = effect.value\n new = value.name if isinstance(value, FluentRef) else str(value)\n # Clear whatever the function used to return, then set the new\n # value: that is what keeps it a function rather than a relation.\n clear = ForallEffect(\n [(\"?__old\", fluent.result_type)],\n WhenEffect(\n _holds(name, *effect.target.args, \"?__old\"),\n DelEffect(_fact(name, *effect.target.args, \"?__old\")),\n ),\n )\n return ConjunctiveEffect(\n [clear, AddEffect(_fact(name, *effect.target.args, new))]\n )\n return effect\n\n for index, action in enumerate(domain.actions):\n domain.actions[index] = replace(\n action,\n precondition=rewrite_condition(action.precondition),\n effect=rewrite_effect(action.effect),\n )\n for index, rule in enumerate(domain.derived):\n domain.derived[index] = replace(rule, body=rewrite_condition(rule.body))\n\n problem.goal = rewrite_condition(problem.goal)\n for index, preference in enumerate(problem.preferences):\n problem.preferences[index] = Preference(\n preference.name, rewrite_condition(preference.body)\n )\n\n # `(= (location p1) depot)` in :init becomes a plain fact.\n for fluent_ref, value in problem.init_objects.items():\n if fluent_ref.name not in fluents:\n raise PDDLError(\n f\"'{fluent_ref.name}' is assigned an object in :init but is not \"\n \"declared as an object fluent in :functions\"\n )\n problem.init.append(\n _fact(predicate_name(fluent_ref.name), *fluent_ref.args, value)\n )\n problem.init_objects = {}\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# timed initial literals\n# --------------------------------------------------------------------------\ndef compile_timed_initials(domain: Domain, problem: Problem):\n \"\"\"Give the model a clock, and make each timed literal fire off it.\n\n Elapsed time becomes the numeric fluent ``(__time)``, advanced by each\n durative action's duration. Every timed literal gets a zero-cost\n ``__fire-til-k`` action guarded by ``(>= (__time) t)``, and a\n ``__wait-til-k`` action that lets the planner advance the clock to ``t``\n when it wants the literal to happen.\n\n The literal must not be *skipped*: every domain action therefore carries\n ``(or (< (__time) t) (__til-k))`` for each timed literal, so once the clock\n passes ``t`` nothing else may happen until the literal has fired.\n\n Literals must also fire **in time order**, which is a separate constraint:\n ``(at 0 (open))`` and ``(at 3 (not (open)))`` describe a shop that opens then\n shuts, but firing them the other way round would leave it open forever. Each\n firing therefore requires every earlier literal to have fired already.\n\n Because actions do not overlap, a literal scheduled strictly inside an\n action's duration fires immediately after it rather than during it.\n \"\"\"\n if not problem.timed_initials:\n return domain, problem\n\n problem.init_numeric = dict(problem.init_numeric)\n problem.init_numeric.setdefault(CLOCK, 0.0)\n\n # The clock only moves if something moves it.\n for index, action in enumerate(domain.actions):\n if action.duration is None or not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action,\n effect=_also(\n action.effect, NumericEffect(\"increase\", CLOCK, action.duration)\n ),\n )\n\n guards = []\n earlier: list = []\n for k, timed in enumerate(sorted(problem.timed_initials, key=lambda t: t.time)):\n marker = f\"{PREFIX}til-{k}\"\n _declare(domain, marker)\n due = Comparison(\">=\", CLOCK, Number(timed.time))\n not_due = Comparison(\"<\", CLOCK, Number(timed.time))\n # Everything scheduled before this must already have happened.\n in_order = _conjoin(*[_holds(name) for name in earlier])\n\n body = (\n AddEffect(timed.literal.atom)\n if timed.literal.positive\n else DelEffect(timed.literal.atom)\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}fire-til-{k}\",\n [],\n _conjoin(due, _absent(marker), in_order),\n ConjunctiveEffect([body, AddEffect(_fact(marker)), _free()]),\n )\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}wait-til-{k}\",\n [],\n # Waiting past an event that has not happened yet would skip it.\n _conjoin(not_due, in_order),\n ConjunctiveEffect(\n [NumericEffect(\"assign\", CLOCK, Number(timed.time)), _free()]\n ),\n )\n )\n guards.append(Or((not_due, _holds(marker))))\n earlier.append(marker)\n\n # A due literal blocks everything else until it has fired.\n for guard in guards:\n _add_precondition(domain, guard)\n problem.timed_initials = []\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# trajectory constraints\n# --------------------------------------------------------------------------\ndef compile_constraints(domain: Domain, problem: Problem):\n \"\"\"Compile ``(:constraints ...)`` into preconditions, monitors and goals.\n\n * ``always phi`` \u2014 conjoined onto every action's precondition and onto the\n goal. Every state on a plan's trajectory is either the initial state, a\n state an action is taken from, or the final state, so those three checks\n cover all of them.\n * ``at-end phi`` \u2014 conjoined onto the goal.\n * ``sometime phi`` \u2014 a zero-cost ``__observe`` action, applicable exactly\n when ``phi`` holds, sets a monitor fact the goal then requires.\n * ``sometime-before phi psi`` \u2014 the same monitor for ``psi``, plus\n ``always (phi implies monitor)``.\n * ``sometime-after phi psi`` \u2014 a *forced* monitor: every action records an\n outstanding obligation when ``phi`` holds without ``psi``, and discharges\n it when ``psi`` holds. The goal requires nothing outstanding.\n * ``at-most-once phi`` \u2014 forced monitors for \"phi has held\" and \"phi has\n since stopped\", plus ``always not (phi and stopped)``.\n\n Forced monitors ride on conditional effects, which the planner cannot\n decline; optional ones ride on actions, which it applies when convenient.\n The difference matters: a constraint the planner could satisfy by *not\n looking* would not be a constraint.\n \"\"\"\n constraints = list(domain.constraints) + list(problem.constraints)\n if not constraints:\n return domain, problem\n\n for constraint in constraints:\n if isinstance(constraint, Preference):\n raise UnsupportedFeatureError(\n f\"the soft constraint '{constraint.name}' is not supported: \"\n \"preferences are supported over goals, not over trajectory \"\n \"constraints\"\n )\n\n goal_parts = [problem.goal]\n invariants = []\n\n for index, constraint in enumerate(constraints):\n kind = constraint.kind\n if kind == \"always\":\n invariants.append(constraint.args[0])\n elif kind == \"at-end\":\n goal_parts.append(constraint.args[0])\n elif kind == \"sometime\":\n marker = _observation_monitor(domain, index, constraint.args[0])\n goal_parts.append(_holds(marker))\n elif kind == \"sometime-before\":\n trigger, earlier = constraint.args\n marker = _observation_monitor(domain, index, earlier)\n # \"phi implies the monitor\" as an invariant: by the time phi holds,\n # psi must already have been observed.\n invariants.append(Or((_negate(trigger), _holds(marker))))\n elif kind == \"sometime-after\":\n trigger, follower = constraint.args\n marker = f\"{PREFIX}pending-{index}\"\n _declare(domain, marker)\n _add_conditional_effect(\n domain,\n _conjoin(trigger, _negate(follower)),\n AddEffect(_fact(marker)),\n )\n _add_conditional_effect(domain, follower, DelEffect(_fact(marker)))\n goal_parts.append(_absent(marker))\n # ...and the final state must not leave a fresh obligation either.\n goal_parts.append(Or((_negate(trigger), follower)))\n elif kind == \"at-most-once\":\n phi = constraint.args[0]\n seen = f\"{PREFIX}amo-seen-{index}\"\n closed = f\"{PREFIX}amo-closed-{index}\"\n _declare(domain, seen)\n _declare(domain, closed)\n _add_conditional_effect(domain, phi, AddEffect(_fact(seen)))\n _add_conditional_effect(\n domain,\n _conjoin(_negate(phi), _holds(seen)),\n AddEffect(_fact(closed)),\n )\n # Holding again after an interval has closed is the second interval.\n invariants.append(Or((_negate(phi), _absent(closed))))\n else: # pragma: no cover - the parser rejects anything else\n raise UnsupportedFeatureError(f\"unsupported constraint '{kind}'\")\n\n for invariant in invariants:\n # Every action, not just the domain's own. The coverage argument above\n # holds only if each state on the trajectory is either taken from by an\n # action that checks the invariant or is the final state \u2014 and the\n # actions timed initial literals compile to *change facts*. Exempting\n # them let a plan step through a state the invariant forbade: a literal\n # that clears `(safe)` at t=3 and one that restores it at t=4 fire\n # back-to-back with nothing checking the state in between.\n _add_precondition(domain, invariant, only_domain_actions=False)\n goal_parts.append(invariant)\n\n problem.goal = _conjoin(*goal_parts)\n domain.constraints = []\n problem.constraints = []\n return domain, problem\n\n\ndef _observation_monitor(domain: Domain, index: int, formula) -> str:\n \"\"\"A fact the planner can set, for free, whenever ``formula`` holds.\"\"\"\n marker = f\"{PREFIX}seen-{index}\"\n _declare(domain, marker)\n domain.actions.append(\n Action(\n f\"{PREFIX}observe-{index}\",\n [],\n _conjoin(formula, _absent(marker)),\n ConjunctiveEffect([AddEffect(_fact(marker)), _free()]),\n )\n )\n return marker\n\n\n# --------------------------------------------------------------------------\n# preferences\n# --------------------------------------------------------------------------\ndef compile_preferences(domain: Domain, problem: Problem):\n \"\"\"Turn each soft goal into a priced choice: satisfy it, or pay for it.\n\n A closing phase makes this sound. ``__close`` ends the plan \u2014 every domain\n action requires that it has *not* happened \u2014 after which each preference is\n resolved by one of two zero-parameter actions: a free one that requires the\n preference to hold, or one costing the metric's ``(is-violated p)`` weight\n that does not. Cost-optimal search then picks whichever is cheaper, which is\n exactly what minimising the metric means.\n\n Freezing the state first is the point: without it the planner could satisfy\n a preference halfway through and then break it, and still be paid for it.\n \"\"\"\n if not problem.preferences:\n return domain, problem\n\n closed = f\"{PREFIX}closed\"\n _declare(domain, closed)\n _add_precondition(domain, _absent(closed))\n\n domain.actions.append(\n Action(\n f\"{PREFIX}close\",\n [],\n _absent(closed),\n ConjunctiveEffect([AddEffect(_fact(closed)), _free()]),\n )\n )\n\n goal_parts = [problem.goal, _holds(closed)]\n for index, preference in enumerate(problem.preferences):\n done = f\"{PREFIX}pref-{index}\"\n _declare(domain, done)\n weight = float(problem.violation_weights.get(preference.name, 1.0))\n if weight < 0:\n raise PDDLError(\n f\"preference '{preference.name}' has a negative violation \"\n \"weight, which would reward breaking it\"\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}satisfy-{preference.name}\",\n [],\n _conjoin(_holds(closed), _absent(done), preference.body),\n ConjunctiveEffect([AddEffect(_fact(done)), _free()]),\n )\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}violate-{preference.name}\",\n [],\n _conjoin(_holds(closed), _absent(done)),\n ConjunctiveEffect([AddEffect(_fact(done))]), # priced below\n )\n )\n # The penalty rides on the action cost, so plain cost-optimal search\n # optimises the metric without knowing what a preference is.\n domain.actions[-1] = replace(\n domain.actions[-1],\n effect=_also(domain.actions[-1].effect, _increase_cost(weight)),\n )\n goal_parts.append(_holds(done))\n\n problem.goal = _conjoin(*goal_parts)\n problem.preferences = []\n return domain, problem\n\n\ndef _increase_cost(amount: float):\n return IncreaseCostEffect(amount)\n\n\n# --------------------------------------------------------------------------\n# entry point\n# --------------------------------------------------------------------------\ndef compile_problem(domain: Domain, problem: Problem):\n \"\"\"Apply every PDDL 3 compilation, in the order they depend on each other.\n\n Object fluents go first because they rewrite terms everywhere else reads;\n preferences go last because their closing phase must sit outside the\n machinery the other compilations add.\n \"\"\"\n domain, problem = compile_object_fluents(domain, problem)\n domain, problem = compile_timed_initials(domain, problem)\n domain, problem = compile_constraints(domain, problem)\n domain, problem = compile_preferences(domain, problem)\n return domain, problem\n", "jupyddl/generator.py": "\"\"\"Generate PDDL domains and problems, reproducibly.\n\nBenchmarks live or die on their instance set, and hand-writing a scaling ladder\nis tedious. Each generator here takes a size and a ``seed`` and emits PDDL text,\nso a whole difficulty curve is one comprehension \u2014 and re-running the same seed\ngives byte-identical files, which is what makes a published experiment\nreproducible.\n\n::\n\n from jupyddl.generator import generate, GENERATORS\n\n domain, problem = generate(\"blocksworld\", size=10, seed=7)\n ladder = [generate(\"gripper\", size=n, seed=1) for n in range(2, 12)]\n\nEvery generator is exercised by the test suite, which grounds and solves what it\nproduces \u2014 a generator that emits unsolvable or unparseable PDDL is a bug.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport random\nfrom typing import Optional\n\n__all__ = [\n \"GENERATORS\",\n \"generate\",\n \"describe_generators\",\n \"generate_blocksworld\",\n \"generate_gripper\",\n \"generate_logistics\",\n \"generate_rovers\",\n \"generate_random_strips\",\n \"generate_numeric_transport\",\n \"generate_temporal_workshop\",\n]\n\n\ndef _objects(prefix: str, count: int) -> list:\n return [f\"{prefix}{i + 1}\" for i in range(count)]\n\n\n# --------------------------------------------------------------------------\n# blocksworld\n# --------------------------------------------------------------------------\nBLOCKSWORLD_DOMAIN = \"\"\"\\\n;; Blocksworld -- generated by jupyddl.generator\n(define (domain blocksworld)\n (:requirements :strips :typing)\n (:types block - object)\n (:predicates\n (on ?x - block ?y - block)\n (ontable ?x - block)\n (clear ?x - block)\n (handempty)\n (holding ?x - block))\n\n (:action pick-up\n :parameters (?x - block)\n :precondition (and (clear ?x) (ontable ?x) (handempty))\n :effect (and (not (ontable ?x)) (not (clear ?x))\n (not (handempty)) (holding ?x)))\n\n (:action put-down\n :parameters (?x - block)\n :precondition (holding ?x)\n :effect (and (not (holding ?x)) (clear ?x) (handempty) (ontable ?x)))\n\n (:action stack\n :parameters (?x - block ?y - block)\n :precondition (and (holding ?x) (clear ?y))\n :effect (and (not (holding ?x)) (not (clear ?y))\n (clear ?x) (handempty) (on ?x ?y)))\n\n (:action unstack\n :parameters (?x - block ?y - block)\n :precondition (and (on ?x ?y) (clear ?x) (handempty))\n :effect (and (holding ?x) (clear ?y) (not (clear ?x))\n (not (handempty)) (not (on ?x ?y)))))\n\"\"\"\n\n\ndef _random_towers(blocks, rng) -> list:\n \"\"\"Partition ``blocks`` into a random set of towers (bottom-first).\"\"\"\n shuffled = list(blocks)\n rng.shuffle(shuffled)\n towers: list = []\n index = 0\n while index < len(shuffled):\n height = rng.randint(1, max(1, len(shuffled) - index))\n towers.append(shuffled[index : index + height])\n index += height\n return towers\n\n\ndef _tower_facts(towers) -> list:\n facts = []\n for tower in towers:\n facts.append(f\"(ontable {tower[0]})\")\n for lower, upper in zip(tower, tower[1:]):\n facts.append(f\"(on {upper} {lower})\")\n facts.append(f\"(clear {tower[-1]})\")\n return facts\n\n\ndef generate_blocksworld(size: int = 6, seed: int = 0):\n \"\"\"``size`` blocks in random towers, to be rearranged into other towers.\"\"\"\n rng = random.Random(seed)\n blocks = _objects(\"b\", max(2, size))\n start = _random_towers(blocks, rng)\n goal_towers = _random_towers(blocks, rng)\n while goal_towers == start and len(blocks) > 2:\n goal_towers = _random_towers(blocks, rng)\n\n goal_facts = []\n for tower in goal_towers:\n goal_facts.append(f\"(ontable {tower[0]})\")\n for lower, upper in zip(tower, tower[1:]):\n goal_facts.append(f\"(on {upper} {lower})\")\n\n problem = f\"\"\"\\\n;; {len(blocks)} blocks, seed {seed} -- generated by jupyddl.generator\n(define (problem blocksworld-{len(blocks)}-{seed})\n (:domain blocksworld)\n (:objects {' '.join(blocks)} - block)\n (:init\n (handempty)\n {chr(10).join(' ' + f for f in _tower_facts(start)).strip()})\n (:goal (and\n {chr(10).join(' ' + f for f in goal_facts).strip()})))\n\"\"\"\n return BLOCKSWORLD_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# gripper\n# --------------------------------------------------------------------------\nGRIPPER_DOMAIN = \"\"\"\\\n;; Gripper -- generated by jupyddl.generator\n(define (domain gripper)\n (:requirements :strips :typing)\n (:types room ball gripper - object)\n (:predicates\n (at-robby ?r - room)\n (at ?b - ball ?r - room)\n (free ?g - gripper)\n (carry ?b - ball ?g - gripper))\n\n (:action move\n :parameters (?from - room ?to - room)\n :precondition (at-robby ?from)\n :effect (and (at-robby ?to) (not (at-robby ?from))))\n\n (:action pick\n :parameters (?b - ball ?r - room ?g - gripper)\n :precondition (and (at ?b ?r) (at-robby ?r) (free ?g))\n :effect (and (carry ?b ?g) (not (at ?b ?r)) (not (free ?g))))\n\n (:action drop\n :parameters (?b - ball ?r - room ?g - gripper)\n :precondition (and (carry ?b ?g) (at-robby ?r))\n :effect (and (at ?b ?r) (free ?g) (not (carry ?b ?g)))))\n\"\"\"\n\n\ndef generate_gripper(size: int = 4, seed: int = 0, grippers: int = 2):\n \"\"\"``size`` balls to move from room A to room B. Plan length grows linearly.\n\n Unlike every other generator here, ``seed`` does not vary the instance:\n in gripper every ball starts in room A and must end in room B, so\n ``size`` and ``grippers`` determine the problem completely. The seed\n reaches only the problem name, which keeps generated files distinct.\n\n The consequence is worth stating because it is easy to miss: a\n train/test split that varies the seed and holds the size fixed produces\n the *same instance* twice for this domain. Vary ``size`` instead.\n \"\"\"\n balls = _objects(\"ball\", max(1, size))\n hands = _objects(\"gripper\", max(1, grippers))\n init = [\"(at-robby rooma)\"]\n init += [f\"(free {g})\" for g in hands]\n init += [f\"(at {b} rooma)\" for b in balls]\n\n problem = f\"\"\"\\\n;; {len(balls)} balls, {len(hands)} grippers, seed {seed}\n(define (problem gripper-{len(balls)}-{seed})\n (:domain gripper)\n (:objects\n rooma roomb - room\n {' '.join(balls)} - ball\n {' '.join(hands)} - gripper)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(f' (at {b} roomb)' for b in balls).strip()})))\n\"\"\"\n return GRIPPER_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# logistics\n# --------------------------------------------------------------------------\nLOGISTICS_DOMAIN = \"\"\"\\\n;; Logistics with action costs -- generated by jupyddl.generator\n(define (domain logistics)\n (:requirements :strips :typing :action-costs)\n (:types\n truck airplane - vehicle\n package vehicle - thing\n airport location - place\n city - object)\n (:predicates\n (at ?t - thing ?p - place)\n (in ?p - package ?v - vehicle)\n (in-city ?p - place ?c - city))\n (:functions (total-cost))\n\n (:action load\n :parameters (?p - package ?v - vehicle ?l - place)\n :precondition (and (at ?p ?l) (at ?v ?l))\n :effect (and (not (at ?p ?l)) (in ?p ?v) (increase (total-cost) 1)))\n\n (:action unload\n :parameters (?p - package ?v - vehicle ?l - place)\n :precondition (and (in ?p ?v) (at ?v ?l))\n :effect (and (not (in ?p ?v)) (at ?p ?l) (increase (total-cost) 1)))\n\n (:action drive\n :parameters (?t - truck ?from - place ?to - place ?c - city)\n :precondition (and (at ?t ?from) (in-city ?from ?c) (in-city ?to ?c))\n :effect (and (not (at ?t ?from)) (at ?t ?to) (increase (total-cost) 2)))\n\n (:action fly\n :parameters (?a - airplane ?from - airport ?to - airport)\n :precondition (at ?a ?from)\n :effect (and (not (at ?a ?from)) (at ?a ?to) (increase (total-cost) 6))))\n\"\"\"\n\n\ndef generate_logistics(size: int = 3, seed: int = 0, cities: int = 2):\n \"\"\"``size`` packages across ``cities`` cities, each with an airport and depot.\"\"\"\n rng = random.Random(seed)\n cities = max(2, cities)\n packages = _objects(\"pkg\", max(1, size))\n city_names = _objects(\"city\", cities)\n airports = [f\"apt{i + 1}\" for i in range(cities)]\n depots = [f\"depot{i + 1}\" for i in range(cities)]\n trucks = [f\"truck{i + 1}\" for i in range(cities)]\n\n init = [\"(= (total-cost) 0)\"]\n for i in range(cities):\n init.append(f\"(in-city {airports[i]} {city_names[i]})\")\n init.append(f\"(in-city {depots[i]} {city_names[i]})\")\n init.append(f\"(at {trucks[i]} {depots[i]})\")\n init.append(f\"(at plane1 {airports[0]})\")\n\n goals = []\n for package in packages:\n source = rng.randrange(cities)\n target = rng.randrange(cities)\n while target == source and cities > 1:\n target = rng.randrange(cities)\n init.append(f\"(at {package} {rng.choice([airports[source], depots[source]])})\")\n goals.append(f\"(at {package} {rng.choice([airports[target], depots[target]])})\")\n\n problem = f\"\"\"\\\n;; {len(packages)} packages, {cities} cities, seed {seed}\n(define (problem logistics-{len(packages)}-{seed})\n (:domain logistics)\n (:objects\n {' '.join(city_names)} - city\n {' '.join(trucks)} - truck\n plane1 - airplane\n {' '.join(packages)} - package\n {' '.join(airports)} - airport\n {' '.join(depots)} - location)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()}))\n (:metric minimize (total-cost)))\n\"\"\"\n return LOGISTICS_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# rovers (ADL: disjunction + quantification)\n# --------------------------------------------------------------------------\nROVERS_DOMAIN = \"\"\"\\\n;; Rovers -- exercises disjunctive and quantified preconditions.\n(define (domain rovers)\n (:requirements :strips :typing :adl)\n (:types rover waypoint objective - object)\n (:predicates\n (at ?r - rover ?w - waypoint)\n (can-traverse ?a ?b - waypoint)\n (visible ?o - objective ?w - waypoint)\n (imaged ?o - objective)\n (sampled ?w - waypoint)\n (analysed ?w - waypoint)\n (reported ?o - objective))\n\n (:action navigate\n :parameters (?r - rover ?from ?to - waypoint)\n :precondition (and (at ?r ?from) (can-traverse ?from ?to))\n :effect (and (not (at ?r ?from)) (at ?r ?to)))\n\n (:action sample\n :parameters (?r - rover ?w - waypoint)\n :precondition (and (at ?r ?w) (not (sampled ?w)))\n :effect (sampled ?w))\n\n (:action analyse\n :parameters (?w - waypoint)\n :precondition (sampled ?w)\n :effect (analysed ?w))\n\n ;; An objective can be imaged from any waypoint that sees it.\n (:action image\n :parameters (?r - rover ?o - objective)\n :precondition (exists (?w - waypoint) (and (at ?r ?w) (visible ?o ?w)))\n :effect (imaged ?o))\n\n ;; Reporting accepts either a picture or a full sample analysis.\n (:action report\n :parameters (?o - objective)\n :precondition (or (imaged ?o)\n (exists (?w - waypoint)\n (and (visible ?o ?w) (analysed ?w))))\n :effect (reported ?o)))\n\"\"\"\n\n\ndef generate_rovers(size: int = 3, seed: int = 0, waypoints: int = 5):\n \"\"\"``size`` objectives over a connected waypoint graph. Uses `or` and `exists`.\"\"\"\n rng = random.Random(seed)\n waypoints = max(2, waypoints)\n points = _objects(\"wp\", waypoints)\n objectives = _objects(\"obj\", max(1, size))\n\n init = [\"(at rover1 wp1)\"]\n # A spanning path guarantees the graph is connected, so every instance is\n # solvable; the extra edges just give the planner choices.\n for a, b in zip(points, points[1:]):\n init.append(f\"(can-traverse {a} {b})\")\n init.append(f\"(can-traverse {b} {a})\")\n for _ in range(waypoints):\n a, b = rng.sample(points, 2)\n init.append(f\"(can-traverse {a} {b})\")\n\n for objective in objectives:\n for point in rng.sample(points, rng.randint(1, min(2, len(points)))):\n init.append(f\"(visible {objective} {point})\")\n\n problem = f\"\"\"\\\n;; {len(objectives)} objectives, {waypoints} waypoints, seed {seed}\n(define (problem rovers-{len(objectives)}-{seed})\n (:domain rovers)\n (:objects\n rover1 - rover\n {' '.join(points)} - waypoint\n {' '.join(objectives)} - objective)\n (:init\n {chr(10).join(' ' + f for f in sorted(set(init))).strip()})\n (:goal (and\n {chr(10).join(f' (reported {o})' for o in objectives).strip()})))\n\"\"\"\n return ROVERS_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# numeric transport\n# --------------------------------------------------------------------------\nNUMERIC_DOMAIN = \"\"\"\\\n;; Transport with fuel -- exercises numeric fluents.\n(define (domain numeric-transport)\n (:requirements :strips :typing :numeric-fluents)\n (:types truck location package - object)\n (:predicates\n (at ?t - truck ?l - location)\n (road ?a ?b - location)\n (carrying ?p - package ?t - truck)\n (package-at ?p - package ?l - location))\n (:functions\n (fuel ?t - truck)\n (distance ?a ?b - location))\n\n (:action drive\n :parameters (?t - truck ?from ?to - location)\n :precondition (and (at ?t ?from) (road ?from ?to)\n (>= (fuel ?t) (distance ?from ?to)))\n :effect (and (not (at ?t ?from)) (at ?t ?to)\n (decrease (fuel ?t) (distance ?from ?to))))\n\n (:action refuel\n :parameters (?t - truck)\n :precondition (< (fuel ?t) 40)\n :effect (assign (fuel ?t) 60))\n\n (:action load\n :parameters (?p - package ?t - truck ?l - location)\n :precondition (and (at ?t ?l) (package-at ?p ?l))\n :effect (and (not (package-at ?p ?l)) (carrying ?p ?t)))\n\n (:action unload\n :parameters (?p - package ?t - truck ?l - location)\n :precondition (and (at ?t ?l) (carrying ?p ?t))\n :effect (and (not (carrying ?p ?t)) (package-at ?p ?l))))\n\"\"\"\n\n\ndef generate_numeric_transport(size: int = 2, seed: int = 0, locations: int = 4):\n \"\"\"``size`` packages on a fuel-limited road network.\"\"\"\n rng = random.Random(seed)\n locations = max(2, locations)\n places = _objects(\"loc\", locations)\n packages = _objects(\"pkg\", max(1, size))\n\n init = [\"(at truck1 loc1)\", \"(= (fuel truck1) 50)\"]\n for a, b in zip(places, places[1:]):\n distance = rng.choice([10, 15, 20])\n init.append(f\"(road {a} {b})\")\n init.append(f\"(road {b} {a})\")\n init.append(f\"(= (distance {a} {b}) {distance})\")\n init.append(f\"(= (distance {b} {a}) {distance})\")\n\n goals = []\n for package in packages:\n init.append(f\"(package-at {package} {places[0]})\")\n goals.append(f\"(package-at {package} {places[-1]})\")\n\n problem = f\"\"\"\\\n;; {len(packages)} packages, {locations} locations, seed {seed}\n(define (problem numeric-transport-{len(packages)}-{seed})\n (:domain numeric-transport)\n (:objects\n truck1 - truck\n {' '.join(places)} - location\n {' '.join(packages)} - package)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()})))\n\"\"\"\n return NUMERIC_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# temporal workshop\n# --------------------------------------------------------------------------\nTEMPORAL_DOMAIN = \"\"\"\\\n;; Workshop -- exercises durative actions (sequential compilation).\n(define (domain workshop)\n (:requirements :strips :typing :durative-actions)\n (:types part - object)\n (:predicates\n (cut ?p - part)\n (drilled ?p - part)\n (painted ?p - part)\n (finished ?p - part))\n\n (:durative-action cut\n :parameters (?p - part)\n :duration (= ?duration 2)\n :condition (and (at start (not (cut ?p))))\n :effect (and (at end (cut ?p))))\n\n (:durative-action drill\n :parameters (?p - part)\n :duration (= ?duration 3)\n :condition (and (over all (cut ?p)))\n :effect (and (at end (drilled ?p))))\n\n (:durative-action paint\n :parameters (?p - part)\n :duration (= ?duration 5)\n :condition (and (over all (drilled ?p)))\n :effect (and (at end (painted ?p))))\n\n (:durative-action inspect\n :parameters (?p - part)\n :duration (= ?duration 1)\n :condition (and (over all (painted ?p)))\n :effect (and (at end (finished ?p)))))\n\"\"\"\n\n\ndef generate_temporal_workshop(size: int = 2, seed: int = 0):\n \"\"\"``size`` parts through a cut/drill/paint/inspect pipeline, with durations.\"\"\"\n parts = _objects(\"part\", max(1, size))\n problem = f\"\"\"\\\n;; {len(parts)} parts, seed {seed}\n(define (problem workshop-{len(parts)}-{seed})\n (:domain workshop)\n (:objects {' '.join(parts)} - part)\n (:init )\n (:goal (and\n {chr(10).join(f' (finished {p})' for p in parts).strip()})))\n\"\"\"\n return TEMPORAL_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# random STRIPS\n# --------------------------------------------------------------------------\ndef generate_random_strips(\n size: int = 8,\n seed: int = 0,\n actions: int = 10,\n goal_size: int = 3,\n):\n \"\"\"A random STRIPS instance built backwards from a guaranteed solution.\n\n Purely random operators almost always give an unsolvable problem, which\n makes for a useless benchmark. Instead this plants a random *chain*: each\n step in the chain has an action that turns the previous state into the next,\n so a plan of known length exists. The remaining actions are noise the\n planner has to search past.\n \"\"\"\n rng = random.Random(seed)\n size = max(4, size)\n facts = [f\"p{i + 1}\" for i in range(size)]\n chain_length = max(2, min(actions // 2, size))\n\n init_facts = set(rng.sample(facts, max(1, size // 3)))\n state = set(init_facts)\n operators = []\n\n # --- the planted solution ---------------------------------------------\n for step in range(chain_length):\n candidates = [f for f in facts if f not in state]\n if not candidates:\n break\n added = rng.choice(candidates)\n precondition = sorted(rng.sample(sorted(state), min(2, len(state))))\n deleted = []\n if len(state) > 1 and rng.random() < 0.4:\n deleted = [\n (\n rng.choice([f for f in sorted(state) if f not in precondition])\n if len(state) > len(precondition)\n else None\n )\n ]\n deleted = [d for d in deleted if d]\n operators.append((f\"solve{step + 1}\", precondition, [added], deleted))\n state.add(added)\n state.difference_update(deleted)\n\n goal_facts = sorted(rng.sample(sorted(state), min(goal_size, len(state))))\n\n # --- distractors -------------------------------------------------------\n for index in range(max(0, actions - len(operators))):\n precondition = sorted(rng.sample(facts, rng.randint(1, 2)))\n added = sorted(rng.sample(facts, rng.randint(1, 2)))\n deleted = [f for f in rng.sample(facts, 1) if f not in added]\n operators.append((f\"noise{index + 1}\", precondition, added, deleted))\n\n def action_text(name, pre, add, delete):\n conditions = \" \".join(f\"({p})\" for p in pre) or \"\"\n effects = \" \".join(f\"({a})\" for a in add)\n effects += \" \" + \" \".join(f\"(not ({d}))\" for d in delete)\n return (\n f\" (:action {name}\\n\"\n f\" :precondition (and {conditions})\\n\"\n f\" :effect (and {effects.strip()}))\"\n )\n\n domain = \"\\n\".join(\n [\n \";; Random STRIPS -- generated by jupyddl.generator\",\n f\"(define (domain random-strips-{size}-{seed})\",\n \" (:requirements :strips)\",\n \" (:predicates \" + \" \".join(f\"({f})\" for f in facts) + \")\",\n \"\",\n \"\\n\\n\".join(action_text(*op) for op in operators),\n \")\",\n ]\n )\n problem = f\"\"\"\\\n;; {size} facts, {len(operators)} actions, seed {seed}\n(define (problem random-strips-{size}-{seed})\n (:domain random-strips-{size}-{seed})\n (:init {' '.join(f'({f})' for f in sorted(init_facts))})\n (:goal (and {' '.join(f'({f})' for f in goal_facts)})))\n\"\"\"\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# registry\n# --------------------------------------------------------------------------\nGENERATORS = {\n \"blocksworld\": generate_blocksworld,\n \"gripper\": generate_gripper,\n \"logistics\": generate_logistics,\n \"rovers\": generate_rovers,\n \"numeric-transport\": generate_numeric_transport,\n \"workshop\": generate_temporal_workshop,\n \"random-strips\": generate_random_strips,\n}\n\n# What each generator is meant to stress, for the CLI and the playground.\nGENERATOR_NOTES = {\n \"blocksworld\": \"Classic block stacking. Deep plans, heavy plateaus.\",\n \"gripper\": \"Balls between two rooms. High branching factor.\",\n \"logistics\": \"Trucks and a plane with action costs.\",\n \"rovers\": \"ADL: disjunctive and existential preconditions.\",\n \"numeric-transport\": \"Numeric fluents: fuel consumption and refuelling.\",\n \"workshop\": \"Durative actions; plans report a makespan.\",\n \"random-strips\": \"Random operators around a planted solution chain.\",\n}\n\n\ndef generate(kind: str, size: int = 4, seed: int = 0, **kwargs):\n \"\"\"Generate ``(domain_text, problem_text)`` for a named generator.\"\"\"\n try:\n factory = GENERATORS[kind]\n except KeyError:\n raise ValueError(\n f\"Unknown generator '{kind}'. Available: {sorted(GENERATORS)}\"\n ) from None\n return factory(size=size, seed=seed, **kwargs)\n\n\ndef describe_generators() -> list:\n \"\"\"Serialisable metadata for the CLI and the web workbench.\"\"\"\n return [\n {\"name\": name, \"summary\": GENERATOR_NOTES.get(name, \"\")}\n for name in sorted(GENERATORS)\n ]\n\n\ndef write_instance(\n kind: str,\n folder: str,\n size: int = 4,\n seed: int = 0,\n name: Optional[str] = None,\n **kwargs,\n) -> str:\n \"\"\"Generate an instance and write ``//{domain,problem}.pddl``.\"\"\"\n import os\n\n domain, problem = generate(kind, size=size, seed=seed, **kwargs)\n target = os.path.join(folder, name or f\"{kind}-{size:02d}-{seed}\")\n os.makedirs(target, exist_ok=True)\n with open(os.path.join(target, \"domain.pddl\"), \"w\", encoding=\"utf-8\") as handle:\n handle.write(domain)\n with open(os.path.join(target, \"problem.pddl\"), \"w\", encoding=\"utf-8\") as handle:\n handle.write(problem)\n return target\n", "jupyddl/grounding.py": "\"\"\"Grounding: turn a parsed :class:`Domain` + :class:`Problem` into a\ngrounded :class:`~jupyddl.task.Task`.\n\nPipeline:\n\n1. Build the ``type -> objects`` table (with type-hierarchy closure).\n2. Instantiate every action over all type-consistent parameter tuples. The\n precondition formula is expanded (quantifiers over the object pool) and\n distributed into DNF; **each disjunct becomes its own grounded operator**, so\n the search only ever sees conjunctive preconditions.\n3. Detect static predicates (never added, deleted, or derived) and use the\n initial state to prune infeasible action instances and simplify conditions.\n4. Compile negative preconditions/goals into *positive normal form* by\n introducing complement facts ``(not ...)`` and maintaining them on every\n operator that touches the underlying atom.\n5. Ground derived-predicate rules into :class:`~jupyddl.task.Axiom` objects.\n6. Collect numeric fluents, compile their expressions into closures over the\n state's value vector, and encode everything as integer ids.\n\nA disjunctive goal is compiled to a single artificial goal fact achieved by one\nzero-cost operator per disjunct; those operators are recorded in\n``Task.synthetic`` so they can be hidden when a plan is printed.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import defaultdict\nfrom dataclasses import dataclass, field\nfrom itertools import product\n\nfrom .parser.ast import (\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n ConjunctiveEffect,\n DelEffect,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Problem,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\nfrom .compile import CLOCK as CLOCK_FLUENT\nfrom .compile import SYNTHETIC_PREFIX, compile_problem\nfrom .parser.parser import parse_domain_file, parse_problem_file\nfrom .task import Axiom, CondEffect, Operator, Task\n\n# Distributing a deeply disjunctive precondition into DNF can blow up\n# combinatorially. Stop with a clear message rather than exhausting memory.\nMAX_DISJUNCTS = 20000\n\nGOAL_FACT = Atom(\"__goal-reached__\", ())\n\n\ndef _ground_atom(atom: Atom, subst: dict) -> Atom:\n return Atom(atom.predicate, tuple(subst.get(a, a) for a in atom.args))\n\n\ndef _ground_fluent(ref: FluentRef, subst: dict) -> FluentRef:\n return FluentRef(ref.name, tuple(subst.get(a, a) for a in ref.args))\n\n\ndef _ground_expression(expr, subst: dict):\n if isinstance(expr, Number):\n return expr\n if isinstance(expr, FluentRef):\n return _ground_fluent(expr, subst)\n if isinstance(expr, Arithmetic):\n return Arithmetic(\n expr.op,\n _ground_expression(expr.left, subst),\n _ground_expression(expr.right, subst),\n )\n raise PDDLError(f\"unexpected numeric expression node: {expr!r}\")\n\n\ndef _build_type_objects(domain: Domain, problem: Problem):\n \"\"\"Map every type to the set of objects that inhabit it (hierarchy-aware).\"\"\"\n parent = dict(domain.types)\n\n def ancestors(typ: str):\n chain = [typ]\n seen = {typ}\n cur = typ\n while cur in parent and parent[cur] not in (None, \"object\", cur):\n cur = parent[cur]\n if cur in seen:\n break\n seen.add(cur)\n chain.append(cur)\n return chain\n\n type_objects: dict = defaultdict(set)\n declared: set = set()\n for name, typ in list(domain.constants) + list(problem.objects):\n declared.add(name)\n for anc in ancestors(typ):\n type_objects[anc].add(name)\n type_objects[\"object\"].add(name)\n\n # Robustness: some (toy) problems omit the :objects section and only mention\n # constants in :init/:goal. Treat any such undeclared constant as an object\n # of the root type so untyped domains still ground.\n for name in _harvest_constants(problem):\n if name not in declared:\n type_objects[\"object\"].add(name)\n\n for typ in set(list(parent) + list(parent.values())):\n type_objects.setdefault(typ, set())\n return {typ: tuple(sorted(objs)) for typ, objs in type_objects.items()}\n\n\ndef _harvest_constants(problem: Problem) -> set:\n found: set = set()\n for atom in problem.init:\n found.update(arg for arg in atom.args if not arg.startswith(\"?\"))\n for ref in problem.init_numeric:\n found.update(arg for arg in ref.args if not arg.startswith(\"?\"))\n\n def walk(formula):\n if isinstance(formula, Literal):\n found.update(a for a in formula.atom.args if not a.startswith(\"?\"))\n elif isinstance(formula, (And, Or)):\n for part in formula.parts:\n walk(part)\n elif isinstance(formula, (Exists, Forall)):\n walk(formula.body)\n\n walk(problem.goal)\n return found\n\n\n# --------------------------------------------------------------------------\n# condition formulas -> DNF\n# --------------------------------------------------------------------------\n@dataclass\nclass _Disjunct:\n \"\"\"One ground conjunction: positive atoms, negative atoms, comparisons.\"\"\"\n\n pos: set = field(default_factory=set)\n neg: set = field(default_factory=set)\n comparisons: list = field(default_factory=list)\n\n def merged(self, other: \"_Disjunct\") -> \"_Disjunct\":\n return _Disjunct(\n self.pos | other.pos,\n self.neg | other.neg,\n self.comparisons + other.comparisons,\n )\n\n @property\n def contradictory(self) -> bool:\n return bool(self.pos & self.neg)\n\n\nTRUE_DNF = [_Disjunct()]\nFALSE_DNF: list = []\n\n\ndef _dnf(formula, subst: dict, type_objects: dict) -> list:\n \"\"\"Expand quantifiers and distribute ``formula`` into a list of disjuncts.\n\n An empty list means *unsatisfiable*; a list holding one empty disjunct means\n *trivially true*.\n \"\"\"\n if formula is None:\n return TRUE_DNF\n\n if isinstance(formula, Truth):\n return TRUE_DNF if formula.value else FALSE_DNF\n\n if isinstance(formula, Literal):\n atom = _ground_atom(formula.atom, subst)\n if formula.positive:\n return [_Disjunct(pos={atom})]\n return [_Disjunct(neg={atom})]\n\n if isinstance(formula, EqualityConstraint):\n left = subst.get(formula.left, formula.left)\n right = subst.get(formula.right, formula.right)\n holds = (left == right) == formula.positive\n return TRUE_DNF if holds else FALSE_DNF\n\n if isinstance(formula, Comparison):\n grounded = Comparison(\n formula.op,\n _ground_expression(formula.left, subst),\n _ground_expression(formula.right, subst),\n )\n return [_Disjunct(comparisons=[grounded])]\n\n if isinstance(formula, And):\n result = TRUE_DNF\n for part in formula.parts:\n result = _cross(result, _dnf(part, subst, type_objects))\n if not result:\n return FALSE_DNF\n return result\n\n if isinstance(formula, Or):\n out: list = []\n for part in formula.parts:\n out.extend(_dnf(part, subst, type_objects))\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n \"disjunctive condition expands past \"\n f\"{MAX_DISJUNCTS} cases; simplify the domain or split the action\"\n )\n return out\n\n if isinstance(formula, Forall):\n result = TRUE_DNF\n for sub in _quantifier_substitutions(formula.params, subst, type_objects):\n result = _cross(result, _dnf(formula.body, sub, type_objects))\n if not result:\n return FALSE_DNF\n return result\n\n if isinstance(formula, Exists):\n out = []\n for sub in _quantifier_substitutions(formula.params, subst, type_objects):\n out.extend(_dnf(formula.body, sub, type_objects))\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n \"existential condition expands past \"\n f\"{MAX_DISJUNCTS} cases; the object pool is too large\"\n )\n return out\n\n raise PDDLError(f\"unexpected condition node: {formula!r}\")\n\n\ndef _quantifier_substitutions(params, subst: dict, type_objects: dict):\n pools = [type_objects.get(typ, ()) for (_, typ) in params]\n for combo in product(*pools):\n extended = dict(subst)\n for (var, _), obj in zip(params, combo):\n extended[var] = obj\n yield extended\n\n\ndef _cross(left: list, right: list) -> list:\n \"\"\"Distribute a conjunction of two DNFs, dropping contradictory disjuncts.\"\"\"\n if not left or not right:\n return FALSE_DNF\n out = []\n for a in left:\n for b in right:\n merged = a.merged(b)\n if not merged.contradictory:\n out.append(merged)\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n f\"conjunction of disjunctions expands past {MAX_DISJUNCTS} cases\"\n )\n return out\n\n\n# --------------------------------------------------------------------------\n# effects\n# --------------------------------------------------------------------------\n@dataclass\nclass _RawOp:\n name: str\n pre_pos: set\n pre_neg: set\n comparisons: list\n add: set\n delete: set\n cond: list # (cpos, cneg, cadd, cdel)\n numeric: list # (op, FluentRef, expression)\n cost: float\n duration: float\n synthetic: bool = False\n\n\ndef _collect_effect(eff, subst, type_objects, cpos, cneg, acc):\n if isinstance(eff, ConjunctiveEffect):\n for part in eff.parts:\n _collect_effect(part, subst, type_objects, cpos, cneg, acc)\n elif isinstance(eff, AddEffect):\n atom = _ground_atom(eff.atom, subst)\n if cpos or cneg:\n acc[\"cond\"].append((frozenset(cpos), frozenset(cneg), {atom}, set()))\n else:\n acc[\"add\"].add(atom)\n elif isinstance(eff, DelEffect):\n atom = _ground_atom(eff.atom, subst)\n if cpos or cneg:\n acc[\"cond\"].append((frozenset(cpos), frozenset(cneg), set(), {atom}))\n else:\n acc[\"delete\"].add(atom)\n elif isinstance(eff, IncreaseCostEffect):\n acc[\"cost\"] += eff.amount\n acc[\"has_cost\"] = True\n elif isinstance(eff, NumericEffect):\n if cpos or cneg:\n raise UnsupportedFeatureError(\n \"numeric effects inside a 'when' are not supported\"\n )\n acc[\"numeric\"].append(\n (\n eff.op,\n _ground_fluent(eff.target, subst),\n _ground_expression(eff.value, subst),\n )\n )\n elif isinstance(eff, ForallEffect):\n for sub in _quantifier_substitutions(eff.params, subst, type_objects):\n _collect_effect(eff.body, sub, type_objects, cpos, cneg, acc)\n elif isinstance(eff, WhenEffect):\n # A disjunctive effect condition splits into one conditional effect per\n # disjunct, which is exactly equivalent.\n for disjunct in _dnf(eff.condition, subst, type_objects):\n if disjunct.comparisons:\n raise UnsupportedFeatureError(\n \"numeric comparisons inside a 'when' condition are not supported\"\n )\n _collect_effect(\n eff.body,\n subst,\n type_objects,\n cpos | disjunct.pos,\n cneg | disjunct.neg,\n acc,\n )\n else:\n raise PDDLError(f\"unexpected effect node: {eff!r}\")\n\n\ndef _effect_predicates(domain: Domain) -> set:\n preds: set = set()\n\n def walk(eff):\n if isinstance(eff, ConjunctiveEffect):\n for part in eff.parts:\n walk(part)\n elif isinstance(eff, (AddEffect, DelEffect)):\n preds.add(eff.atom.predicate)\n elif isinstance(eff, (ForallEffect, WhenEffect)):\n walk(eff.body)\n\n for action in domain.actions:\n walk(action.effect)\n return preds\n\n\ndef _ground_raw_operators(domain, problem, type_objects) -> list:\n raw = []\n for action in domain.actions:\n pools = [type_objects.get(typ, ()) for (_, typ) in action.parameters]\n for combo in product(*pools):\n subst = {var: obj for (var, _), obj in zip(action.parameters, combo)}\n disjuncts = _dnf(action.precondition, subst, type_objects)\n if not disjuncts:\n continue # precondition is unsatisfiable for this instance\n\n acc = {\n \"add\": set(),\n \"delete\": set(),\n \"cond\": [],\n \"numeric\": [],\n \"cost\": 0.0,\n \"has_cost\": False,\n }\n _collect_effect(action.effect, subst, type_objects, set(), set(), acc)\n\n args = \",\".join(combo)\n base = f\"{action.name}({args})\" if combo else action.name\n duration = 0.0\n if action.duration is not None:\n duration = _constant_value(action.duration, subst, action.name)\n if acc[\"has_cost\"]:\n cost = acc[\"cost\"]\n elif duration:\n # A temporal action with no explicit cost: optimise makespan.\n cost = duration\n else:\n cost = 1\n\n for index, disjunct in enumerate(disjuncts):\n # Only tag the name when the split is real, so classical domains\n # keep the operator names their users expect.\n name = base if len(disjuncts) == 1 else f\"{base}#{index + 1}\"\n raw.append(\n _RawOp(\n name=name,\n pre_pos=set(disjunct.pos),\n pre_neg=set(disjunct.neg),\n comparisons=list(disjunct.comparisons),\n add=set(acc[\"add\"]),\n delete=set(acc[\"delete\"]),\n cond=list(acc[\"cond\"]),\n numeric=list(acc[\"numeric\"]),\n cost=cost,\n duration=duration,\n )\n )\n return raw\n\n\ndef _constant_value(expr, subst, action_name):\n \"\"\"Evaluate a duration expression that must be constant.\"\"\"\n grounded = _ground_expression(expr, subst)\n if isinstance(grounded, Number):\n return float(grounded.value)\n raise UnsupportedFeatureError(\n f\"the duration of '{action_name}' must be a constant; \"\n \"durations that read numeric fluents are not supported\"\n )\n\n\n# --------------------------------------------------------------------------\n# numeric compilation\n# --------------------------------------------------------------------------\ndef _collect_fluents(expr, out: set) -> None:\n if isinstance(expr, FluentRef):\n out.add(expr)\n elif isinstance(expr, Arithmetic):\n _collect_fluents(expr.left, out)\n _collect_fluents(expr.right, out)\n\n\ndef _compile_expression(expr, index_of: dict):\n \"\"\"Compile a ground numeric expression into a ``values -> float`` closure.\"\"\"\n if isinstance(expr, Number):\n constant = float(expr.value)\n return lambda values: constant\n if isinstance(expr, FluentRef):\n index = index_of[expr]\n return lambda values: values[index]\n if isinstance(expr, Arithmetic):\n left = _compile_expression(expr.left, index_of)\n right = _compile_expression(expr.right, index_of)\n if expr.op == \"+\":\n return lambda values: left(values) + right(values)\n if expr.op == \"-\":\n return lambda values: left(values) - right(values)\n if expr.op == \"*\":\n return lambda values: left(values) * right(values)\n if expr.op == \"/\":\n\n def divide(values):\n denominator = right(values)\n if denominator == 0:\n # Undefined rather than crashing mid-search: an infinite\n # value makes every comparison against it fail.\n return float(\"inf\")\n return left(values) / denominator\n\n return divide\n raise PDDLError(f\"unknown arithmetic operator '{expr.op}'\")\n raise PDDLError(f\"unexpected numeric expression: {expr!r}\")\n\n\ndef _compile_comparison(comparison: Comparison, index_of: dict):\n left = _compile_expression(comparison.left, index_of)\n right = _compile_expression(comparison.right, index_of)\n op = comparison.op\n if op == \"<\":\n return lambda values: left(values) < right(values)\n if op == \"<=\":\n return lambda values: left(values) <= right(values)\n if op == \">\":\n return lambda values: left(values) > right(values)\n if op == \">=\":\n return lambda values: left(values) >= right(values)\n if op == \"=\":\n return lambda values: left(values) == right(values)\n if op == \"!=\":\n return lambda values: left(values) != right(values)\n raise PDDLError(f\"unknown comparison operator '{op}'\")\n\n\ndef _compile_numeric_effect(op: str, index: int, value, index_of: dict):\n compute = _compile_expression(value, index_of)\n if op == \"assign\":\n return (index, compute)\n if op == \"increase\":\n return (index, lambda values: values[index] + compute(values))\n if op == \"decrease\":\n return (index, lambda values: values[index] - compute(values))\n if op == \"scale-up\":\n return (index, lambda values: values[index] * compute(values))\n if op == \"scale-down\":\n\n def scale_down(values):\n divisor = compute(values)\n return float(\"inf\") if divisor == 0 else values[index] / divisor\n\n return (index, scale_down)\n raise PDDLError(f\"unknown numeric assignment '{op}'\")\n\n\n# --------------------------------------------------------------------------\n# encoding\n# --------------------------------------------------------------------------\n@dataclass\nclass _Encoder:\n fact_ids: dict = field(default_factory=dict)\n comp_ids: dict = field(default_factory=dict)\n names: list = field(default_factory=list)\n\n def fact(self, atom: Atom) -> int:\n if atom not in self.fact_ids:\n self.fact_ids[atom] = len(self.names)\n self.names.append(str(atom))\n return self.fact_ids[atom]\n\n def comp(self, atom: Atom) -> int:\n if atom not in self.comp_ids:\n self.comp_ids[atom] = len(self.names)\n self.names.append(f\"(not {atom})\")\n return self.comp_ids[atom]\n\n\ndef _ground_axioms(domain: Domain, type_objects: dict) -> list:\n \"\"\"Ground every derived-predicate rule into (head atom, disjunct) pairs.\"\"\"\n grounded = []\n for rule in domain.derived:\n for subst in _quantifier_substitutions(rule.params, {}, type_objects):\n head = _ground_atom(rule.head, subst)\n for disjunct in _dnf(rule.body, subst, type_objects):\n if disjunct.comparisons:\n raise UnsupportedFeatureError(\n \"numeric comparisons in a derived predicate are not supported\"\n )\n grounded.append((head, disjunct))\n return grounded\n\n\ndef ground(domain: Domain, problem: Problem) -> Task:\n \"\"\"Ground ``domain`` + ``problem`` into a :class:`Task`.\"\"\"\n # PDDL 3 constructs (preferences, trajectory constraints, timed literals,\n # object fluents) are rewritten into the classical core first, so nothing\n # below this line has to know they exist.\n requirements = tuple(domain.requirements)\n domain, problem = compile_problem(domain, problem)\n type_objects = _build_type_objects(domain, problem)\n init_atoms = set(problem.init)\n\n derived_preds = {rule.head.predicate for rule in domain.derived}\n all_preds = {p.name for p in domain.predicates} | derived_preds\n # A derived predicate never appears in an effect, but it is emphatically not\n # static -- the axioms compute it.\n static_preds = all_preds - _effect_predicates(domain) - derived_preds\n\n def is_static(atom: Atom) -> bool:\n return atom.predicate in static_preds\n\n raw_ops = _ground_raw_operators(domain, problem, type_objects)\n axiom_rules = _ground_axioms(domain, type_objects)\n\n enc = _Encoder()\n tracked_neg: set = set()\n\n def resolve_literals(pos_atoms, neg_atoms):\n \"\"\"Drop static literals that hold, fail on ones that do not.\"\"\"\n pos_fluent, neg_fluent = set(), set()\n for atom in pos_atoms:\n if is_static(atom):\n if atom not in init_atoms:\n return None\n else:\n pos_fluent.add(atom)\n for atom in neg_atoms:\n if is_static(atom):\n if atom in init_atoms:\n return None\n else:\n neg_fluent.add(atom)\n tracked_neg.add(atom)\n return pos_fluent, neg_fluent\n\n # --- resolve static literals, drop infeasible operators ------------------\n resolved = []\n for op in raw_ops:\n pre = resolve_literals(op.pre_pos, op.pre_neg)\n if pre is None:\n continue\n pre_pos_fluent, pre_neg_fluent = pre\n\n add = set(op.add)\n delete = set(op.delete)\n cond = []\n for cpos, cneg, cadd, cdel in op.cond:\n trigger = resolve_literals(cpos, cneg)\n if trigger is None:\n continue # this conditional effect can never fire\n cpos_f, cneg_f = trigger\n if not cpos_f and not cneg_f:\n add |= cadd\n delete |= cdel\n else:\n cond.append((cpos_f, cneg_f, cadd, cdel))\n resolved.append((op, pre_pos_fluent, pre_neg_fluent, add, delete, cond))\n\n # --- goal ----------------------------------------------------------------\n goal_disjuncts = _dnf(problem.goal, {}, type_objects)\n if not goal_disjuncts:\n raise ValueError(\"Goal condition is self-contradictory\")\n\n resolved_goals = []\n unsolvable = True\n for disjunct in goal_disjuncts:\n parts = resolve_literals(disjunct.pos, disjunct.neg)\n if parts is None:\n continue # this way of satisfying the goal is statically impossible\n unsolvable = False\n resolved_goals.append((parts[0], parts[1], disjunct.comparisons))\n if not resolved_goals:\n resolved_goals = [(set(), set(), [])]\n\n # --- axioms --------------------------------------------------------------\n resolved_axioms = []\n for head, disjunct in axiom_rules:\n parts = resolve_literals(disjunct.pos, disjunct.neg)\n if parts is None:\n continue\n resolved_axioms.append((head, parts[0], parts[1]))\n\n # --- numeric fluents -----------------------------------------------------\n fluents: set = set(problem.init_numeric)\n for op, *_ in resolved:\n for comparison in op.comparisons:\n _collect_fluents(comparison.left, fluents)\n _collect_fluents(comparison.right, fluents)\n for _, target, value in op.numeric:\n fluents.add(target)\n _collect_fluents(value, fluents)\n for _, _, comparisons in resolved_goals:\n for comparison in comparisons:\n _collect_fluents(comparison.left, fluents)\n _collect_fluents(comparison.right, fluents)\n # `total-cost` is bookkeeping handled by operator costs, not a state variable.\n fluents = {ref for ref in fluents if ref.name != \"total-cost\"}\n\n ordered_fluents = sorted(fluents, key=str)\n index_of = {ref: i for i, ref in enumerate(ordered_fluents)}\n numeric_names = tuple(str(ref) for ref in ordered_fluents)\n init_values = tuple(\n float(problem.init_numeric.get(ref, 0.0)) for ref in ordered_fluents\n )\n\n # --- encode facts --------------------------------------------------------\n init_ids = {enc.fact(a) for a in init_atoms if not is_static(a)}\n for atom in tracked_neg:\n cid = enc.comp(atom)\n if atom not in init_atoms:\n init_ids.add(cid)\n\n def encode_add_del(add_atoms, del_atoms):\n add_ids = {enc.fact(a) for a in add_atoms}\n del_ids = {enc.fact(a) for a in del_atoms}\n for a in add_atoms:\n if a in tracked_neg:\n del_ids.add(enc.comp(a))\n for a in del_atoms:\n if a in tracked_neg:\n add_ids.add(enc.comp(a))\n return frozenset(add_ids), frozenset(del_ids)\n\n operators = []\n for op, pp, pn, add, delete, cond in resolved:\n precond = {enc.fact(a) for a in pp} | {enc.comp(a) for a in pn}\n add_ids, del_ids = encode_add_del(add, delete)\n cond_effects = []\n for cpos_f, cneg_f, cadd, cdel in cond:\n cond_ids = {enc.fact(a) for a in cpos_f} | {enc.comp(a) for a in cneg_f}\n cadd_ids, cdel_ids = encode_add_del(cadd, cdel)\n if cadd_ids or cdel_ids:\n cond_effects.append(CondEffect(frozenset(cond_ids), cadd_ids, cdel_ids))\n numeric_pre = tuple(_compile_comparison(c, index_of) for c in op.comparisons)\n numeric_eff = tuple(\n _compile_numeric_effect(kind, index_of[target], value, index_of)\n for kind, target, value in op.numeric\n )\n operators.append(\n Operator(\n op.name,\n frozenset(precond),\n add_ids,\n del_ids,\n tuple(cond_effects),\n op.cost,\n numeric_pre,\n numeric_eff,\n op.duration,\n )\n )\n\n # --- goal encoding, compiling a disjunction into a synthetic fact --------\n synthetic: set = set()\n goal_numeric: tuple = ()\n if len(resolved_goals) == 1:\n goal_pos, goal_neg, comparisons = resolved_goals[0]\n goals = {enc.fact(a) for a in goal_pos} | {enc.comp(a) for a in goal_neg}\n goal_numeric = tuple(_compile_comparison(c, index_of) for c in comparisons)\n else:\n goal_fact = enc.fact(GOAL_FACT)\n goals = {goal_fact}\n for index, (goal_pos, goal_neg, comparisons) in enumerate(resolved_goals):\n name = f\"__reach-goal__#{index + 1}\"\n synthetic.add(name)\n precond = {enc.fact(a) for a in goal_pos} | {enc.comp(a) for a in goal_neg}\n operators.append(\n Operator(\n name,\n frozenset(precond),\n frozenset({goal_fact}),\n frozenset(),\n (),\n 0,\n tuple(_compile_comparison(c, index_of) for c in comparisons),\n (),\n 0.0,\n )\n )\n\n if unsolvable:\n sentinel = len(enc.names)\n enc.names.append(\"(unsolvable)\")\n goals.add(sentinel) # never produced by any operator\n\n axioms = tuple(\n Axiom(\n enc.fact(head),\n frozenset(\n {enc.fact(a) for a in body_pos} | {enc.comp(a) for a in body_neg}\n ),\n )\n for head, body_pos, body_neg in resolved_axioms\n )\n\n # Anything a compilation introduced is bookkeeping, not something the\n # domain author wrote, so keep it out of printed plans.\n synthetic |= {\n op.name for op in operators if op.base_name.startswith(SYNTHETIC_PREFIX)\n }\n temporal = any(op.duration for op in operators)\n clock_index = index_of.get(CLOCK_FLUENT)\n metric = None\n if problem.metric is not None:\n direction, expression = problem.metric\n metric = f\"{direction} {expression}\"\n\n return Task(\n name=problem.name or domain.name,\n facts=tuple(enc.names),\n init=frozenset(init_ids),\n goals=frozenset(goals),\n operators=tuple(operators),\n metric_cost=problem.metric_minimize_cost,\n axioms=axioms,\n numeric_names=numeric_names,\n init_values=init_values,\n goal_numeric=goal_numeric,\n temporal=temporal,\n clock_index=clock_index,\n metric=metric,\n requirements=requirements,\n synthetic=frozenset(synthetic),\n )\n\n\ndef ground_files(domain_path: str, problem_path: str) -> Task:\n \"\"\"Convenience: parse both files and ground them into a :class:`Task`.\"\"\"\n return ground(parse_domain_file(domain_path), parse_problem_file(problem_path))\n", "jupyddl/heuristics/__init__.py": "\"\"\"Heuristics and a name-based registry.\"\"\"\n\nfrom __future__ import annotations\n\nfrom functools import partial\n\nfrom .base import Heuristic\nfrom .critical_path import CriticalPathHeuristic\nfrom .delete_relaxation import FFHeuristic, HAddHeuristic, HMaxHeuristic\nfrom .lmcut import LMCutHeuristic\nfrom .simple import BlindHeuristic, GoalCountHeuristic\n\n# name -> callable(task) -> Heuristic\nHEURISTICS = {\n \"blind\": BlindHeuristic,\n \"goalcount\": GoalCountHeuristic,\n \"hmax\": HMaxHeuristic,\n \"hadd\": HAddHeuristic,\n \"hff\": FFHeuristic,\n \"lmcut\": LMCutHeuristic,\n \"h1\": partial(CriticalPathHeuristic, m=1),\n \"h2\": partial(CriticalPathHeuristic, m=2),\n \"hm\": CriticalPathHeuristic,\n}\n\n\n# A heuristic that needs more than a task to build takes an argument after a\n# colon: ``learned:blocksworld.heur.json``. Keeping this a *string* spec rather\n# than a Python object is what makes a trained heuristic usable from the CLI,\n# the benchmark harness and the web UI without any of them knowing it exists.\nLOADERS = {}\n\n\ndef _load_learned(path: str, task) -> Heuristic:\n \"\"\"Resolve ``learned:``, importing the learning stack only if asked.\"\"\"\n from ..learn.heuristic import HeuristicBundle\n\n return HeuristicBundle.load(path).bind(task)\n\n\nLOADERS[\"learned\"] = _load_learned\n\n\ndef make_heuristic(name, task) -> Heuristic:\n \"\"\"Instantiate a heuristic by name (see :data:`HEURISTICS`).\n\n Accepts a registry name (``\"lmcut\"``), a parameterised spec\n (``\"learned:model.json\"``), or an already-built heuristic, which is passed\n through so callers holding a trained model need not round-trip it to disk.\n \"\"\"\n if callable(name) and not isinstance(name, str):\n return name\n if \":\" in name:\n kind, _, argument = name.partition(\":\")\n loader = LOADERS.get(kind)\n if loader is None:\n raise ValueError(\n f\"Unknown parameterised heuristic '{kind}'. \"\n f\"Available: {sorted(LOADERS)}\"\n )\n return loader(argument, task)\n try:\n factory = HEURISTICS[name]\n except KeyError:\n raise ValueError(\n f\"Unknown heuristic '{name}'. Available: {sorted(HEURISTICS)}\"\n + (f\" (or {sorted(LOADERS)} with an argument)\" if LOADERS else \"\")\n ) from None\n return factory(task)\n\n\n__all__ = [\n \"Heuristic\",\n \"BlindHeuristic\",\n \"GoalCountHeuristic\",\n \"HMaxHeuristic\",\n \"HAddHeuristic\",\n \"FFHeuristic\",\n \"LMCutHeuristic\",\n \"CriticalPathHeuristic\",\n \"HEURISTICS\",\n \"LOADERS\",\n \"make_heuristic\",\n]\n", "jupyddl/heuristics/base.py": "\"\"\"Heuristic base class.\"\"\"\n\nfrom __future__ import annotations\n\n\nclass Heuristic:\n \"\"\"A heuristic bound to a task; call it on a state to get an estimate.\n\n Returning ``math.inf`` signals that the state is a (relaxed) dead end.\n \"\"\"\n\n name: str = \"heuristic\"\n admissible: bool = False\n\n def __init__(self, task):\n self.task = task\n\n def __call__(self, state: frozenset) -> float: # pragma: no cover\n raise NotImplementedError\n", "jupyddl/heuristics/critical_path.py": "\"\"\"Critical-path heuristics h^m (Haslum & Geffner, 2000).\n\n``h^m`` estimates the cost of the most expensive size-``m`` subset of atoms.\n``h^1`` equals ``h_max``; higher ``m`` is more informative but costs more.\nAll ``h^m`` are admissible.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom itertools import combinations\n\nfrom .base import Heuristic\n\n\nclass CriticalPathHeuristic(Heuristic):\n name = \"hm\"\n admissible = True\n\n def __init__(self, task, m: int = 2):\n super().__init__(task)\n self.m = m\n self.goal = task.goals\n self.ops = list(task.relaxed_operators()) # (pre, add, cost)\n\n def __call__(self, state) -> float:\n return self._hm(state)\n\n def _hm(self, state) -> float:\n m = self.m\n inf = math.inf\n atoms = set(state) | set(self.goal)\n for pre, add, _ in self.ops:\n atoms |= pre\n atoms |= add\n atoms = sorted(atoms)\n\n # Table of costs for every atom-set of size 1..m.\n table: dict = {}\n sets: list = []\n for size in range(1, m + 1):\n for combo in combinations(atoms, size):\n fs = frozenset(combo)\n table[fs] = 0.0 if fs <= state else inf\n sets.append(fs)\n\n def cost_of(subset: frozenset) -> float:\n # Cost of an arbitrary atom-set: table lookup, or (if larger than m)\n # the max over its size-m subsets.\n if not subset:\n return 0.0\n if len(subset) <= m:\n return table.get(subset, inf)\n best = 0.0\n for combo in combinations(sorted(subset), m):\n val = table.get(frozenset(combo), inf)\n if val > best:\n best = val\n return best\n\n # Value iteration to the fixpoint.\n changed = True\n while changed:\n changed = False\n for target in sets:\n if table[target] == 0.0:\n continue\n best = table[target]\n for pre, add, cost in self.ops:\n if not (add & target):\n continue\n regressed = (target - add) | pre\n val = cost + cost_of(regressed)\n if val < best:\n best = val\n if best < table[target]:\n table[target] = best\n changed = True\n\n return cost_of(frozenset(self.goal))\n", "jupyddl/heuristics/delete_relaxation.py": "\"\"\"Delete-relaxation heuristics: h_max, h_add and the FF heuristic.\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom collections import deque\n\nfrom .base import Heuristic\nfrom .relaxation import RelaxedTask, goal_value, propagate_costs\n\n\nclass HMaxHeuristic(Heuristic):\n \"\"\"h_max: the most expensive relaxed goal fact. Admissible.\"\"\"\n\n name = \"hmax\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, _ = propagate_costs(self.rt, state, additive=False)\n return goal_value(cost, self.rt.goal, additive=False)\n\n\nclass HAddHeuristic(Heuristic):\n \"\"\"h_add: sum of relaxed goal-fact costs. Informative, not admissible.\"\"\"\n\n name = \"hadd\"\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, _ = propagate_costs(self.rt, state, additive=True)\n return goal_value(cost, self.rt.goal, additive=True)\n\n\nclass FFHeuristic(Heuristic):\n \"\"\"FF heuristic: cost of a relaxed plan extracted from the h_add graph.\"\"\"\n\n name = \"hff\"\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, supporter = propagate_costs(self.rt, state, additive=True)\n if any(math.isinf(cost[g]) for g in self.rt.goal):\n return math.inf\n relaxed_plan: set = set()\n seen: set = set()\n queue = deque(self.rt.goal)\n while queue:\n fact = queue.popleft()\n if fact in state or fact in seen:\n continue\n seen.add(fact)\n op_idx = supporter[fact]\n if op_idx < 0:\n return math.inf\n if op_idx not in relaxed_plan:\n relaxed_plan.add(op_idx)\n for pre_fact in self.rt.ops[op_idx].pre:\n if pre_fact not in state:\n queue.append(pre_fact)\n return float(sum(self.rt.ops[i].cost for i in relaxed_plan))\n", "jupyddl/heuristics/lmcut.py": "\"\"\"The LM-cut heuristic (Helmert & Domshlak, 2009).\n\nLM-cut repeatedly computes h_max, finds a *landmark cut* of operators separating\nthe initial state from the goal in the justification graph, adds the cut's\ncheapest operator cost to the estimate, and discounts the cut operators. It is\none of the strongest admissible heuristics for optimal planning.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport heapq\nimport math\nfrom dataclasses import dataclass\n\nfrom .base import Heuristic\n\n\n@dataclass\nclass _AugOp:\n idx: int\n pre: frozenset\n add: frozenset\n base_cost: int\n\n\nclass LMCutHeuristic(Heuristic):\n name = \"lmcut\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n nf = task.num_facts\n self.INIT = nf # artificial \"always true\" fact\n self.GOAL = nf + 1 # artificial goal fact\n self.num = nf + 2\n\n self.ops: list[_AugOp] = []\n for pre, add, cost in task.relaxed_operators():\n # Every operator must have at least one precondition for the\n # justification graph; use the artificial INIT fact if needed.\n pre = pre if pre else frozenset({self.INIT})\n self.ops.append(_AugOp(len(self.ops), pre, add, cost))\n # Artificial goal operator (cost 0) collecting the real goal.\n self.goal_op = len(self.ops)\n self.ops.append(_AugOp(self.goal_op, task.goals, frozenset({self.GOAL}), 0))\n\n self.consumers: list[list[int]] = [[] for _ in range(self.num)]\n for op in self.ops:\n for f in op.pre:\n self.consumers[f].append(op.idx)\n\n def __call__(self, state) -> float:\n base = set(state)\n base.add(self.INIT)\n source = frozenset(base)\n costs = [op.base_cost for op in self.ops]\n total = 0.0\n\n while True:\n hmax, pcf = self._hmax(source, costs)\n if hmax[self.GOAL] == 0:\n return total\n if math.isinf(hmax[self.GOAL]):\n return math.inf\n\n goal_zone = self._goal_zone(costs, pcf)\n cut = self._cut(source, goal_zone, pcf, hmax)\n if not cut: # safety; should not happen when hmax(goal) > 0\n return math.inf\n min_cost = min(costs[oi] for oi in cut)\n total += min_cost\n for oi in cut:\n costs[oi] -= min_cost\n\n def _hmax(self, source, costs):\n inf = math.inf\n cost = [inf] * self.num\n pcf = [-1] * len(self.ops)\n counter = [len(op.pre) for op in self.ops]\n pq: list = []\n for f in source:\n cost[f] = 0\n heapq.heappush(pq, (0, f))\n\n def relax(op: _AugOp):\n supporter = max(op.pre, key=lambda p: cost[p])\n value = cost[supporter] + costs[op.idx]\n pcf[op.idx] = supporter\n if math.isinf(cost[supporter]):\n return\n for f in op.add:\n if value < cost[f]:\n cost[f] = value\n heapq.heappush(pq, (value, f))\n\n for op in self.ops:\n if counter[op.idx] == 0:\n relax(op)\n while pq:\n c, f = heapq.heappop(pq)\n if c > cost[f]:\n continue\n for op_idx in self.consumers[f]:\n counter[op_idx] -= 1\n if counter[op_idx] == 0:\n relax(self.ops[op_idx])\n return cost, pcf\n\n def _goal_zone(self, costs, pcf):\n \"\"\"Facts that reach the goal through zero-cost justification edges.\"\"\"\n zone = {self.GOAL}\n changed = True\n while changed:\n changed = False\n for op in self.ops:\n supporter = pcf[op.idx]\n if (\n costs[op.idx] == 0\n and supporter >= 0\n and supporter not in zone\n and (op.add & zone)\n ):\n zone.add(supporter)\n changed = True\n return zone\n\n def _cut(self, source, goal_zone, pcf, hmax):\n \"\"\"Operators crossing from the init-reachable region into the goal zone.\"\"\"\n # Forward-reachable facts from the initial state that stay out of the\n # goal zone (the \"before\" region).\n before = {f for f in source if f not in goal_zone}\n changed = True\n while changed:\n changed = False\n for op in self.ops:\n supporter = pcf[op.idx]\n if supporter in before and not math.isinf(hmax[supporter]):\n for f in op.add:\n if f not in goal_zone and f not in before:\n before.add(f)\n changed = True\n cut = [\n op.idx for op in self.ops if pcf[op.idx] in before and (op.add & goal_zone)\n ]\n return cut\n", "jupyddl/heuristics/relaxation.py": "\"\"\"Delete-relaxation machinery shared by h_max, h_add, h_FF and LM-cut.\"\"\"\n\nfrom __future__ import annotations\n\nimport heapq\nimport math\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass RelaxedOp:\n idx: int\n pre: frozenset\n add: frozenset\n cost: int\n\n\nclass RelaxedTask:\n \"\"\"Delete-relaxed view of a task: unary-ish operators + goal.\n\n Conditional effects are already expanded into separate relaxed operators by\n :meth:`jupyddl.task.Task.relaxed_operators`.\n \"\"\"\n\n def __init__(self, task):\n self.num_facts = task.num_facts\n self.goal = task.goals\n self.ops = [\n RelaxedOp(i, pre, add, cost)\n for i, (pre, add, cost) in enumerate(task.relaxed_operators())\n ]\n # Index: fact -> operators that have it as a precondition.\n self.consumers: list[list[int]] = [[] for _ in range(self.num_facts)]\n self.no_pre: list[int] = []\n for op in self.ops:\n if op.pre:\n for f in op.pre:\n self.consumers[f].append(op.idx)\n else:\n self.no_pre.append(op.idx)\n\n\ndef propagate_costs(rt: RelaxedTask, state, additive: bool):\n \"\"\"Generalised Dijkstra computing h_max (``additive=False``) or h_add costs.\n\n Returns ``(cost, supporter)`` where ``cost[f]`` is the estimated cost to\n achieve fact ``f`` and ``supporter[f]`` is the operator index that achieved\n it (used for FF's relaxed-plan extraction).\n \"\"\"\n inf = math.inf\n cost = [inf] * rt.num_facts\n supporter = [-1] * rt.num_facts\n counter = [len(op.pre) for op in rt.ops]\n pq: list = []\n\n for f in state:\n if cost[f] != 0:\n cost[f] = 0\n heapq.heappush(pq, (0, f))\n\n def op_value(op: RelaxedOp) -> float:\n if not op.pre:\n return op.cost\n pre_costs = [cost[p] for p in op.pre]\n agg = sum(pre_costs) if additive else max(pre_costs)\n return op.cost + agg\n\n def apply_op(op: RelaxedOp):\n value = op_value(op)\n if math.isinf(value):\n return\n for f in op.add:\n if value < cost[f]:\n cost[f] = value\n supporter[f] = op.idx\n heapq.heappush(pq, (value, f))\n\n for idx in rt.no_pre:\n apply_op(rt.ops[idx])\n\n while pq:\n c, f = heapq.heappop(pq)\n if c > cost[f]:\n continue\n for op_idx in rt.consumers[f]:\n counter[op_idx] -= 1\n if counter[op_idx] == 0:\n apply_op(rt.ops[op_idx])\n return cost, supporter\n\n\ndef goal_value(cost, goal, additive: bool) -> float:\n if not goal:\n return 0.0\n values = [cost[g] for g in goal]\n if any(math.isinf(v) for v in values):\n return math.inf\n return float(sum(values) if additive else max(values))\n", "jupyddl/heuristics/simple.py": "\"\"\"Cheap non-relaxation heuristics.\"\"\"\n\nfrom __future__ import annotations\n\nfrom ..task import facts_of\nfrom .base import Heuristic\n\n\nclass BlindHeuristic(Heuristic):\n \"\"\"0 in a goal state, otherwise the cheapest operator cost. Admissible.\"\"\"\n\n name = \"blind\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n costs = [op.cost for op in task.operators if op.cost > 0]\n self.min_cost = min(costs) if costs else 1\n\n def __call__(self, state) -> float:\n return 0.0 if self.task.goal_reached(state) else float(self.min_cost)\n\n\nclass GoalCountHeuristic(Heuristic):\n \"\"\"Number of unsatisfied goal facts. Fast, informative, not admissible.\"\"\"\n\n name = \"goalcount\"\n\n def __call__(self, state) -> float:\n return float(len(self.task.goals - facts_of(state)))\n", "jupyddl/live.py": "\"\"\"A live search dashboard that runs in any terminal, with no dependencies.\n\n:class:`TerminalDashboard` is a :class:`~jupyddl.trace.SearchObserver` that\nrepaints a small block of the terminal while the planner works \u2014 sparklines for\nthe heuristic and the ``f`` frontier, a frontier gauge, live counters and a node\nrate. It is pure standard library (ANSI escapes and Unicode block characters),\nso watching a search never costs you a dependency::\n\n from jupyddl import build_task, solve_task\n from jupyddl.live import TerminalDashboard\n\n task = build_task(\"domain.pddl\", \"problem.pddl\")\n solve_task(task, \"astar\", \"lmcut\", observer=TerminalDashboard())\n\nOn a non-interactive stream it degrades to a periodic one-line progress report,\nso it is also safe to use in CI logs and notebooks.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nimport shutil\nimport sys\nimport time\n\nfrom .trace import SearchObserver\n\nSPARKS = \"\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\"\nGAUGE_FULL = \"\u2588\"\nGAUGE_EMPTY = \"\u2591\"\n\n# 256-colour ANSI approximations of the jupyddl palette slots.\n_ANSI = {\n \"blue\": \"\\x1b[38;5;33m\",\n \"orange\": \"\\x1b[38;5;208m\",\n \"aqua\": \"\\x1b[38;5;36m\",\n \"yellow\": \"\\x1b[38;5;178m\",\n \"green\": \"\\x1b[38;5;34m\",\n \"muted\": \"\\x1b[38;5;245m\",\n \"bold\": \"\\x1b[1m\",\n \"reset\": \"\\x1b[0m\",\n}\n\n__all__ = [\"TerminalDashboard\", \"sparkline\"]\n\n\ndef sparkline(values, width: int = 40) -> str:\n \"\"\"Render ``values`` as a Unicode sparkline of at most ``width`` cells.\"\"\"\n values = [v for v in values if v is not None and not math.isinf(v)]\n if not values:\n return \"\"\n if len(values) > width: # keep the shape, drop the resolution\n bucket = len(values) / width\n values = [values[min(len(values) - 1, int(i * bucket))] for i in range(width)]\n low, high = min(values), max(values)\n if high == low:\n return SPARKS[3] * len(values)\n span = high - low\n return \"\".join(SPARKS[min(7, int((v - low) / span * 7.999))] for v in values)\n\n\ndef _gauge(fraction: float, width: int = 16) -> str:\n fraction = min(1.0, max(0.0, fraction))\n filled = int(round(fraction * width))\n return GAUGE_FULL * filled + GAUGE_EMPTY * (width - filled)\n\n\ndef _fmt(value) -> str:\n if value is None:\n return \"\u2013\"\n value = int(value)\n if value >= 1_000_000:\n return f\"{value / 1_000_000:.1f}M\"\n if value >= 10_000:\n return f\"{value / 1000:.1f}k\"\n return f\"{value:,}\"\n\n\nclass TerminalDashboard(SearchObserver):\n \"\"\"Repaint a live view of the search in the terminal.\n\n ``interval`` throttles repaints (seconds); ``history`` bounds the sparkline\n buffers so a multi-million-node search still costs constant memory.\n \"\"\"\n\n def __init__(\n self,\n stream=None,\n interval: float = 0.08,\n history: int = 400,\n color: bool = True,\n ):\n self.stream = stream if stream is not None else sys.stderr\n self.interval = interval\n self.history = history\n self.interactive = bool(getattr(self.stream, \"isatty\", lambda: False)())\n self.color = color and self.interactive\n\n self._h: list = []\n self._f: list = []\n self._open: list = []\n self._peak_open = 1\n self._best_h = math.inf\n self._start = time.perf_counter()\n self._last_paint = 0.0\n self._lines = 0\n self._title = \"jupyddl\"\n self._latest = None\n self._bounds = 0\n\n # ------------------------------------------------------------- painting\n def _c(self, key: str, text: str) -> str:\n if not self.color:\n return text\n return f\"{_ANSI[key]}{text}{_ANSI['reset']}\"\n\n def _width(self) -> int:\n try:\n return max(48, min(shutil.get_terminal_size().columns - 2, 100))\n except Exception: # pragma: no cover - very unusual terminals\n return 72\n\n def _clear(self) -> None:\n if self._lines and self.interactive:\n self.stream.write(f\"\\x1b[{self._lines}A\\x1b[0J\")\n\n def _render(self, final: bool = False) -> None:\n width = self._width()\n spark_width = max(16, width - 30)\n event = self._latest\n expanded = event.expanded if event else 0\n generated = event.generated if event else 0\n evaluated = event.evaluated if event else 0\n elapsed = max(1e-9, time.perf_counter() - self._start)\n rate = expanded / elapsed\n\n current_h = self._h[-1] if self._h else None\n current_f = self._f[-1] if self._f else None\n open_now = self._open[-1] if self._open else 0\n best_h = None if math.isinf(self._best_h) else self._best_h\n\n lines = [\n self._c(\"bold\", self._title),\n self._c(\"muted\", \"\u2500\" * width),\n \" h \"\n + self._c(\"aqua\", sparkline(self._h, spark_width))\n + self._c(\"muted\", f\" now {_num(current_h)} best {_num(best_h)}\"),\n \" f \"\n + self._c(\"blue\", sparkline(self._f, spark_width))\n + self._c(\"muted\", f\" now {_num(current_f)}\"),\n \" frontier \"\n + self._c(\"orange\", _gauge(open_now / max(1, self._peak_open), 18))\n + self._c(\"muted\", f\" {_fmt(open_now)} (peak {_fmt(self._peak_open)})\"),\n \" \"\n + self._c(\"bold\", _fmt(expanded))\n + self._c(\"muted\", \" expanded \")\n + self._c(\"bold\", _fmt(generated))\n + self._c(\"muted\", \" generated \")\n + self._c(\"bold\", _fmt(evaluated))\n + self._c(\"muted\", \" evaluated\"),\n self._c(\"muted\", f\" {_fmt(rate)} nodes/s \u00b7 {elapsed:.2f}s\")\n + (self._c(\"muted\", f\" \u00b7 {self._bounds} bounds\") if self._bounds else \"\"),\n ]\n self._clear()\n self.stream.write(\"\\n\".join(lines) + \"\\n\")\n self.stream.flush()\n self._lines = len(lines)\n\n def _maybe_paint(self) -> None:\n now = time.perf_counter()\n if now - self._last_paint < self.interval:\n return\n self._last_paint = now\n if self.interactive:\n self._render()\n else:\n # Non-tty: a single appended line, much less often.\n if now - self._start > 1 and int(now) % 2 == 0:\n event = self._latest\n if event is not None:\n self.stream.write(\n f\" ... {_fmt(event.expanded)} expanded, \"\n f\"{_fmt(event.generated)} generated, \"\n f\"{now - self._start:.1f}s\\n\"\n )\n self.stream.flush()\n\n # -------------------------------------------------------- observer hooks\n def on_start(self, task, planner: str, heuristic: str = \"\") -> None:\n self._start = time.perf_counter()\n label = f\"{planner}/{heuristic}\" if heuristic else planner\n name = getattr(task, \"name\", \"\") or \"task\"\n facts = getattr(task, \"num_facts\", 0)\n operators = len(getattr(task, \"operators\", ()))\n self._title = (\n f\"jupyddl \u00b7 {label} \u00b7 {name} \"\n f\"({facts} facts, {operators} ground actions)\"\n )\n if self.interactive:\n self._render()\n\n def on_expand(\n self,\n state,\n g: float = 0.0,\n h: float = 0.0,\n f: float = 0.0,\n depth: int = 0,\n open_size: int = 0,\n stats=None,\n parent=None,\n action: str = \"\",\n ) -> None:\n self._latest = stats\n if h is not None and not math.isinf(h):\n self._h.append(h)\n self._best_h = min(self._best_h, h)\n self._f.append(f if f else g + (h or 0))\n self._open.append(open_size)\n self._peak_open = max(self._peak_open, open_size)\n for buffer in (self._h, self._f, self._open):\n if len(buffer) > self.history:\n del buffer[: len(buffer) - self.history]\n self._maybe_paint()\n\n def on_bound(self, threshold: float, iteration: int, stats=None) -> None:\n self._bounds += 1\n self._latest = stats or self._latest\n\n def on_finish(self, result) -> None:\n self._latest = getattr(result, \"stats\", None) or self._latest\n if self.interactive:\n self._render(final=True)\n elapsed = time.perf_counter() - self._start\n if result is not None and getattr(result, \"solved\", False):\n verdict = self._c(\n \"green\",\n f\" \u2714 solved \u00b7 cost {result.cost} \u00b7 \"\n f\"{result.plan_length} actions \u00b7 {elapsed:.2f}s\",\n )\n else:\n verdict = self._c(\"orange\", f\" \u2718 no plan found \u00b7 {elapsed:.2f}s\")\n self.stream.write(verdict + \"\\n\")\n self.stream.flush()\n self._lines = 0\n\n\ndef _num(value) -> str:\n if value is None:\n return \"\u2013\"\n if isinstance(value, float):\n if math.isinf(value):\n return \"\u221e\"\n if value.is_integer():\n return str(int(value))\n return f\"{value:.1f}\"\n return str(value)\n", "jupyddl/parser/__init__.py": "\"\"\"PDDL parsing: tokenizer, AST and recursive-descent parser.\"\"\"\n\nfrom .ast import (\n Action,\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n Conjunct,\n ConjunctiveEffect,\n DelEffect,\n DerivedPredicate,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n Function,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Predicate,\n Problem,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\nfrom .parser import (\n parse,\n parse_condition,\n parse_domain,\n parse_domain_file,\n parse_effect,\n parse_problem,\n parse_problem_file,\n)\nfrom .tokenizer import tokenize\n\n__all__ = [\n \"Action\",\n \"AddEffect\",\n \"And\",\n \"Arithmetic\",\n \"Atom\",\n \"Comparison\",\n \"Conjunct\",\n \"ConjunctiveEffect\",\n \"DelEffect\",\n \"DerivedPredicate\",\n \"Domain\",\n \"EqualityConstraint\",\n \"Exists\",\n \"Forall\",\n \"ForallEffect\",\n \"FluentRef\",\n \"Function\",\n \"IncreaseCostEffect\",\n \"Literal\",\n \"Number\",\n \"NumericEffect\",\n \"Or\",\n \"PDDLError\",\n \"Predicate\",\n \"Problem\",\n \"Truth\",\n \"UnsupportedFeatureError\",\n \"WhenEffect\",\n \"parse\",\n \"parse_condition\",\n \"parse_domain\",\n \"parse_effect\",\n \"parse_problem\",\n \"parse_domain_file\",\n \"parse_problem_file\",\n \"tokenize\",\n]\n", "jupyddl/parser/ast.py": "\"\"\"Structured AST for PDDL.\n\nConditions are stored as a **formula tree in negation normal form**: the parser\npushes every ``not`` down to the atoms and rewrites ``imply``, so the grounder\nonly ever sees negation applied to a literal. Quantifiers stay in the tree\nbecause expanding them needs the object pool, which only exists at grounding\ntime; the grounder then distributes the formula into DNF and emits one operator\nper disjunct.\n\nNumeric fluents and durative actions have their own small node types. See\n:mod:`jupyddl.requirements` for exactly which PDDL requirement flags are\nsupported and how.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\nclass PDDLError(Exception):\n \"\"\"Base class for all parsing / modelling errors.\"\"\"\n\n\nclass UnsupportedFeatureError(PDDLError):\n \"\"\"Raised when a PDDL construct outside the supported subset is used.\"\"\"\n\n\n@dataclass(frozen=True)\nclass Atom:\n \"\"\"A (possibly lifted) predicate application, e.g. ``(on ?x ?y)``.\n\n ``args`` holds terms as raw strings: variables keep their leading ``?``\n while constants/objects are stored verbatim.\n \"\"\"\n\n predicate: str\n args: tuple = ()\n\n def __str__(self) -> str:\n if not self.args:\n return f\"({self.predicate})\"\n return f\"({self.predicate} {' '.join(self.args)})\"\n\n\n@dataclass(frozen=True)\nclass Literal:\n \"\"\"A positive or negative atom used in preconditions and goals.\"\"\"\n\n atom: Atom\n positive: bool = True\n\n\n@dataclass(frozen=True)\nclass EqualityConstraint:\n \"\"\"An ``(= a b)`` (or its negation) constraint over terms.\"\"\"\n\n left: str\n right: str\n positive: bool = True\n\n\n# --- numeric expressions -----------------------------------------------------\n\n\n@dataclass(frozen=True)\nclass Number:\n \"\"\"A numeric literal.\"\"\"\n\n value: float\n\n\n@dataclass(frozen=True)\nclass FluentRef:\n \"\"\"A reference to a numeric fluent, e.g. ``(fuel ?truck)``.\"\"\"\n\n name: str\n args: tuple = ()\n\n def __str__(self) -> str:\n if not self.args:\n return f\"({self.name})\"\n return f\"({self.name} {' '.join(self.args)})\"\n\n\n@dataclass(frozen=True)\nclass Arithmetic:\n \"\"\"A binary arithmetic expression (``+``, ``-``, ``*``, ``/``).\n\n Unary minus is parsed as ``(- 0 x)``.\n \"\"\"\n\n op: str\n left: object\n right: object\n\n\n@dataclass(frozen=True)\nclass Comparison:\n \"\"\"A numeric comparison used in preconditions and goals.\"\"\"\n\n op: str # one of < <= = >= >\n left: object\n right: object\n\n\n# --- condition formulas (negation normal form) -------------------------------\n\n\n@dataclass(frozen=True)\nclass Truth:\n \"\"\"The constant ``true`` \u2014 what an empty ``(and)`` parses to.\"\"\"\n\n value: bool = True\n\n\n@dataclass(frozen=True)\nclass And:\n parts: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Or:\n parts: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Exists:\n params: tuple = () # ((var, type), ...)\n body: object = None\n\n\n@dataclass(frozen=True)\nclass Forall:\n params: tuple = () # ((var, type), ...)\n body: object = None\n\n\n@dataclass\nclass Conjunct:\n \"\"\"One ground DNF disjunct: a conjunction of literals and comparisons.\n\n Produced by the grounder, not by the parser. ``equalities`` are resolved\n during instantiation and never survive into a grounded operator.\n \"\"\"\n\n literals: list = field(default_factory=list)\n equalities: list = field(default_factory=list)\n comparisons: list = field(default_factory=list)\n\n\n# --- effects -----------------------------------------------------------------\n\n\n@dataclass\nclass AddEffect:\n atom: Atom\n\n\n@dataclass\nclass DelEffect:\n atom: Atom\n\n\n@dataclass\nclass IncreaseCostEffect:\n \"\"\"``(increase (total-cost) k)`` \u2014 the classical action-cost shorthand.\"\"\"\n\n amount: float\n\n\n@dataclass\nclass NumericEffect:\n \"\"\"An assignment to a numeric fluent.\n\n ``op`` is one of ``assign``, ``increase``, ``decrease``, ``scale-up`` or\n ``scale-down``.\n \"\"\"\n\n op: str\n target: FluentRef\n value: object # an arithmetic expression\n\n\n@dataclass\nclass ConjunctiveEffect:\n parts: list = field(default_factory=list)\n\n\n@dataclass\nclass ForallEffect:\n params: list # [(variable, type), ...]\n body: object\n\n\n@dataclass\nclass WhenEffect:\n condition: object # a condition formula\n body: object\n\n\n# --- domain / problem --------------------------------------------------------\n\n\n@dataclass\nclass Predicate:\n name: str\n params: list # [(variable, type), ...]\n\n\n@dataclass\nclass Function:\n \"\"\"A declared numeric function (``:functions``).\"\"\"\n\n name: str\n params: list = field(default_factory=list)\n\n\n@dataclass\nclass Action:\n name: str\n parameters: list # [(variable, type), ...]\n precondition: object # a condition formula\n effect: object # one of the *Effect nodes above\n # Set when the action came from a (:durative-action ...) block; the value is\n # its duration, and the compilation is documented in jupyddl.requirements.\n duration: object = None\n\n\n@dataclass\nclass DerivedPredicate:\n \"\"\"A ``(:derived (head ?x) body)`` axiom.\"\"\"\n\n head: Atom\n params: list # [(variable, type), ...]\n body: object # a condition formula\n\n\n@dataclass\nclass Domain:\n name: str\n requirements: list\n types: dict # child type -> parent type (\"object\" if none)\n constants: list # [(name, type), ...]\n predicates: list\n actions: list\n functions: list = field(default_factory=list)\n derived: list = field(default_factory=list)\n object_fluents: list = field(default_factory=list)\n constraints: list = field(default_factory=list)\n\n @property\n def has_durative_actions(self) -> bool:\n return any(action.duration is not None for action in self.actions)\n\n\n@dataclass\nclass Problem:\n name: str\n domain_name: str\n objects: list # [(name, type), ...]\n init: list # [Atom, ...]\n goal: object # a condition formula\n metric_minimize_cost: bool = False\n init_numeric: dict = field(default_factory=dict) # FluentRef -> float\n metric: object = None # (direction, expression) or None\n preferences: list = field(default_factory=list) # [Preference, ...]\n constraints: list = field(default_factory=list) # [Constraint | Preference]\n timed_initials: list = field(default_factory=list) # [TimedInitial, ...]\n init_objects: dict = field(default_factory=dict) # object-fluent assignments\n violation_weights: dict = field(default_factory=dict) # preference -> weight\n\n\n# --- PDDL 3: preferences and trajectory constraints --------------------------\n\n\n@dataclass(frozen=True)\nclass Preference:\n \"\"\"A named soft goal or soft constraint.\n\n ``body`` is either a condition formula (a goal preference) or a\n :class:`Constraint` (a soft trajectory constraint). Violating it is legal;\n the ``:metric`` says what it costs.\n \"\"\"\n\n name: str\n body: object\n\n\n@dataclass(frozen=True)\nclass Constraint:\n \"\"\"One state-trajectory constraint from a ``(:constraints ...)`` block.\n\n ``kind`` is the PDDL modal operator (``always``, ``sometime``, ``at-end``,\n ``at-most-once``, ``sometime-before``, ``sometime-after``) and ``args`` holds\n its operand formulas, already in negation normal form.\n \"\"\"\n\n kind: str\n args: tuple = ()\n\n\n@dataclass(frozen=True)\nclass TimedInitial:\n \"\"\"A ``(at