diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..069ce85 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503 +exclude = .venv, pddl-examples, build, dist, .git, __pycache__ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ceb627..e18af85 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,40 +6,24 @@ jobs: build: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - python-version: [3.6, 3.7, 3.8] - exclude: - - os: macos-latest - python-version: 3.8 + os: [ubuntu-latest] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v3 + - uses: actions/checkout@v4 with: - python-version: 3.7 - - uses: actions/checkout@v2.4.0 - - uses: julia-actions/setup-julia@v1 + submodules: recursive + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - version: '1.4.1' - - name: Install Julia dependencies - run: | - julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/APLA-Toolbox/PDDL.jl"))' - julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/JuliaPy/PyCall.jl"))' - - name: Install Python dependencies + python-version: ${{ matrix.python-version }} + - name: Install package (dev extras) run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install -e ".[dev]" - name: Lint with flake8 run: | - python -m pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Checkout reposistory - uses: actions/checkout@master - - name: Checkout submodules - uses: snickerbockers/submodules-init@v4 - + flake8 jupyddl tests --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 jupyddl tests --count --statistics diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 76c926e..1206e25 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -6,23 +6,22 @@ jobs: format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v3 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: '3.12' - name: Install formatter dependencies run: | python -m pip install --upgrade pip python -m pip install black - name: Format with black run: | - black . + black jupyddl tests - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v4.14.0 + uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Apply formatting changes branch: main diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1557cb5..63faae4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,91 +3,34 @@ name: tests on: [push] jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - python-version: [3.6, 3.7, 3.8] - exclude: - - os: macos-latest - python-version: 3.8 - - steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v3 - with: - python-version: 3.7 - - uses: actions/checkout@v2.4.0 - - uses: julia-actions/setup-julia@v1 - with: - version: '1.4.1' - - name: Install Julia dependencies - run: | - julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/APLA-Toolbox/PDDL.jl"))' - julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/JuliaPy/PyCall.jl"))' - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - python -m pip install julia - python -m pip install pycall - - name: Lint with flake8 - run: | - python -m pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Checkout reposistory - uses: actions/checkout@master - - name: Checkout submodules - uses: snickerbockers/submodules-init@v4 - test: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - python-version: [3.6, 3.7, 3.8] - exclude: - - os: macos-latest - python-version: 3.8 + os: [ubuntu-latest] + python-version: ['3.9', '3.10', '3.11', '3.12'] + steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v3 + - uses: actions/checkout@v4 with: - python-version: 3.7 - - uses: actions/checkout@v2.4.0 - - uses: julia-actions/setup-julia@v1 + submodules: recursive + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - version: '1.4.1' - - name: Install Julia dependencies - run: | - julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/JuliaPy/PyCall.jl"))' - julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/APLA-Toolbox/PDDL.jl"))' - - name: Install Python dependencies + python-version: ${{ matrix.python-version }} + - name: Install package (dev extras) run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install -e ".[dev]" - name: Lint with flake8 run: | - python -m pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Checkout reposistory - uses: actions/checkout@master - - name: Checkout submodules - uses: snickerbockers/submodules-init@v4 + flake8 jupyddl tests --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 jupyddl tests --count --statistics - name: Test with pytest run: | - pip install pytest - pip install pytest-cov - pytest --cov=./ + pytest --cov=jupyddl --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 with: name: codecov-umbrella diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ebd744e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +`jupyddl` is a **pure-Python** PDDL planning framework (parser, grounder, +planners, heuristics, benchmarking). The Julia/`PDDL.jl`/PyCall integration has +been removed — there is no Julia, no native build step, and the core has zero +runtime dependencies. + +### Environment +- Python ≥ 3.9; a `.venv` (created with `uv`, Python 3.12) with an editable + install: `uv pip install -e ".[dev]"` (add `viz` for matplotlib-based + benchmark plots). `.venv` and `pddl-examples/` contents are git-ignored. +- The `pddl-examples` git submodule supplies the domains/problems the tests use; + it must be initialised (`git submodule update --init`). + +### Running / testing / linting (use the venv interpreter) +- Tests: `.venv/bin/python -m pytest` (add `--cov=jupyddl`). +- Lint (as CI): `flake8 jupyddl tests` (config in `.flake8`, max-line 100). +- CLI: `.venv/bin/python -m jupyddl.cli solve -s astar -H lmcut` + or `... benchmark pddl-examples --csv out.csv`. Installed as `jupyddl` too. + +### Non-obvious notes +- **Example data quirks (external submodule, do not "fix" in this repo):** + `grid` uses numeric fluents and is intentionally unsupported (raises + `UnsupportedFeatureError`); `vehicle` has typos in its problem file + (`struck`/`truck`, `acessible`) so its goal is unreachable and it is correctly + reported unsolvable. Tests treat both as expected. +- **Conditional effects (`flip`)**: the delete-relaxation heuristics (`hadd`, + `hff`, `lmcut`, `h^m`) are *not guaranteed admissible* on domains with + conditional effects because each conditional effect is relaxed into its own + operator. For guaranteed-optimal plans there use `bfs`, `dijkstra`, or + `astar`/`idastar` with the `blind` heuristic. Optimality tests use these. +- **Matplotlib is optional**: only `jupyddl.benchmark.plot_summary` (and + `jupyddl solve/benchmark --plot`) need it; run headless with `MPLBACKEND=Agg` + if no display. The test suite does not require it. +- Extend via the registries: `jupyddl.search.PLANNERS` and + `jupyddl.heuristics.HEURISTICS`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f142f63 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project are documented in this file. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-07-01 + +Complete rewrite: **the Julia dependency is removed** and the project is now a +pure-Python planning framework. + +### Added +- Hand-written PDDL tokenizer, AST and recursive-descent parser + (`jupyddl.parser`). +- Grounder (`jupyddl.grounding`) with type hierarchies, static-predicate + pruning, positive-normal-form compilation of negative preconditions/goals, + object harvesting for undeclared constants, and `forall`/`when` conditional + effect expansion. +- Grounded task representation with conditional effects (`jupyddl.task`). +- Planners (`jupyddl.search`): BFS, DFS, Iterative Deepening, Dijkstra + (uniform cost), Greedy Best-First, A*, Weighted A*, IDA*, and Enforced Hill + Climbing, plus a shared best-first engine and a planner registry. +- Heuristics (`jupyddl.heuristics`): blind, goal-count, `h_max`, `h_add`, FF, + critical-path `h^m` (`h1`/`h2`), and LM-cut, plus a heuristic registry. +- Benchmarking harness (`jupyddl.benchmark`) with CSV export and comparison + plots, and a CLI (`jupyddl solve` / `jupyddl benchmark`). +- High-level API: `solve`, `build_task`, `solve_task`, `validate_plan`. +- Comprehensive pytest suite covering parsing, grounding, search optimality, + heuristic admissibility, the API, the benchmark harness and the CLI. + +### Changed +- Packaging migrated from `setup.py`/`requirements.txt` to `pyproject.toml` + (hatchling); the core has **zero runtime dependencies** (matplotlib is an + optional `viz` extra). +- CI reworked to run on modern Python without Julia. + +### Removed +- The Julia / `PDDL.jl` / PyCall / pyjulia integration and the old + `AutomatedPlanner` / `DataAnalyst` API. diff --git a/README.md b/README.md index 9a33344..8b9c340 100644 --- a/README.md +++ b/README.md @@ -1,195 +1,147 @@
- -Logo - -
- -
-# Python PDDL +# jupyddl — Python PDDL -✨ A Python wrapper using JuliaPy for the PDDL.jl parser package and implementing its own planners. ✨ +✨ A dependency-free, pure-Python PDDL planning framework: parser, grounder, +classical-to-SOTA planners, heuristics and a benchmarking harness. ✨
- + ![tests](https://github.com/APLA-Toolbox/PythonPDDL/workflows/tests/badge.svg?branch=main) ![build](https://github.com/APLA-Toolbox/PythonPDDL/workflows/build/badge.svg?branch=main) -[![codecov](https://codecov.io/gh/APLA-Toolbox/PythonPDDL/branch/main/graph/badge.svg?token=63GHA9JUND)](https://codecov.io/gh/APLA-Toolbox/PythonPDDL) -[![CodeFactor](https://www.codefactor.io/repository/github/apla-toolbox/pythonpddl/badge)](https://www.codefactor.io/repository/github/apla-toolbox/pythonpddl) -[![Percentage of issues still open](http://isitmaintained.com/badge/open/APLA-Toolbox/PythonPDDL.svg)](http://isitmaintained.com/project/APLA-Toolbox/PythonPDDL "Percentage of issues still open") -[![GitHub license](https://img.shields.io/github/license/Apla-Toolbox/PythonPDDL.svg)](https://github.com/Apla-Toolbox/PythonPDDL/blob/master/LICENSE) -[![GitHub contributors](https://img.shields.io/github/contributors/Apla-Toolbox/PythonPDDL.svg)](https://GitHub.com/Apla-Toolbox/PythonPDDL/graphs/contributors/) -![PipPerMonths](https://img.shields.io/pypi/dm/jupyddl.svg) -[![Pip version fury.io](https://badge.fury.io/py/jupyddl.svg)](https://pypi.python.org/pypi/jupyddl/) +[![GitHub license](https://img.shields.io/github/license/Apla-Toolbox/PythonPDDL.svg)](./LICENSE)
-
- -[Report Bug](https://github.com/APLA-Toolbox/PythonPDDL/issues) · [Request Feature](https://github.com/APLA-Toolbox/PythonPDDL/issues) - -Loved the project? Please consider [donating](https://www.buymeacoffee.com/dq01aOE) to help it improve! - -
+`jupyddl` used to be a thin wrapper around the Julia `PDDL.jl` parser. It has been +**rewritten from scratch as a pure-Python framework** — no Julia, no native +dependencies, just the standard library — so it is trivial to install, embed and +build on top of for research and teaching. ## Features 🌱 -- ✨ Built to be expanded: easy to add new planners -- 🖥️ Supported on MacOS and Ubuntu -- 🎌 Built with Julia and Python -- 🔎 Uninformed Planners (DFS, BFS) -- 🧭 Informed Planners (Dijkstra, A*, Greedy Best First) -- 📊 Several general purpose heuristics (Goal Count, Delete Relaxation [Hmax, Hadd], Critical Path [H1, H2, H3], Relaxed Critical Path [H1, H2, H3]) -- 🍻 Maintained (Incoming: Landmarks Heuristics...) - -## Docker 🐋 - -You can also use the project in a docker container using [docker-pythonpddl](https://github.com/APLA-Toolbox/docker-pythonpddl) +- 🧩 **Hand-written PDDL parser** and grounder (typing, negative preconditions, + equality, action costs, and `forall`/`when` conditional effects). +- 🔎 **Uninformed planners**: BFS, DFS, Iterative Deepening. +- 🧭 **Informed planners**: Dijkstra (uniform cost), Greedy Best-First, A*, + Weighted A*, IDA*, and Enforced Hill Climbing (FF-style). +- 📊 **Heuristics from classical to SOTA**: blind, goal-count, `h_max`, `h_add`, + FF (`h_ff`), critical-path `h^m` (`h1`/`h2`), and **LM-cut**. +- ⚖️ **Benchmarking harness** for comparative analysis with CSV export and plots. +- 🧱 **Extensible by design**: planners and heuristics live behind simple + registries; add your own in a few lines. +- ✅ **Zero runtime dependencies** and a comprehensive test suite. ## Install 💾 -- Install Python (3.7.5 is the tested version) - -- Install Julia +Requires Python ≥ 3.9. Using [uv](https://docs.astral.sh/uv/) (recommended): ```bash -$ wget https://julialang-s3.julialang.org/bin/linux/x64/1.5/julia-1.5.2-linux-x86_64.tar.gz -$ tar -xvzf julia-1.5.2-linux-x86_64.tar.gz -$ sudo cp -r julia-1.5.2 /opt/ -$ sudo ln -s /opt/julia-1.5.2/bin/julia /usr/local/bin/julia +uv venv +uv pip install -e ".[dev,viz]" # 'viz' pulls matplotlib for benchmark plots ``` -- Install Julia dependencies +or with plain pip: ```bash -$ julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/APLA-Toolbox/PDDL.jl"))' -$ julia --color=yes -e 'using Pkg; Pkg.add(Pkg.PackageSpec(path="https://github.com/JuliaPy/PyCall.jl"))' +python -m pip install -e ".[dev,viz]" ``` -- Package installation (only if used as library, not needed to run the scripts) +## Command line ⚔️ ```bash -$ python3 -m pip install --upgrade pip -$ python3 -m pip install jupyddl -``` - -## IPC Script ⚔️ - -- Clone the project : -```shell -$ git clone https://github.com/APLA-Toolbox/PythonPDDL -$ cd PythonPDDL -$ python3 -m pip install -r requirements.txt -$ git submodule update --init // Only if you need PDDL files for testing -``` - -- Run the script : -```shell -$ cd scripts/ -$ python ipc.py "path_to_domain.pddl" "path_to_problem.pddl" "path_to_desired_output_file" -``` - -The output file will show the path with a list of state, the path with a list of action and the metrics proposed by IPC2018. - -## Basic Usage 📑 - -If using the jupyddl pip package: - -- If you want to use the data analysis tool, create a pddl-examples folder with pddl instances subfolders containing "problem.pddl" and "domain.pddl". (refer to APLA-Toolbox/pddl-examples) - -If you want to use it by cloning the project: - -```shell -$ git clone https://github.com/APLA-Toolbox/PythonPDDL -$ cd PythonPDDL -$ python3 -m pip install -r requirements.txt -$ git submodule update --init +# Solve a single instance +jupyddl solve pddl-examples/tsp/domain.pddl pddl-examples/tsp/problem.pddl \ + --search astar --heuristic lmcut + +# Compare planners over a folder of /{domain,problem}.pddl instances +jupyddl benchmark pddl-examples \ + --planners bfs,dijkstra,astar,gbfs,ehc --heuristic hff \ + --csv results.csv --plot comparison.png ``` -You should have a `pddl-examples` folder containing PDDL instances. - -### AutomatedPlanner Class 🗺️ +## Library usage 📑 ```python -from jupyddl import AutomatedPlanner # takes some time because it has to instantiate the Julia interface -apl = AutomatedPlanner("pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl) - -apl.available_heuristics -["basic/zero", "basic/goal_count", "delete_relaxation/h_max", "delete_relaxation/h_add"] - -apl.initial_state - - -actions = apl.available_actions(apl.initial_state) -[, , , , , ] - -apl.satisfies(apl.problem.goal, apl.initial_state) -False - -apl.transition(apl.initial_state, actions[0]) - - -path = apl.breadth_first_search() # computes path ([]State) with BFS - -print(apl.get_state_def_from_path(path)) -[, , ] - -print(apl.get_actions_from_path(path)) -[, , ] +from jupyddl import solve, build_task, solve_task, validate_plan + +# One-shot solve +result = solve("pddl-examples/tsp/domain.pddl", + "pddl-examples/tsp/problem.pddl", + search="astar", heuristic="lmcut") +print(result.solved, result.cost, result.plan_names()) + +# Ground once, try several configurations +task = build_task("pddl-examples/blocksworld/domain.pddl", + "pddl-examples/blocksworld/problem.pddl") +for cfg in [("astar", "lmcut"), ("gbfs", "hff"), ("bfs", None)]: + r = solve_task(task, cfg[0], cfg[1]) + assert validate_plan(task, r.plan) + print(cfg, r.cost, r.stats.expanded, "expanded") ``` -### DataAnalyst (more like Viz) Class 📈 +## Available planners & heuristics -Make sure you have a pddl-examples folder where you run your environment that contains independent folders with "domain.pddl" and "problem.pddl" files, with those standard names. ( if you didn't generate with git submodule update ) +| Planners | Heuristics | +|---|---| +| `bfs`, `dfs`, `iddfs`, `dijkstra`, `gbfs`, `astar`, `wastar`, `idastar`, `ehc` | `blind`, `goalcount`, `hmax`, `hadd`, `hff`, `h1`, `h2`/`hm`, `lmcut` | -```python -from jupyddl import DataAnalyst +`A*`/`IDA*` are cost-optimal with an admissible heuristic (`blind`, `hmax`, +`h1`/`h2`, `lmcut`); `dijkstra` and `bfs` are optimal without a heuristic. -da = DataAnalyst() -da.plot_astar() # plots complexity statistics for all the problem.pddl/domain.pddl couples in the pddl-examples/ folder +## Supported PDDL subset -da.plot_astar(problem="pddl-examples/dinner/problem.pddl", domain="pddl-examples/dinner/domain.pddl") # scatter complexity statistics for the provided pddl +STRIPS, `:typing` (with hierarchy), `:negative-preconditions`, `:equality`, +`:action-costs` (`(increase (total-cost) k)`), universally-quantified goals, and +`forall`/`when` conditional effects. Numeric fluents beyond `total-cost` and +`:durative-action`s are out of scope and rejected with a clear error. -da.plot_astar(heuristic_key="basic/zero") # use h=0 instead of goal_count for your computation +> **Note:** on domains with conditional effects (e.g. `flip`), the delete-relaxation +> heuristics (`hadd`, `hff`, `lmcut`, `h^m`) are *satisficing* only — admissibility +> is not guaranteed. Use `bfs`, `dijkstra`, or `astar`/`idastar` with `blind` for +> guaranteed-optimal plans on such domains. -da.plot_dfs() # same as astar +## Architecture 🏗️ -da.comparative_data_plot() # Run all planners on the pddl-examples folder and plots them on the same figure, data is stored in a data.json file - -da.comparative_data_plot(astar=False) # Exclude astar from the comparative plot +``` +jupyddl/ + parser/ tokenizer + AST + recursive-descent parser + grounding.py Domain+Problem -> grounded Task (typing, PNF, static pruning) + task.py grounded STRIPS(+conditional-effects) task & operators + search/ planners + shared best-first engine + registry + heuristics/ heuristics + delete-relaxation machinery + registry + benchmark.py comparative benchmarking (CSV + plots) + cli.py `jupyddl solve` / `jupyddl benchmark` +``` -da.comparative_data_plot(heuristic_key="basic/zero") # use zero heuristic for h based planners +Add a planner by subclassing `jupyddl.search.Planner` (or reusing `best_first`) +and registering it in `jupyddl.search.PLANNERS`; add a heuristic by subclassing +`jupyddl.heuristics.Heuristic` and registering it in +`jupyddl.heuristics.HEURISTICS`. -da.comparative_data_plot(collect_new_data=False) # uses data.json to plot the data +## Development 🛠️ -da.comparative_astar_heuristic_plot() # compare results of astar with all available heuristics +```bash +git submodule update --init # fetch the pddl-examples used by the tests +uv pip install -e ".[dev,viz]" +pytest --cov=jupyddl # run the test suite +flake8 jupyddl tests # lint ``` ## Cite 📰 -If you use the project in your work, please consider citing it with: ``` @misc{https://doi.org/10.13140/rg.2.2.22418.89282, doi = {10.13140/RG.2.2.22418.89282}, url = {http://rgdoi.net/10.13140/RG.2.2.22418.89282}, author = {Erwin Lejeune}, - language = {en}, - title = {Jupyddl, an extensible python library for PDDL planning and parsing}, - publisher = {Unpublished}, + title = {Jupyddl, an extensible python library for PDDL planning and parsing}, year = {2021} } ``` -List of publications & preprints using `jupyddl` (please open a pull request to add missing entries): - -* [name](link) (month year) - -## Contribute 🆘 - -Please see `docs/CONTRIBUTING.md` for more details on contributing! - ## Maintainers Ⓜ️ - Erwin Lejeune diff --git a/jupyddl/__init__.py b/jupyddl/__init__.py index 0158c4c..fa2933a 100644 --- a/jupyddl/__init__.py +++ b/jupyddl/__init__.py @@ -1,2 +1,40 @@ -from .automated_planner import AutomatedPlanner -from .data_analyst import DataAnalyst +"""jupyddl: a pure-Python PDDL planning framework. + +Quickstart:: + + from jupyddl import solve, build_task, validate_plan + + result = solve("domain.pddl", "problem.pddl", search="astar", heuristic="lmcut") + print(result.solved, result.cost, result.plan_names()) +""" + +from __future__ import annotations + +from .api import build_task, solve, solve_task, validate_plan +from .grounding import ground, ground_files +from .heuristics import HEURISTICS, make_heuristic +from .parser import PDDLError, UnsupportedFeatureError, parse +from .search import PLANNERS, SearchResult, make_planner +from .task import Operator, Task + +__version__ = "1.0.0" + +__all__ = [ + "solve", + "solve_task", + "build_task", + "validate_plan", + "ground", + "ground_files", + "make_planner", + "make_heuristic", + "PLANNERS", + "HEURISTICS", + "SearchResult", + "Task", + "Operator", + "parse", + "PDDLError", + "UnsupportedFeatureError", + "__version__", +] diff --git a/jupyddl/a_star.py b/jupyddl/a_star.py deleted file mode 100644 index e518a9d..0000000 --- a/jupyddl/a_star.py +++ /dev/null @@ -1,100 +0,0 @@ -from .node import Node -import logging -import math -from time import time as now -from datetime import datetime as timestamp -from .metrics import Metrics - - -class AStarBestFirstSearch: - def __init__(self, automated_planner, heuristic_function): - self.time_start = now() - self.automated_planner = automated_planner - self.metrics = Metrics() - self.init = Node( - self.automated_planner.initial_state, - automated_planner, - is_closed=False, - is_open=True, - heuristic=heuristic_function, - heuristic_based=True, - metric=self.metrics, - ) - self.heuristic_function = heuristic_function - self.open_nodes_n = 1 - self.nodes = dict() - self.nodes[self.__hash(self.init)] = self.init - - def __hash(self, node): - sep = ", Dict{Symbol,Any}" - string = str(node.state) - return string.split(sep, 1)[0] + ")" - - def search(self, node_bound=float("inf")): - self.automated_planner.logger.debug( - "Search started at: " + str(timestamp.now()) - ) - while self.open_nodes_n > 0: - current_key = min( - [n for n in self.nodes if self.nodes[n].is_open], - key=(lambda k: self.nodes[k].f_cost), - ) - current_node = self.nodes[current_key] - - self.metrics.n_evaluated += 1 - if self.automated_planner.satisfies( - self.automated_planner.problem.goal, current_node.state - ): - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.debug( - "Search finished at: " + str(timestamp.now()) - ) - return current_node, self.metrics - - current_node.is_closed = True - current_node.is_open = False - self.open_nodes_n -= 1 - - if self.metrics.n_opened > node_bound: - break - - actions = self.automated_planner.available_actions(current_node.state) - if actions: - self.metrics.n_expended += 1 - else: - self.metrics.deadend_states += 1 - for act in actions: - child = Node( - state=self.automated_planner.transition(current_node.state, act), - automated_planner=self.automated_planner, - parent_action=act, - parent=current_node, - heuristic=self.heuristic_function, - is_closed=False, - is_open=True, - heuristic_based=True, - metric=self.metrics, - ) - self.metrics.n_generated += 1 - child_hash = self.__hash(child) - if child_hash in self.nodes: - if self.nodes[child_hash].is_closed: - continue - if not self.nodes[child_hash].is_open: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - else: - if child.g_cost < self.nodes[child_hash].g_cost: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - self.metrics.n_reopened += 1 - - else: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.warning("!!! No path found !!!") - return None, self.metrics diff --git a/jupyddl/api.py b/jupyddl/api.py new file mode 100644 index 0000000..bc7ed1a --- /dev/null +++ b/jupyddl/api.py @@ -0,0 +1,48 @@ +"""High-level convenience API: parse + ground + plan + validate.""" + +from __future__ import annotations + +from .grounding import ground_files +from .heuristics import make_heuristic +from .search import make_planner +from .search.result import SearchResult +from .task import Task + + +def build_task(domain_path: str, problem_path: str) -> Task: + """Parse and ground a domain/problem pair into a :class:`Task`.""" + return ground_files(domain_path, problem_path) + + +def solve_task( + task: Task, search: str = "astar", heuristic=None, **planner_kwargs +) -> SearchResult: + """Run ``search`` (optionally with ``heuristic``) on an already-ground task.""" + planner = make_planner(search, **planner_kwargs) + heur = None + name = heuristic if heuristic else ("hff" if planner.requires_heuristic else None) + if name is not None: + heur = make_heuristic(name, task) + return planner.search(task, heur) + + +def solve( + domain_path: str, + problem_path: str, + search: str = "astar", + heuristic="lmcut", + **planner_kwargs, +) -> SearchResult: + """Parse, ground and solve a PDDL instance in one call.""" + task = build_task(domain_path, problem_path) + return solve_task(task, search=search, heuristic=heuristic, **planner_kwargs) + + +def validate_plan(task: Task, plan) -> bool: + """Return ``True`` iff applying ``plan`` from the initial state reaches the goal.""" + state = task.init + for op in plan: + if not op.applicable(state): + return False + state = op.apply(state) + return task.goal_reached(state) diff --git a/jupyddl/automated_planner.py b/jupyddl/automated_planner.py deleted file mode 100644 index 71f7b5c..0000000 --- a/jupyddl/automated_planner.py +++ /dev/null @@ -1,213 +0,0 @@ -from .bfs import BreadthFirstSearch -from .dfs import DepthFirstSearch -from .dijkstra import DijkstraBestFirstSearch -from .a_star import AStarBestFirstSearch -from .greedy_best_first import GreedyBestFirstSearch -from .metrics import Metrics -from .heuristics import ( - BasicHeuristic, - DeleteRelaxationHeuristic, - RelaxedCriticalPathHeuristic, - CriticalPathHeuristic, -) -import coloredlogs -import logging -import julia - -_ = julia.Julia(compiled_modules=False, debug=False) -from julia import PDDL -from time import time as now - -logging.getLogger("julia").setLevel(logging.WARNING) - - -class AutomatedPlanner: - def __init__(self, domain_path, problem_path, log_level="DEBUG"): - # Planning Tool - self.pddl = PDDL - self.domain_path = domain_path - self.problem_path = problem_path - self.domain = self.pddl.load_domain(domain_path) - self.problem = self.pddl.load_problem(problem_path) - self.initial_state = self.pddl.initialize(self.problem) - self.goals = self.__flatten_goal() - self.available_heuristics = [ - "basic/zero", - "basic/goal_count", - "delete_relaxation/h_add", - "delete_relaxation/h_max", - "relaxed_critical_path/1", - "relaxed_critical_path/2", - "relaxed_critical_path/3", - "critical_path/1", - "critical_path/2", - "critical_path/3", - ] - - # Logger - self.__init_logger(log_level) - self.logger = logging.getLogger("automated_planning") - coloredlogs.install(level=log_level) - - # Running external Julia functions once to create the routes - self.__run_julia_once() - - def __run_julia_once(self): - self.satisfies(self.problem.goal, self.initial_state) - self.state_has_term(self.initial_state, self.goals[0]) - actions = self.available_actions(self.initial_state) - if actions: - self.transition(self.initial_state, actions[0]) - return - logging.warning( - "No actions from initial state, a path probably (definitely) won't be found" - ) - - def __init_logger(self, log_level): - import os - - if not os.path.exists("logs"): - os.makedirs("logs") - logging.basicConfig( - filename="logs/main.log", - format="%(levelname)s:%(message)s", - filemode="w", - level=log_level, - ) - - def display_available_heuristics(self): - print(self.available_heuristics) - - def transition(self, state, action): - return self.pddl.transition(self.domain, state, action, check=False) - - def available_actions(self, state): - try: - return self.pddl.available(state, self.domain) - except (RuntimeError, TypeError, NameError): - self.logger.warning( - "Runtime, Type or Name error occured when fetching available action from state" - + str(state) - ) - return [] - - def satisfies(self, asserted_state, state): - return self.pddl.satisfy(asserted_state, state, self.domain)[0] - - def state_has_term(self, state, term): - if self.pddl.has_term_in_state(self.domain, state, term): - return True - return False - - def __flatten_goal(self): - return self.pddl.flatten_goal(self.problem) - - def __retrace_path(self, node): - if not node: - return [] - path = [] - while node.parent: - path.append(node) - node = node.parent - path.reverse() - return path - - def get_actions_from_path(self, path): - if not path: - self.logger.warning("Path is empty, can't operate...") - return [] - actions = [] - for node in path: - if not node.parent_action: - break - act = str(node.parent_action).replace("', "total-cost =" - ) - state_str = state_str.replace("))>", "") - return state_str - - def get_state_def_from_path(self, path): - if not path: - self.logger.warning("Path is empty, can't operate...") - return [] - trimmed_path = [] - for node in path: - state = self.__stringify_state(node.state) - trimmed_path.append(state) - return trimmed_path - - def breadth_first_search(self, node_bound=float("inf")): - bfs = BreadthFirstSearch(self) - last_node, metrics = bfs.search(node_bound=node_bound) - path = self.__retrace_path(last_node) - - return path, metrics - - def depth_first_search(self, node_bound=float("inf")): - dfs = DepthFirstSearch(self) - last_node, metrics = dfs.search(node_bound=node_bound) - path = self.__retrace_path(last_node) - - return path, metrics - - def dijktra_best_first_search(self, node_bound=float("inf")): - dijkstra = DijkstraBestFirstSearch(self) - last_node, metrics = dijkstra.search(node_bound=node_bound) - path = self.__retrace_path(last_node) - - return path, metrics - - def astar_best_first_search( - self, node_bound=float("inf"), heuristic_key="basic/goal_count" - ): - if "basic" in heuristic_key: - heuristic = BasicHeuristic(self, heuristic_key) - elif "delete_relaxation" in heuristic_key: - heuristic = DeleteRelaxationHeuristic(self, heuristic_key) - elif "relaxed_critical_path" in heuristic_key: - heuristic = RelaxedCriticalPathHeuristic(self, int(heuristic_key[-1])) - elif "critical_path" in heuristic_key: - heuristic = CriticalPathHeuristic(self, int(heuristic_key[-1])) - else: - logging.fatal("Not yet implemented") - return [], Metrics() - astar = AStarBestFirstSearch(self, heuristic.compute) - last_node, metrics = astar.search(node_bound=node_bound) - path = self.__retrace_path(last_node) - - return path, metrics - - def greedy_best_first_search( - self, node_bound=float("inf"), heuristic_key="basic/goal_count" - ): - if "basic" in heuristic_key: - if "zero" in heuristic_key: - self.logger.warning( - "Forced heuristic to goal_count. Zero isn't a proper heuristic for Greedy Best First." - ) - heuristic = BasicHeuristic(self, "basic/goal_count") - elif "delete_relaxation" in heuristic_key: - heuristic = DeleteRelaxationHeuristic(self, heuristic_key) - elif "relaxed_critical_path" in heuristic_key: - logging.warning("Relaxed Critical Path is deficient for H^2 and H^3") - heuristic = RelaxedCriticalPathHeuristic(self, int(heuristic_key[-1])) - elif "critical_path" in heuristic_key: - heuristic = CriticalPathHeuristic(self, int(heuristic_key[-1])) - else: - logging.fatal("Not yet implemented") - return [], Metrics() - greedy = GreedyBestFirstSearch(self, heuristic.compute) - last_node, metrics = greedy.search(node_bound=node_bound) - path = self.__retrace_path(last_node) - - return path, metrics diff --git a/jupyddl/benchmark.py b/jupyddl/benchmark.py new file mode 100644 index 0000000..2ebc7a8 --- /dev/null +++ b/jupyddl/benchmark.py @@ -0,0 +1,174 @@ +"""Comparative benchmarking of planners/heuristics over PDDL instances. + +Example:: + + from jupyddl.benchmark import discover_instances, run_benchmark, to_csv + rows = run_benchmark(discover_instances("pddl-examples"), + [("astar", "lmcut"), ("gbfs", "hff")]) + to_csv(rows, "results.csv") +""" + +from __future__ import annotations + +import csv +import glob +import os +from dataclasses import asdict, dataclass +from typing import Optional + +from .api import build_task, solve_task, validate_plan +from .parser import PDDLError + + +@dataclass +class Instance: + name: str + domain: str + problem: str + + +@dataclass +class BenchmarkRow: + instance: str + planner: str + heuristic: str + solved: bool + valid: bool + cost: Optional[int] + plan_length: Optional[int] + expanded: int + generated: int + evaluated: int + runtime: float + error: str = "" + + +def discover_instances(root: str) -> list: + """Find ``/*/`` folders containing both ``domain.pddl`` and ``problem.pddl``.""" + instances = [] + for domain in sorted(glob.glob(os.path.join(root, "*", "domain.pddl"))): + folder = os.path.dirname(domain) + problem = os.path.join(folder, "problem.pddl") + if os.path.exists(problem): + instances.append(Instance(os.path.basename(folder), domain, problem)) + return instances + + +def run_benchmark(instances, configs) -> list: + """Run each ``(planner, heuristic[, kwargs])`` config on each instance. + + ``configs`` entries are ``(planner_name, heuristic_name_or_None)`` or + ``(planner_name, heuristic_name_or_None, planner_kwargs)``. + """ + rows: list = [] + for inst in instances: + try: + task = build_task(inst.domain, inst.problem) + except (PDDLError, ValueError) as exc: + for cfg in configs: + planner, heuristic = cfg[0], (cfg[1] or "") + rows.append( + BenchmarkRow( + inst.name, + planner, + heuristic, + False, + False, + None, + None, + 0, + 0, + 0, + 0.0, + f"{type(exc).__name__}: {exc}", + ) + ) + continue + for cfg in configs: + planner = cfg[0] + heuristic = cfg[1] + kwargs = cfg[2] if len(cfg) > 2 else {} + try: + result = solve_task(task, planner, heuristic, **kwargs) + valid = bool(result.solved and validate_plan(task, result.plan)) + rows.append( + BenchmarkRow( + inst.name, + planner, + heuristic or "", + result.solved, + valid, + result.cost, + result.plan_length, + result.stats.expanded, + result.stats.generated, + result.stats.evaluated, + round(result.stats.runtime, 6), + ) + ) + except Exception as exc: # keep the benchmark going on a single failure + rows.append( + BenchmarkRow( + inst.name, + planner, + heuristic or "", + False, + False, + None, + None, + 0, + 0, + 0, + 0.0, + f"{type(exc).__name__}: {exc}", + ) + ) + return rows + + +def to_csv(rows, path: str) -> None: + fieldnames = ( + list(asdict(rows[0]).keys()) + if rows + else [f.name for f in BenchmarkRow.__dataclass_fields__.values()] + ) + with open(path, "w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(asdict(row)) + + +def summarize(rows) -> dict: + """Aggregate coverage and totals per ``planner/heuristic`` configuration.""" + summary: dict = {} + for row in rows: + key = f"{row.planner}/{row.heuristic}" if row.heuristic else row.planner + agg = summary.setdefault( + key, {"coverage": 0, "expanded": 0, "runtime": 0.0, "instances": 0} + ) + agg["instances"] += 1 + agg["coverage"] += int(row.valid) + agg["expanded"] += row.expanded + agg["runtime"] += row.runtime + return summary + + +def plot_summary(rows, path: str, metric: str = "expanded") -> None: + """Bar chart of a metric per configuration (requires the ``viz`` extra).""" + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + summary = summarize(rows) + labels = list(summary.keys()) + values = [summary[k][metric] for k in labels] + fig, ax = plt.subplots(figsize=(max(6, len(labels) * 0.9), 4)) + ax.bar(labels, values, color="#4C72B0") + ax.set_ylabel(f"total {metric}") + ax.set_title(f"Planner comparison ({metric})") + plt.xticks(rotation=45, ha="right") + plt.tight_layout() + fig.savefig(path, dpi=120) + plt.close(fig) diff --git a/jupyddl/bfs.py b/jupyddl/bfs.py deleted file mode 100644 index bcc387d..0000000 --- a/jupyddl/bfs.py +++ /dev/null @@ -1,59 +0,0 @@ -from .node import Node -from datetime import datetime as timestamp -from time import time as now -from .metrics import Metrics - - -class BreadthFirstSearch: - def __init__(self, automated_planner): - self.time_start = now() - self.visited = [] - self.automated_planner = automated_planner - self.init = Node(self.automated_planner.initial_state, automated_planner) - self.queue = [self.init] - self.metrics = Metrics() - - def search(self, node_bound=float("inf")): - self.automated_planner.logger.debug( - "Search started at: " + str(timestamp.now()) - ) - while self.queue: - current_node = self.queue.pop(0) - if current_node not in self.visited: - self.visited.append(current_node) - self.metrics.n_evaluated += 1 - if self.automated_planner.satisfies( - self.automated_planner.problem.goal, current_node.state - ): - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.debug( - "Search finished at: " + str(timestamp.now()) - ) - self.metrics.total_cost = current_node.g_cost - return current_node, self.metrics - - if self.metrics.n_opened > node_bound: - break - - actions = self.automated_planner.available_actions(current_node.state) - if not actions: - self.metrics.deadend_states += 1 - else: - self.metrics.n_expended += 1 - for act in actions: - child = Node( - state=self.automated_planner.transition( - current_node.state, act - ), - automated_planner=self.automated_planner, - parent_action=act, - parent=current_node, - ) - self.metrics.n_generated += 1 - if child in self.visited: - continue - self.metrics.n_opened += 1 - self.queue.append(child) - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.warning("!!! No path found !!!") - return None, self.metrics diff --git a/jupyddl/cli.py b/jupyddl/cli.py new file mode 100644 index 0000000..4cedb1b --- /dev/null +++ b/jupyddl/cli.py @@ -0,0 +1,115 @@ +"""Command-line interface: ``jupyddl solve`` and ``jupyddl benchmark``.""" + +from __future__ import annotations + +import argparse +import sys + +from .api import build_task, solve_task, validate_plan +from .benchmark import ( + discover_instances, + plot_summary, + run_benchmark, + summarize, + to_csv, +) +from .heuristics import HEURISTICS +from .search import PLANNERS + + +def _add_solve(sub): + p = sub.add_parser("solve", help="solve a single PDDL instance") + p.add_argument("domain") + p.add_argument("problem") + p.add_argument("-s", "--search", default="astar", choices=sorted(PLANNERS)) + p.add_argument( + "-H", "--heuristic", default="lmcut", choices=sorted(HEURISTICS) + ["none"] + ) + p.add_argument( + "-w", "--weight", type=float, default=2.0, help="weight for weighted A*" + ) + p.set_defaults(func=_cmd_solve) + + +def _add_benchmark(sub): + p = sub.add_parser("benchmark", help="compare planners over a folder of instances") + p.add_argument("root", help="folder containing /domain.pddl + problem.pddl") + p.add_argument("--planners", default="bfs,dijkstra,astar,gbfs,wastar,ehc") + p.add_argument("--heuristic", default="hff", help="heuristic for informed planners") + p.add_argument("--csv", default=None, help="write per-run results to this CSV") + p.add_argument("--plot", default=None, help="write a comparison bar chart (PNG)") + p.add_argument("--metric", default="expanded") + p.set_defaults(func=_cmd_benchmark) + + +def _cmd_solve(args) -> int: + task = build_task(args.domain, args.problem) + heuristic = None if args.heuristic == "none" else args.heuristic + kwargs = {"weight": args.weight} if args.search == "wastar" else {} + result = solve_task(task, args.search, heuristic, **kwargs) + if not result.solved: + print("No plan found.") + _print_stats(result) + return 1 + valid = validate_plan(task, result.plan) + print(f"Plan ({result.plan_length} steps, cost {result.cost}):") + for i, op in enumerate(result.plan): + print(f" {i + 1:3d}. {op.name}") + print(f"Valid: {valid}") + _print_stats(result) + return 0 if valid else 2 + + +def _cmd_benchmark(args) -> int: + instances = discover_instances(args.root) + if not instances: + print(f"No instances found under {args.root}", file=sys.stderr) + return 1 + configs = [] + informed = {"gbfs", "astar", "wastar", "idastar", "ehc"} + for planner in args.planners.split(","): + planner = planner.strip() + configs.append((planner, args.heuristic if planner in informed else None)) + + rows = run_benchmark(instances, configs) + _print_summary(summarize(rows)) + if args.csv: + to_csv(rows, args.csv) + print(f"\nWrote per-run results to {args.csv}") + if args.plot: + plot_summary(rows, args.plot, metric=args.metric) + print(f"Wrote plot to {args.plot}") + return 0 + + +def _print_stats(result) -> None: + s = result.stats + print( + f"Stats: expanded={s.expanded} generated={s.generated} " + f"evaluated={s.evaluated} reopened={s.reopened} " + f"deadends={s.deadends} runtime={s.runtime:.4f}s" + ) + + +def _print_summary(summary) -> None: + print(f"{'config':<20}{'coverage':>12}{'expanded':>12}{'runtime(s)':>12}") + print("-" * 56) + for key, agg in summary.items(): + cov = f"{agg['coverage']}/{agg['instances']}" + print(f"{key:<20}{cov:>12}{agg['expanded']:>12}{agg['runtime']:>12.3f}") + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + prog="jupyddl", + description="Pure-Python PDDL planning framework.", + ) + sub = parser.add_subparsers(dest="command", required=True) + _add_solve(sub) + _add_benchmark(sub) + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/jupyddl/data_analyst.py b/jupyddl/data_analyst.py deleted file mode 100644 index 07c48cf..0000000 --- a/jupyddl/data_analyst.py +++ /dev/null @@ -1,813 +0,0 @@ -import os -import glob -import matplotlib as mpl -import logging - -if "DISPLAY" not in os.environ: - mpl.use("agg") -else: - mpl.use("TkAgg") -mpl.set_loglevel("WARNING") -import matplotlib.pyplot as plt - -plt.style.use("ggplot") -from .automated_planner import AutomatedPlanner -from os import path -import json - - -class DataAnalyst: - def __init__(self): - logging.info("Instantiating data analyst...") - self.available_heuristics = [ - "basic/goal_count", - "basic/zero", - "delete_relaxation/h_add", - "delete_relaxation/h_max", - ] - - def __get_all_pddl_from_data(self, max_pddl_instances=-1): - tested_files = [] - domains_problems = [] - i = 0 - if "DISPLAY" in os.environ: - for root, _, files in os.walk("pddl-examples/", topdown=False): - for name in files: - # if "README" in name: - # continue - # if "LICENSE" in name: - # continue - # if ".gitignore" in name: - # continue - tested_files.append(os.getcwd() + "/" + os.path.join(root, name)) - if i % 2 != 0: - domains_problems.append((tested_files[i - 1], tested_files[i])) - i += 1 - if max_pddl_instances != -1 and i >= max_pddl_instances * 2: - return domains_problems - return domains_problems - return [ - ("pddl-examples/dinner/problem.pddl", "pddl-examples/dinner/domain.pddl"), - ("pddl-examples/dinner/problem.pddl", "pddl-examples/dinner/domain.pddl"), - ] - - def __plot_data(self, times, total_nodes, plot_title): - data = dict() - for i, val in enumerate(total_nodes): - data[val] = times[i] - nodes_sorted = sorted(list(data.keys())) - times_y = [] - for node_opened in nodes_sorted: - times_y.append(data[node_opened]) - plt.plot(nodes_sorted, times_y, "r:o") - plt.xlabel("Number of opened nodes") - plt.ylabel("Planning computation time (s)") - plt.xscale("symlog") - plt.title(plot_title) - plt.grid(True) - plt.show(block=False) - - def __plot_data_generic(self, data, name): - _, ax = plt.subplots() - plt.xlabel("Domain") - plt.ylabel(name) - for key, val in data.items(): - ax.plot(val[name], "-o", label=key) - - plt.title("Planners metric comparison") - plt.legend(loc="upper left") - plt.grid(True) - plt.show(block=False) - - def __scatter_data(self, times, total_nodes, plot_title): - plt.scatter(total_nodes, times) - plt.xlabel("Number of opened nodes") - plt.ylabel("Planning computation time (s)") - plt.xscale("symlog") - plt.title(plot_title) - plt.grid(True) - plt.show(block=False) - - def __gather_data_astar( - self, - domain_path="", - problem_path="", - heuristic_key="basic/goal_count", - max_pddl_instances=-1, - ): - has_multiple_files_tested = True - if not domain_path or not problem_path: - metrics = dict() - costs = [] - for problem, domain in self.__get_all_pddl_from_data( - max_pddl_instances=max_pddl_instances - ): - logging.debug( - "Loading new PDDL instance planned with A* [ " - + heuristic_key - + " ]" - ) - logging.debug("Domain: " + domain) - logging.debug("Problem: " + problem) - apla = AutomatedPlanner(domain, problem) - if heuristic_key in apla.available_heuristics: - path, metrics_obj = apla.astar_best_first_search( - heuristic_key=heuristic_key - ) - else: - logging.critical( - "Heuristic is not implemented! (Key not found in registered heuristics dict)" - ) - return [0], [0], [0], has_multiple_files_tested - if path: - metrics[metrics_obj.runtime] = metrics_obj.n_opened - costs.append(path[-1].g_cost) - else: - metrics[0] = 0 - costs.append(0) - - total_nodes = list(metrics.values()) - times = list(metrics.keys()) - return costs, times, total_nodes, has_multiple_files_tested - has_multiple_files_tested = False - logging.debug("Loading new PDDL instance...") - logging.debug("Domain: " + domain_path) - logging.debug("Problem: " + problem_path) - apla = AutomatedPlanner(domain_path, problem_path) - if heuristic_key in apla.available_heuristics: - path, metrics_obj = apla.astar_best_first_search( - heuristic_key=heuristic_key - ) - else: - logging.critical( - "Heuristic is not implemented! (Key not found in registered heuristics dict)" - ) - return [0], [0], [0], has_multiple_files_tested - if path: - return ( - [path[-1].g_cost], - [metrics_obj.runtime], - [metrics_obj.n_opened], - has_multiple_files_tested, - ) - return [0], [0], [0], has_multiple_files_tested - - def plot_astar( - self, - heuristic_key="basic/goal_count", - domain="", - problem="", - max_pddl_instances=-1, - ): - if bool(not problem) != bool(not domain): - logging.warning( - "Either problem or domain wasn't provided, testing all files in data folder" - ) - problem = domain = "" - _, times, total_nodes, has_multiple_files_tested = self.__gather_data_astar( - heuristic_key=heuristic_key, - problem_path=problem, - domain_path=domain, - max_pddl_instances=max_pddl_instances, - ) - title = "A* Statistics" + "[Heuristic: " + heuristic_key + "]" - if has_multiple_files_tested: - self.__plot_data(times, total_nodes, title) - else: - self.__scatter_data(times, total_nodes, title) - - def __gather_data_greedy_bfs( - self, - domain_path="", - problem_path="", - heuristic_key="basic/goal_count", - max_pddl_instances=-1, - ): - has_multiple_files_tested = True - if not domain_path or not problem_path: - metrics = dict() - costs = [] - for problem, domain in self.__get_all_pddl_from_data( - max_pddl_instances=max_pddl_instances - ): - logging.debug( - "Loading new PDDL instance planned with A* [ " - + heuristic_key - + " ]" - ) - logging.debug("Domain: " + domain) - logging.debug("Problem: " + problem) - apla = AutomatedPlanner(domain, problem) - if heuristic_key in apla.available_heuristics: - path, metrics_obj = apla.greedy_best_first_search( - heuristic_key=heuristic_key - ) - else: - logging.critical( - "Heuristic is not implemented! (Key not found in registered heuristics dict)" - ) - return [0], [0], [0], has_multiple_files_tested - if path: - metrics[metrics_obj.runtime] = metrics_obj.n_opened - costs.append(path[-1].g_cost) - else: - metrics[0] = 0 - costs.append(0) - - total_nodes = list(metrics.values()) - times = list(metrics.keys()) - return costs, times, total_nodes, has_multiple_files_tested - has_multiple_files_tested = False - logging.debug("Loading new PDDL instance...") - logging.debug("Domain: " + domain_path) - logging.debug("Problem: " + problem_path) - apla = AutomatedPlanner(domain_path, problem_path) - if heuristic_key in apla.available_heuristics: - path, metrics_obj = apla.greedy_best_first_search( - heuristic_key=heuristic_key - ) - else: - logging.critical( - "Heuristic is not implemented! (Key not found in registered heuristics dict)" - ) - return [0], [0], [0], has_multiple_files_tested - if path: - return ( - [path[-1].g_cost], - [metrics_obj.runtime], - [metrics_obj.n_opened], - has_multiple_files_tested, - ) - return [0], [0], [0], has_multiple_files_tested - - def plot_greedy_bfs( - self, - heuristic_key="basic/goal_count", - domain="", - problem="", - max_pddl_instances=-1, - ): - if bool(not problem) != bool(not domain): - logging.warning( - "Either problem or domain wasn't provided, testing all files in data folder" - ) - problem = domain = "" - ( - _, - times, - total_nodes, - has_multiple_files_tested, - ) = self.__gather_data_greedy_bfs( - heuristic_key=heuristic_key, - problem_path=problem, - domain_path=domain, - max_pddl_instances=max_pddl_instances, - ) - title = ( - "Greedy Best First Search Statistics" + "[Heuristic: " + heuristic_key + "]" - ) - if has_multiple_files_tested: - self.__plot_data(times, total_nodes, title) - else: - self.__scatter_data(times, total_nodes, title) - - def __gather_data_bfs(self, domain_path="", problem_path="", max_pddl_instances=-1): - has_multiple_files_tested = True - if not domain_path or not problem_path: - metrics = dict() - costs = [] - for problem, domain in self.__get_all_pddl_from_data( - max_pddl_instances=max_pddl_instances - ): - logging.debug("Loading new PDDL instance planned with BFS...") - logging.debug("Domain: " + domain) - logging.debug("Problem: " + problem) - apla = AutomatedPlanner(domain, problem) - path, metrics_obj = apla.breadth_first_search() - if path: - metrics[metrics_obj.runtime] = metrics_obj.n_opened - costs.append(path[-1].g_cost) - else: - metrics[0] = 0 - costs.append(0) - - total_nodes = list(metrics.values()) - times = list(metrics.keys()) - return costs, times, total_nodes, has_multiple_files_tested - has_multiple_files_tested = False - logging.debug("Loading new PDDL instance...") - logging.debug("Domain: " + domain_path) - logging.debug("Problem: " + problem_path) - apla = AutomatedPlanner(domain_path, problem_path) - path, metrics_obj = apla.breadth_first_search() - if path: - return ( - [path[-1].g_cost], - [metrics_obj.runtime], - [metrics_obj.n_opened], - has_multiple_files_tested, - ) - return [0], [0], [0], has_multiple_files_tested - - def plot_bfs(self, domain="", problem="", max_pddl_instances=-1): - title = "BFS Statistics" - if bool(not problem) != bool(not domain): - logging.warning( - "Either problem or domain wasn't provided, testing all files in data folder" - ) - problem = domain = "" - _, times, total_nodes, has_multiple_files_tested = self.__gather_data_bfs( - problem_path=problem, - domain_path=domain, - max_pddl_instances=max_pddl_instances, - ) - if has_multiple_files_tested: - self.__plot_data(times, total_nodes, title) - else: - self.__scatter_data(times, total_nodes, title) - - def __gather_data_dfs(self, domain_path="", problem_path="", max_pddl_instances=-1): - has_multiple_files_tested = True - if not domain_path or not problem_path: - metrics = dict() - costs = [] - for problem, domain in self.__get_all_pddl_from_data( - max_pddl_instances=max_pddl_instances - ): - logging.debug("Loading new PDDL instance planned with DFS...") - logging.debug("Domain: " + domain) - logging.debug("Problem: " + problem) - apla = AutomatedPlanner(domain, problem) - path, metrics_obj = apla.depth_first_search() - if path: - metrics[metrics_obj.runtime] = metrics_obj.n_opened - costs.append(path[-1].g_cost) - else: - metrics[0] = 0 - costs.append(0) - - total_nodes = list(metrics.values()) - times = list(metrics.keys()) - return costs, times, total_nodes, has_multiple_files_tested - has_multiple_files_tested = False - logging.debug("Loading new PDDL instance...") - logging.debug("Domain: " + domain_path) - logging.debug("Problem: " + problem_path) - apla = AutomatedPlanner(domain_path, problem_path) - path, metrics_obj = apla.depth_first_search() - if path: - return ( - [path[-1].g_cost], - [metrics_obj.runtime], - [metrics_obj.n_opened], - has_multiple_files_tested, - ) - return [0], [0], [0], has_multiple_files_tested - - def plot_dfs(self, problem="", domain="", max_pddl_instances=-1): - title = "DFS Statistics" - if bool(not problem) != bool(not domain): - logging.warning( - "Either problem or domain wasn't provided, testing all files in data folder" - ) - problem = domain = "" - _, times, total_nodes, has_multiple_files_tested = self.__gather_data_dfs( - problem_path=problem, - domain_path=domain, - max_pddl_instances=max_pddl_instances, - ) - if has_multiple_files_tested: - self.__plot_data(times, total_nodes, title) - else: - self.__scatter_data(times, total_nodes, title) - - def __gather_data_dijkstra( - self, domain_path="", problem_path="", max_pddl_instances=-1 - ): - has_multiple_files_tested = True - if not domain_path or not problem_path: - metrics = dict() - costs = [] - for problem, domain in self.__get_all_pddl_from_data( - max_pddl_instances=max_pddl_instances - ): - logging.debug("Loading new PDDL instance planned with Dijkstra...") - logging.debug("Domain: " + domain) - logging.debug("Problem: " + problem) - apla = AutomatedPlanner(domain, problem) - path, metrics_obj = apla.dijktra_best_first_search() - if path: - metrics[metrics_obj.runtime] = metrics_obj.n_opened - costs.append(path[-1].g_cost) - else: - metrics[0] = 0 - costs.append(0) - - total_nodes = list(metrics.values()) - times = list(metrics.keys()) - return costs, times, total_nodes, has_multiple_files_tested - has_multiple_files_tested = False - logging.debug("Loading new PDDL instance...") - logging.debug("Domain: " + domain_path) - logging.debug("Problem: " + problem_path) - apla = AutomatedPlanner(domain_path, problem_path) - path, metrics_obj = apla.dijktra_best_first_search() - if path: - return ( - [path[-1].g_cost], - [metrics_obj.runtime], - [metrics_obj.n_opened], - has_multiple_files_tested, - ) - return [0], [0], [0], has_multiple_files_tested - - def plot_dijkstra(self, problem="", domain="", max_pddl_instances=-1): - title = "Dijkstra Statistics" - if bool(not problem) != bool(not domain): - logging.warning( - "Either problem or domain wasn't provided, testing all files in data folder" - ) - problem = domain = "" - _, times, total_nodes, has_multiple_files_tested = self.__gather_data_dijkstra( - problem_path=problem, - domain_path=domain, - max_pddl_instances=max_pddl_instances, - ) - if has_multiple_files_tested: - self.__plot_data(times, total_nodes, title) - else: - self.__scatter_data(times, total_nodes, title) - - def __gather_data( - self, - heuristic_key="basic/goal_count", - astar=True, - bfs=True, - dfs=True, - dijkstra=True, - greedy_bfs=False, - domain="", - problem="", - max_pddl_instances=-1, - ): - gatherers = [] - xdata = dict() - ydata = dict() - - if bfs: - gatherers.append(("BFS", self.__gather_data_bfs)) - if dfs: - gatherers.append(("DFS", self.__gather_data_dfs)) - if dijkstra: - gatherers.append(("Dijkstra", self.__gather_data_dijkstra)) - if astar: - gatherers.append(("A*", self.__gather_data_astar)) - if greedy_bfs: - gatherers.append(("Greedy Best First", self.__gather_data_greedy_bfs)) - - _, _, _, _ = self.__gather_data_bfs( - domain_path=domain, problem_path=problem - ) # Dummy line to do first parsing and get rid of static loading - for name, g in gatherers: - if g == self.__gather_data_astar or g == self.__gather_data_greedy_bfs: - _, times, nodes, _ = g( - domain_path=domain, - problem_path=problem, - heuristic_key=heuristic_key, - max_pddl_instances=max_pddl_instances, - ) - else: - _, times, nodes, _ = g( - domain_path=domain, - problem_path=problem, - max_pddl_instances=max_pddl_instances, - ) - ydata[name] = times - xdata[name] = nodes - return xdata, ydata - - def comparative_astar_heuristic_plot( - self, domain="", problem="", max_pddl_instances=-1 - ): - _, ax = plt.subplots() - plt.xlabel("Number of opened nodes") - plt.ylabel("Planning computation time (s)") - - for h in self.available_heuristics: - _, times, nodes, _ = self.__gather_data_astar( - domain_path=domain, - problem_path=problem, - heuristic_key=h, - max_pddl_instances=max_pddl_instances, - ) - data = dict() - for i, val in enumerate(nodes): - data[val] = times[i] - nodes_sorted = sorted(list(data.keys())) - times_y = [] - for node_opened in nodes_sorted: - times_y.append(data[node_opened]) - - ax.plot( - nodes_sorted, - times_y, - "-o", - label=h, - ) - - plt.title("A* heuristics complexity comparison") - plt.legend(loc="upper left") - plt.xscale("symlog") - plt.grid(True) - plt.show(block=False) - - def comparative_greedy_bfs_heuristic_plot( - self, domain="", problem="", max_pddl_instances=-1 - ): - _, ax = plt.subplots() - plt.xlabel("Number of opened nodes") - plt.ylabel("Planning computation time (s)") - - for h in self.available_heuristics: - _, times, nodes, _ = self.__gather_data_greedy_bfs( - domain_path=domain, - problem_path=problem, - heuristic_key=h, - max_pddl_instances=max_pddl_instances, - ) - data = dict() - for i, val in enumerate(nodes): - data[val] = times[i] - nodes_sorted = sorted(list(data.keys())) - times_y = [] - for node_opened in nodes_sorted: - times_y.append(data[node_opened]) - - ax.plot( - nodes_sorted, - times_y, - "-o", - label=h, - ) - - plt.title("Greedy Best First heuristics complexity comparison") - plt.legend(loc="upper left") - plt.xscale("symlog") - plt.grid(True) - plt.show(block=False) - - def comparative_data_plot( - self, - astar=True, - bfs=True, - dfs=True, - dijkstra=True, - greedy_bfs=False, - domain="", - problem="", - heuristic_key="basic/goal_count", - collect_new_data=True, - max_pddl_instances=-1, - ): - json_dict = {} - if collect_new_data: - xdata, ydata = self.__gather_data( - heuristic_key=heuristic_key, - astar=astar, - dfs=dfs, - bfs=bfs, - dijkstra=dijkstra, - greedy_bfs=greedy_bfs, - domain=domain, - problem=problem, - max_pddl_instances=max_pddl_instances, - ) - json_dict["xdata"] = xdata - json_dict["ydata"] = ydata - with open("data.json", "w") as fp: - json.dump(json_dict, fp) - else: - if not path.exists("data.json"): - logging.warning( - "Input says not to generate new data but no data was found. Generating new data..." - ) - xdata, ydata = self.__gather_data( - heuristic_key=heuristic_key, - astar=astar, - dfs=dfs, - bfs=bfs, - greedy_bfs=greedy_bfs, - dijkstra=dijkstra, - domain=domain, - problem=problem, - max_pddl_instances=max_pddl_instances, - ) - json_dict["xdata"] = xdata - json_dict["ydata"] = ydata - with open("data.json", "w") as fp: - json.dump(json_dict, fp) - else: - with open("data.json") as fp: - json_dict = json.load(fp) - - _, ax = plt.subplots() - plt.xlabel("Number of opened nodes") - plt.ylabel("Planning computation time (s)") - for planner in json_dict["xdata"].keys(): - data = dict() - for i, val in enumerate(json_dict["xdata"][planner]): - data[val] = json_dict["ydata"][planner][i] - nodes_sorted = sorted(list(data.keys())) - times_y = [] - for node_opened in nodes_sorted: - times_y.append(data[node_opened]) - ax.plot( - nodes_sorted, - times_y, - "-o", - label=planner, - ) - plt.title("Planners complexity comparison") - plt.legend(loc="upper left") - plt.xscale("symlog") - plt.yscale("log") - plt.grid(True) - plt.show(block=False) - - def plot_metrics(self): - metrics_dict = dict() - metrics_dict["A* [Zero]"] = [] - metrics_dict["DFS"] = [] - metrics_dict["BFS"] = [] - metrics_dict["A* [Goal_Count]"] = [] - metrics_dict["A* [H_Add]"] = [] - metrics_dict["A* [H_Max]"] = [] - metrics_dict["A* [Critical_Path (H2)]"] = [] - metrics_dict["A* [Critical_Path (H3)]"] = [] - logging.debug("Computation of all metrics for all domains registered...") - for problem, domain in self.__get_all_pddl_from_data(): - logging.debug("Loading new PDDL instance planned with Dijkstra...") - logging.debug("Domain: " + domain) - logging.debug("Problem: " + problem) - apla = AutomatedPlanner(domain, problem) - _, metrics_bfs = apla.breadth_first_search() - _, metrics_agc = apla.astar_best_first_search() - _, metrics_ahadd = apla.astar_best_first_search( - heuristic_key="delete_relaxation/h_add" - ) - _, metrics_ahmax = apla.astar_best_first_search( - heuristic_key="delete_relaxation/h_max" - ) - _, metrics_dij = apla.astar_best_first_search(heuristic_key="basic/zero") - _, metrics_dfs = apla.depth_first_search( - node_bound=metrics_bfs.n_opened * 2 - ) - _, metrics_cp2 = apla.astar_best_first_search( - heuristic_key="critical_path/2" - ) - _, metrics_cp3 = apla.astar_best_first_search( - heuristic_key="critical_path/3" - ) - metrics_dict["A* [Zero]"].append(metrics_dij) - metrics_dict["DFS"].append(metrics_dfs) - metrics_dict["BFS"].append(metrics_bfs) - metrics_dict["A* [Goal_Count]"].append(metrics_agc) - metrics_dict["A* [H_Add]"].append(metrics_ahadd) - metrics_dict["A* [H_Max]"].append(metrics_ahmax) - metrics_dict["A* [Critical_Path (H2)]"].append(metrics_cp2) - metrics_dict["A* [Critical_Path (H3)]"].append(metrics_cp3) - - plot_dict = dict() - - for key, val in metrics_dict.items(): - plot_dict[key] = dict() - plot_dict[key]["Search Runtime (s)"] = [m.runtime for m in val] - plot_dict[key]["Total Heuristics Runtime (s)"] = [ - sum(m.heuristic_runtimes) for m in val - ] - plot_dict[key]["Number of Expanded Nodes"] = [m.n_expended for m in val] - plot_dict[key]["Number of Opened Nodes"] = [m.n_opened for m in val] - plot_dict[key]["Number of Reopened Nodes"] = [m.n_reopened for m in val] - plot_dict[key]["Number of Evaluated Nodes"] = [m.n_evaluated for m in val] - plot_dict[key]["Number of Generated Nodes"] = [m.n_generated for m in val] - plot_dict[key]["Number of Deadend States (No Actions from State)"] = [ - m.deadend_states for m in val - ] - - metrics_keys = list(plot_dict["DFS"].keys()) - - for key in metrics_keys: - self.__plot_data_generic(plot_dict, key) - - def compute_planners_efficiency(self): - costs = dict() - costs["A* [Goal_Count]"], _, n_goal_count, _ = self.__gather_data_astar() - costs["A* [H_Max]"], _, n_hmax, _ = self.__gather_data_astar( - heuristic_key="delete_relaxation/h_max" - ) - costs["A* [H_Add]"], _, n_hadd, _ = self.__gather_data_astar( - heuristic_key="delete_relaxation/h_add" - ) - ( - costs["Greedy Best First [Goal_Count]"], - _, - n_greed_goal_count, - _, - ) = self.__gather_data_greedy_bfs(heuristic_key="basic/goal_count") - ( - costs["Greedy Best First [H_Max]"], - _, - n_greed_hmax, - _, - ) = self.__gather_data_greedy_bfs(heuristic_key="delete_relaxation/h_max") - ( - costs["Greedy Best First [H_Add]"], - _, - n_greed_hadd, - _, - ) = self.__gather_data_greedy_bfs(heuristic_key="delete_relaxation/h_add") - costs["DFS"], _, n_dfs, _ = self.__gather_data_dfs() - costs["BFS"], _, n_bfs, _ = self.__gather_data_bfs() - costs["Dijkstra"], _, n_dij, _ = self.__gather_data_dijkstra() - - p_gc = (len(n_goal_count) - n_goal_count.count(0)) / len(n_goal_count) * 100 - p_hmax = (len(n_hmax) - n_hmax.count(0)) / len(n_hmax) * 100 - p_hadd = (len(n_hadd) - n_hadd.count(0)) / len(n_hadd) * 100 - p_greedy_gc = ( - (len(n_greed_goal_count) - n_greed_goal_count.count(0)) - / len(n_greed_goal_count) - * 100 - ) - p_greedy_hmax = ( - (len(n_greed_hmax) - n_greed_hmax.count(0)) / len(n_greed_hmax) * 100 - ) - p_greedy_hadd = ( - (len(n_greed_hadd) - n_greed_hadd.count(0)) / len(n_greed_hadd) * 100 - ) - p_dfs = (len(n_dfs) - n_dfs.count(0)) / len(n_dfs) * 100 - p_bfs = (len(n_bfs) - n_bfs.count(0)) / len(n_bfs) * 100 - p_dij = (len(n_dij) - n_dij.count(0)) / len(n_dij) * 100 - - _, ax = plt.subplots() - plt.xlabel("Domain evaluated") - plt.ylabel("Cost to goal") - for key, val in costs.items(): - ax.plot( - val, - "-o", - label=key, - ) - costs[key] = [i for i in costs[key] if i != 0] - plt.title("Planners efficiency (costs)") - plt.legend(loc="upper left") - plt.grid(True) - plt.show(block=False) - - logging.info( - "DFS succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % (p_dfs, sum(costs["DFS"]) / len(costs["DFS"])) - ) - logging.info( - "BFS succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % (p_bfs, sum(costs["BFS"]) / len(costs["BFS"])) - ) - logging.info( - "Dijkstra succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % (p_dij, sum(costs["Dijkstra"]) / len(costs["Dijkstra"])) - ) - logging.info( - "A* [Goal_Count] succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % (p_gc, sum(costs["A* [Goal_Count]"]) / len(costs["A* [Goal_Count]"])) - ) - logging.info( - "A* [H_Max] succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % (p_hmax, sum(costs["A* [H_Max]"]) / len(costs["A* [H_Max]"])) - ) - logging.info( - "A* [H_Add] succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % (p_hadd, sum(costs["A* [H_Add]"]) / len(costs["A* [H_Add]"])) - ) - logging.info( - "Greedy Best First [Goal_Count] succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % ( - p_greedy_gc, - sum(costs["Greedy Best First [Goal_Count]"]) - / len(costs["Greedy Best First [Goal_Count]"]), - ) - ) - logging.info( - "Greedy Best First [H_Max] succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % ( - p_greedy_hmax, - sum(costs["Greedy Best First [H_Max]"]) - / len(costs["Greedy Best First [H_Max]"]), - ) - ) - logging.info( - "Greedy Best First [H_Add] succeeded to build a plan with a %.2f%% rate and a %.2f cost average" - % ( - p_greedy_hadd, - sum(costs["Greedy Best First [H_Add]"]) - / len(costs["Greedy Best First [H_Add]"]), - ) - ) diff --git a/jupyddl/dfs.py b/jupyddl/dfs.py deleted file mode 100644 index 04cbe86..0000000 --- a/jupyddl/dfs.py +++ /dev/null @@ -1,59 +0,0 @@ -from .node import Node -from datetime import datetime as timestamp -from time import time as now -from .metrics import Metrics - - -class DepthFirstSearch: - def __init__(self, automated_planner): - self.time_start = now() - self.visited = [] - self.automated_planner = automated_planner - self.init = Node(self.automated_planner.initial_state, automated_planner) - self.stack = [self.init] - self.metrics = Metrics() - - def search(self, node_bound=float("inf")): - self.automated_planner.logger.debug( - "Search started at: " + str(timestamp.now()) - ) - while self.stack: - current_node = self.stack.pop() - if current_node not in self.visited: - self.visited.append(current_node) - self.metrics.n_evaluated += 1 - if self.automated_planner.satisfies( - self.automated_planner.problem.goal, current_node.state - ): - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.debug( - "Search finished at: " + str(timestamp.now()) - ) - self.metrics.total_cost = current_node.g_cost - return current_node, self.metrics - - if self.metrics.n_opened > node_bound: - break - - actions = self.automated_planner.available_actions(current_node.state) - if not actions: - self.metrics.deadend_states += 1 - else: - self.metrics.n_expended += 1 - for act in actions: - child = Node( - state=self.automated_planner.transition( - current_node.state, act - ), - automated_planner=self.automated_planner, - parent_action=act, - parent=current_node, - ) - self.metrics.n_generated += 1 - if child in self.visited: - continue - self.metrics.n_opened += 1 - self.stack.append(child) - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.warning("!!! No path found !!!") - return None, self.metrics diff --git a/jupyddl/dijkstra.py b/jupyddl/dijkstra.py deleted file mode 100644 index 7e2ebae..0000000 --- a/jupyddl/dijkstra.py +++ /dev/null @@ -1,101 +0,0 @@ -from .node import Node -import logging -import math -from datetime import datetime as timestamp -from time import time as now -from .metrics import Metrics - - -def zero_heuristic(): - return 0 - - -class DijkstraBestFirstSearch: - def __init__(self, automated_planner): - self.time_start = now() - self.automated_planner = automated_planner - self.metrics = Metrics() - self.init = Node( - self.automated_planner.initial_state, - automated_planner, - is_closed=False, - is_open=True, - heuristic=zero_heuristic, - metric=self.metrics, - ) - self.open_nodes_n = 1 - self.nodes = dict() - self.nodes[self.__hash(self.init)] = self.init - - def __hash(self, node): - sep = ", Dict{Symbol,Any}" - string = str(node.state) - return string.split(sep, 1)[0] + ")" - - def search(self, node_bound=float("inf")): - self.automated_planner.logger.debug( - "Search started at: " + str(timestamp.now()) - ) - while self.open_nodes_n > 0: - current_key = min( - [n for n in self.nodes if self.nodes[n].is_open], - key=(lambda k: self.nodes[k].f_cost), - ) - current_node = self.nodes[current_key] - self.metrics.n_evaluated += 1 - if self.automated_planner.satisfies( - self.automated_planner.problem.goal, current_node.state - ): - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.debug( - "Search finished at: " + str(timestamp.now()) - ) - self.metrics.total_cost = current_node.g_cost - return current_node, self.metrics - - current_node.is_closed = True - current_node.is_open = False - self.open_nodes_n -= 1 - - if self.metrics.n_opened > node_bound: - break - - actions = self.automated_planner.available_actions(current_node.state) - if not actions: - self.metrics.deadend_states += 1 - else: - self.metrics.n_expended += 1 - for act in actions: - child = Node( - state=self.automated_planner.transition(current_node.state, act), - automated_planner=self.automated_planner, - parent_action=act, - parent=current_node, - heuristic=zero_heuristic, - is_closed=False, - is_open=True, - metric=self.metrics, - ) - self.metrics.n_generated += 1 - child_hash = self.__hash(child) - if child_hash in self.nodes: - if self.nodes[child_hash].is_closed: - continue - if not self.nodes[child_hash].is_open: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - else: - if child.g_cost < self.nodes[child_hash].g_cost: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - self.metrics.n_reopened += 1 - - else: - self.nodes[child_hash] = child - self.metrics.n_opened += 1 - self.open_nodes_n += 1 - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.warning("!!! No path found !!!") - return None, self.metrics diff --git a/jupyddl/greedy_best_first.py b/jupyddl/greedy_best_first.py deleted file mode 100644 index eea00a9..0000000 --- a/jupyddl/greedy_best_first.py +++ /dev/null @@ -1,101 +0,0 @@ -from .node import Node -import logging -import math -from time import time as now -from datetime import datetime as timestamp -from .metrics import Metrics - - -class GreedyBestFirstSearch: - def __init__(self, automated_planner, heuristic_function): - self.time_start = now() - self.automated_planner = automated_planner - self.metrics = Metrics() - self.init = Node( - self.automated_planner.initial_state, - automated_planner, - is_closed=False, - is_open=True, - heuristic=heuristic_function, - heuristic_based=True, - metric=self.metrics, - ) - self.heuristic_function = heuristic_function - self.open_nodes_n = 1 - self.nodes = dict() - self.nodes[self.__hash(self.init)] = self.init - - def __hash(self, node): - sep = ", Dict{Symbol,Any}" - string = str(node.state) - return string.split(sep, 1)[0] + ")" - - def search(self, node_bound=float("inf")): - self.automated_planner.logger.debug( - "Search started at: " + str(timestamp.now()) - ) - while self.open_nodes_n > 0: - current_key = min( - [n for n in self.nodes if self.nodes[n].is_open], - key=(lambda k: self.nodes[k].h_cost), - ) - current_node = self.nodes[current_key] - - self.metrics.n_evaluated += 1 - if self.automated_planner.satisfies( - self.automated_planner.problem.goal, current_node.state - ): - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.debug( - "Search finished at: " + str(timestamp.now()) - ) - self.metrics.total_cost = current_node.g_cost - return current_node, self.metrics - - current_node.is_closed = True - current_node.is_open = False - self.open_nodes_n -= 1 - - if self.metrics.n_opened > node_bound: - break - - actions = self.automated_planner.available_actions(current_node.state) - if actions: - self.metrics.n_expended += 1 - else: - self.metrics.deadend_states += 1 - for act in actions: - child = Node( - state=self.automated_planner.transition(current_node.state, act), - automated_planner=self.automated_planner, - parent_action=act, - parent=current_node, - heuristic=self.heuristic_function, - is_closed=False, - is_open=True, - heuristic_based=True, - metric=self.metrics, - ) - self.metrics.n_generated += 1 - child_hash = self.__hash(child) - if child_hash in self.nodes: - if self.nodes[child_hash].is_closed: - continue - if not self.nodes[child_hash].is_open: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - else: - if child.g_cost < self.nodes[child_hash].g_cost: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - self.metrics.n_reopened += 1 - - else: - self.nodes[child_hash] = child - self.open_nodes_n += 1 - self.metrics.n_opened += 1 - self.metrics.runtime = now() - self.time_start - self.automated_planner.logger.warning("!!! No path found !!!") - return None, self.metrics diff --git a/jupyddl/grounding.py b/jupyddl/grounding.py new file mode 100644 index 0000000..9733677 --- /dev/null +++ b/jupyddl/grounding.py @@ -0,0 +1,377 @@ +"""Grounding: turn a parsed :class:`Domain` + :class:`Problem` into a +grounded :class:`~jupyddl.task.Task`. + +Pipeline: + +1. Build the ``type -> objects`` table (with type-hierarchy closure). +2. Instantiate every action over all type-consistent parameter tuples, + expanding ``forall``/``when`` effects and universally-quantified goals. +3. Detect static predicates (never added/deleted) and use the initial state to + prune infeasible action instances and simplify preconditions/conditions. +4. Compile negative preconditions/goals into *positive normal form* by + introducing complement facts ``(not ...)`` and maintaining them on every + operator that touches the underlying atom. +5. Encode everything as integer fact ids. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from itertools import product + +from .parser.ast import ( + AddEffect, + Atom, + ConjunctiveEffect, + DelEffect, + Domain, + ForallEffect, + IncreaseCostEffect, + Problem, + WhenEffect, +) +from .parser.parser import parse_domain_file, parse_problem_file +from .task import CondEffect, Operator, Task + + +def _ground_atom(atom: Atom, subst: dict) -> Atom: + return Atom(atom.predicate, tuple(subst.get(a, a) for a in atom.args)) + + +def _build_type_objects(domain: Domain, problem: Problem): + """Map every type to the set of objects that inhabit it (hierarchy-aware).""" + parent = dict(domain.types) + + def ancestors(typ: str): + chain = [typ] + seen = {typ} + cur = typ + while cur in parent and parent[cur] not in (None, "object", cur): + cur = parent[cur] + if cur in seen: + break + seen.add(cur) + chain.append(cur) + return chain + + type_objects: dict[str, set] = defaultdict(set) + declared: set = set() + for name, typ in list(domain.constants) + list(problem.objects): + declared.add(name) + for anc in ancestors(typ): + type_objects[anc].add(name) + type_objects["object"].add(name) + + # Robustness: some (toy) problems omit the :objects section and only mention + # constants in :init/:goal. Treat any such undeclared constant as an object + # of the root type so untyped domains still ground. + for name in _harvest_constants(problem): + if name not in declared: + type_objects["object"].add(name) + + for typ in set(list(parent) + list(parent.values())): + type_objects.setdefault(typ, set()) + return {typ: tuple(sorted(objs)) for typ, objs in type_objects.items()} + + +def _harvest_constants(problem: Problem) -> set: + found: set = set() + for atom in problem.init: + found.update(a for a in atom.args if not a.startswith("?")) + + def scan(cond): + for lit in cond.literals: + found.update(a for a in lit.atom.args if not a.startswith("?")) + for _, body in cond.universals: + scan(body) + + scan(problem.goal) + return found + + +def _instantiate_condition(cond, subst, type_objects): + """Return ``(positive_atoms, negative_atoms)`` or ``None`` if infeasible.""" + pos: set = set() + neg: set = set() + for eq in cond.equalities: + left = subst.get(eq.left, eq.left) + right = subst.get(eq.right, eq.right) + if (left == right) != eq.positive: + return None + for lit in cond.literals: + atom = _ground_atom(lit.atom, subst) + (pos if lit.positive else neg).add(atom) + for params, body in cond.universals: + pools = [type_objects.get(t, ()) for (_, t) in params] + for combo in product(*pools): + sub = dict(subst) + for (var, _), obj in zip(params, combo): + sub[var] = obj + result = _instantiate_condition(body, sub, type_objects) + if result is None: + return None + pos |= result[0] + neg |= result[1] + return pos, neg + + +@dataclass +class _RawOp: + name: str + pre_pos: set + pre_neg: set + add: set + delete: set + cond: list # list of (cpos, cneg, cadd, cdel) atom-sets + cost: int + + +def _collect_effect(eff, subst, type_objects, cpos, cneg, acc): + if isinstance(eff, ConjunctiveEffect): + for part in eff.parts: + _collect_effect(part, subst, type_objects, cpos, cneg, acc) + elif isinstance(eff, AddEffect): + atom = _ground_atom(eff.atom, subst) + if cpos or cneg: + acc["cond"].append((frozenset(cpos), frozenset(cneg), {atom}, set())) + else: + acc["add"].add(atom) + elif isinstance(eff, DelEffect): + atom = _ground_atom(eff.atom, subst) + if cpos or cneg: + acc["cond"].append((frozenset(cpos), frozenset(cneg), set(), {atom})) + else: + acc["delete"].add(atom) + elif isinstance(eff, IncreaseCostEffect): + acc["cost"] += eff.amount + acc["has_cost"] = True + elif isinstance(eff, ForallEffect): + pools = [type_objects.get(t, ()) for (_, t) in eff.params] + for combo in product(*pools): + sub = dict(subst) + for (var, _), obj in zip(eff.params, combo): + sub[var] = obj + _collect_effect(eff.body, sub, type_objects, cpos, cneg, acc) + elif isinstance(eff, WhenEffect): + result = _instantiate_condition(eff.condition, subst, type_objects) + if result is None: # condition unsatisfiable for this instance + return + wpos, wneg = result + _collect_effect(eff.body, subst, type_objects, cpos | wpos, cneg | wneg, acc) + + +def _effect_predicates(domain: Domain) -> set: + preds: set = set() + + def walk(eff): + if isinstance(eff, ConjunctiveEffect): + for part in eff.parts: + walk(part) + elif isinstance(eff, (AddEffect, DelEffect)): + preds.add(eff.atom.predicate) + elif isinstance(eff, (ForallEffect, WhenEffect)): + walk(eff.body) + + for action in domain.actions: + walk(action.effect) + return preds + + +def _ground_raw_operators(domain, problem, type_objects) -> list: + raw = [] + for action in domain.actions: + pools = [type_objects.get(t, ()) for (_, t) in action.parameters] + for combo in product(*pools): + subst = {var: obj for (var, _), obj in zip(action.parameters, combo)} + pre = _instantiate_condition(action.precondition, subst, type_objects) + if pre is None: + continue + acc = { + "add": set(), + "delete": set(), + "cond": [], + "cost": 0, + "has_cost": False, + } + _collect_effect(action.effect, subst, type_objects, set(), set(), acc) + args = ",".join(combo) + name = f"{action.name}({args})" if combo else action.name + cost = acc["cost"] if acc["has_cost"] else 1 + raw.append( + _RawOp( + name, pre[0], pre[1], acc["add"], acc["delete"], acc["cond"], cost + ) + ) + return raw + + +@dataclass +class _Encoder: + fact_ids: dict = field(default_factory=dict) + comp_ids: dict = field(default_factory=dict) + names: list = field(default_factory=list) + + def fact(self, atom: Atom) -> int: + if atom not in self.fact_ids: + self.fact_ids[atom] = len(self.names) + self.names.append(str(atom)) + return self.fact_ids[atom] + + def comp(self, atom: Atom) -> int: + if atom not in self.comp_ids: + self.comp_ids[atom] = len(self.names) + self.names.append(f"(not {atom})") + return self.comp_ids[atom] + + +def ground(domain: Domain, problem: Problem) -> Task: + """Ground ``domain`` + ``problem`` into a :class:`Task`.""" + type_objects = _build_type_objects(domain, problem) + init_atoms = set(problem.init) + static_preds = {p.name for p in domain.predicates} - _effect_predicates(domain) + + def is_static(atom: Atom) -> bool: + return atom.predicate in static_preds + + raw_ops = _ground_raw_operators(domain, problem, type_objects) + + enc = _Encoder() + # Collect fluent atoms whose complement is referenced (need PNF facts). + tracked_neg: set = set() + + # --- resolve static literals, drop infeasible operators ------------------ + resolved = [] + for op in raw_ops: + feasible = True + pre_pos_fluent, pre_neg_fluent = set(), set() + for atom in op.pre_pos: + if is_static(atom): + if atom not in init_atoms: + feasible = False + break + else: + pre_pos_fluent.add(atom) + if not feasible: + continue + for atom in op.pre_neg: + if is_static(atom): + if atom in init_atoms: + feasible = False + break + else: + pre_neg_fluent.add(atom) + tracked_neg.add(atom) + if not feasible: + continue + + add = set(op.add) + delete = set(op.delete) + cond = [] + for cpos, cneg, cadd, cdel in op.cond: + triggers = True + cpos_f, cneg_f = set(), set() + for atom in cpos: + if is_static(atom): + if atom not in init_atoms: + triggers = False + break + else: + cpos_f.add(atom) + if not triggers: + continue + for atom in cneg: + if is_static(atom): + if atom in init_atoms: + triggers = False + break + else: + cneg_f.add(atom) + tracked_neg.add(atom) + if not triggers: + continue + if not cpos_f and not cneg_f: + add |= cadd + delete |= cdel + else: + cond.append((cpos_f, cneg_f, cadd, cdel)) + resolved.append( + (op.name, pre_pos_fluent, pre_neg_fluent, add, delete, cond, op.cost) + ) + + # --- goal ---------------------------------------------------------------- + goal_res = _instantiate_condition(problem.goal, {}, type_objects) + if goal_res is None: + raise ValueError("Goal condition is self-contradictory") + goal_pos, goal_neg = goal_res + unsolvable = False + goal_pos_fluent, goal_neg_fluent = set(), set() + for atom in goal_pos: + if is_static(atom): + if atom not in init_atoms: + unsolvable = True + else: + goal_pos_fluent.add(atom) + for atom in goal_neg: + if is_static(atom): + if atom in init_atoms: + unsolvable = True + else: + goal_neg_fluent.add(atom) + tracked_neg.add(atom) + + # --- encode facts -------------------------------------------------------- + init_ids = {enc.fact(a) for a in init_atoms if not is_static(a)} + for atom in tracked_neg: + cid = enc.comp(atom) + if atom not in init_atoms: + init_ids.add(cid) + + def encode_add_del(add_atoms, del_atoms): + add_ids = {enc.fact(a) for a in add_atoms} + del_ids = {enc.fact(a) for a in del_atoms} + for a in add_atoms: + if a in tracked_neg: + del_ids.add(enc.comp(a)) + for a in del_atoms: + if a in tracked_neg: + add_ids.add(enc.comp(a)) + return frozenset(add_ids), frozenset(del_ids) + + operators = [] + for name, pp, pn, add, delete, cond, cost in resolved: + precond = {enc.fact(a) for a in pp} | {enc.comp(a) for a in pn} + add_ids, del_ids = encode_add_del(add, delete) + cond_effects = [] + for cpos_f, cneg_f, cadd, cdel in cond: + cond_ids = {enc.fact(a) for a in cpos_f} | {enc.comp(a) for a in cneg_f} + cadd_ids, cdel_ids = encode_add_del(cadd, cdel) + if cadd_ids or cdel_ids: + cond_effects.append(CondEffect(frozenset(cond_ids), cadd_ids, cdel_ids)) + operators.append( + Operator( + name, frozenset(precond), add_ids, del_ids, tuple(cond_effects), cost + ) + ) + + goals = {enc.fact(a) for a in goal_pos_fluent} | { + enc.comp(a) for a in goal_neg_fluent + } + if unsolvable: + sentinel = len(enc.names) + enc.names.append("(unsolvable)") + goals.add(sentinel) # never produced by any operator + + return Task( + name=problem.name or domain.name, + facts=tuple(enc.names), + init=frozenset(init_ids), + goals=frozenset(goals), + operators=tuple(operators), + metric_cost=problem.metric_minimize_cost, + ) + + +def ground_files(domain_path: str, problem_path: str) -> Task: + """Convenience: parse both files and ground them into a :class:`Task`.""" + return ground(parse_domain_file(domain_path), parse_problem_file(problem_path)) diff --git a/jupyddl/heuristics.py b/jupyddl/heuristics.py deleted file mode 100644 index e819aac..0000000 --- a/jupyddl/heuristics.py +++ /dev/null @@ -1,398 +0,0 @@ -import logging -from .node import Node - - -class BasicHeuristic: - def __init__(self, automated_planner, heuristic_key): - self.automated_planner = automated_planner - self.heuristic_keys = { - "basic/zero": self.__zero_heuristic, - "basic/goal_count": self.__goal_count_heuristic, - } - if heuristic_key not in list(self.heuristic_keys.keys()): - logging.warning( - "Heuristic key isn't registered, forcing it to [basic/goal_count]" - ) - heuristic_key = "basic/goal_count" - - self.current_h = heuristic_key - - def compute(self, state): - return self.heuristic_keys[self.current_h](state) - - def __zero_heuristic(self, state): - return 0 - - def __goal_count_heuristic(self, state): - count = 0 - for goal in self.automated_planner.goals: - if not self.automated_planner.state_has_term(state, goal): - count += 1 - return count - - -class DeleteRelaxationHeuristic: - def __init__(self, automated_planner, heuristic_key): - class DRHCache: - def __init__(self, domain=None, axioms=None, preconds=None, additions=None): - self.domain = domain - self.axioms = axioms - self.preconds = preconds - self.additions = additions - - self.automated_planner = automated_planner - self.cache = DRHCache() - self.heuristic_keys = { - "delete_relaxation/h_add": self.__h_add, - "delete_relaxation/h_max": self.__h_max, - } - if heuristic_key not in list(self.heuristic_keys.keys()): - logging.warning( - "Heuristic key isn't registered, forcing it to [delete_relaxation/h_add]" - ) - heuristic_key = "delete_relaxation/h_add" - - self.current_h = heuristic_key - self.has_been_precomputed = False - self.__pre_compute() - # return self.heuristic_keys[self.current_h](state) - - def compute(self, state): - if not self.has_been_precomputed: - self.__pre_compute() - domain = self.cache.domain - goals = self.automated_planner.goals - types = state.types - facts = state.facts - fact_costs = self.automated_planner.pddl.init_facts_costs(facts) - while True: - facts, state = self.automated_planner.pddl.get_facts_and_state( - fact_costs, types - ) - if self.automated_planner.satisfies(goals, state): - costs = [] - fact_costs_str = dict([(str(k), val) for k, val in fact_costs.items()]) - for g in goals: - if str(g) in fact_costs_str: - costs.append(fact_costs_str[str(g)]) - costs.insert(0, 0) - return self.heuristic_keys[self.current_h](costs) - - for ax in self.cache.axioms: - fact_costs = ( - self.automated_planner.pddl.compute_costs_one_step_derivation( - facts, fact_costs, ax, self.current_h - ) - ) - - actions = self.automated_planner.available_actions(state) - for act in actions: - fact_costs = self.automated_planner.pddl.compute_cost_action_effect( - fact_costs, act, domain, self.cache.additions, self.current_h - ) - - if len(fact_costs) == self.automated_planner.pddl.length( - facts - ) and self.__facts_eq(fact_costs, facts): - break - - return float("inf") - - def __pre_compute(self): - if self.has_been_precomputed: - return - domain = self.automated_planner.domain - domain, axioms = self.automated_planner.pddl.compute_hsp_axioms(domain) - # preconditions = dict() - additions = dict() - self.automated_planner.pddl.cache_global_preconditions(domain) - for name, definition in domain.actions.items(): - additions[name] = self.automated_planner.pddl.effect_diff( - definition.effect - ).add - self.cache.additions = additions - self.cache.preconds = self.automated_planner.pddl.g_preconditions - self.cache.domain = domain - self.cache.axioms = axioms - self.has_been_precomputed = True - - def __h_add(self, costs): - return sum(costs) - - def __h_max(self, costs): - return max(costs) - - def __facts_eq(self, facts_dict, facts_set): - fact_costs_str = dict([(str(k), val) for k, val in facts_dict.items()]) - for f in facts_set: - if not (str(f) in fact_costs_str.keys()): - return False - return True - - -class RelaxedCriticalPathHeuristic: - def __init__(self, automated_planner, critical_path_level=1): - class RCPCache: - def __init__(self, domain=None, axioms=None, preconds=None, additions=None): - self.domain = domain - self.axioms = axioms - self.preconds = preconds - self.additions = additions - - self.automated_planner = automated_planner - self.cache = RCPCache() - if critical_path_level > 3: - logging.warning( - "Critical Path level is only implemented until 3, forcing it to 3." - ) - self.critical_path_level = 3 - if critical_path_level < 1: - logging.warning( - "Critical Path level has to be at least 1, forcing it to 1." - ) - self.critical_path_level = 1 - else: - self.critical_path_level = critical_path_level - self.has_been_precomputed = False - self.__pre_compute() - # return self.heuristic_keys[self.current_h](state) - - def compute(self, state): - if not self.has_been_precomputed: - self.__pre_compute() - domain = self.cache.domain - goals = self.automated_planner.goals - types = state.types - facts = state.facts - fact_costs = self.automated_planner.pddl.init_facts_costs(facts) - while True: - facts, state = self.automated_planner.pddl.get_facts_and_state( - fact_costs, types - ) - if self.automated_planner.satisfies(goals, state): - costs = [] - fact_costs_str = dict([(str(k), val) for k, val in fact_costs.items()]) - print(fact_costs_str) - if self.critical_path_level == 1: - for g in goals: - if str(g) in fact_costs_str: - costs.append(fact_costs_str[str(g)]) - if self.critical_path_level == 2: - pairs_of_goals = [ - (g1, g2) for g1 in goals for g2 in goals if g1 != g2 - ] - for gs in pairs_of_goals: - if ( - str(gs[0]) in fact_costs_str - and str(gs[1]) in fact_costs_str - ): - costs.append( - fact_costs_str[str(gs[0])] + fact_costs_str[str(gs[1])] - ) - if self.critical_path_level == 3: - triplets_of_goals = [ - (g1, g2, g3) - for g1 in goals - for g2 in goals - for g3 in goals - if g1 != g2 and g1 != g3 and g2 != g3 - ] - for gs in triplets_of_goals: - if ( - str(gs[0]) in fact_costs_str - and str(gs[1]) in fact_costs_str - and str(gs[2]) in fact_costs_str - ): - costs.append( - fact_costs_str[str(gs[0])] - + fact_costs_str[str(gs[1])] - + fact_costs_str[str(gs[2])] - ) - costs.insert(0, 0) - return max(costs) - - for ax in self.cache.axioms: - fact_costs = ( - self.automated_planner.pddl.compute_costs_one_step_derivation( - facts, fact_costs, ax, "max" - ) - ) - - actions = self.automated_planner.available_actions(state) - for act in actions: - fact_costs = self.automated_planner.pddl.compute_cost_action_effect( - fact_costs, act, domain, self.cache.additions, "max" - ) - - if len(fact_costs) == self.automated_planner.pddl.length( - facts - ) and self.__facts_eq(fact_costs, facts): - break - - return float("inf") - - def __pre_compute(self): - if self.has_been_precomputed: - return - domain = self.automated_planner.domain - domain, axioms = self.automated_planner.pddl.compute_hsp_axioms(domain) - # preconditions = dict() - additions = dict() - self.automated_planner.pddl.cache_global_preconditions(domain) - for name, definition in domain.actions.items(): - additions[name] = self.automated_planner.pddl.effect_diff( - definition.effect - ).add - self.cache.additions = additions - self.cache.preconds = self.automated_planner.pddl.g_preconditions - self.cache.domain = domain - self.cache.axioms = axioms - self.has_been_precomputed = True - - def __h_add(self, costs): - return sum(costs) - - def __h_max(self, costs): - return max(costs) - - def __facts_eq(self, facts_dict, facts_set): - fact_costs_str = dict([(str(k), val) for k, val in facts_dict.items()]) - for f in facts_set: - if not (str(f) in fact_costs_str.keys()): - return False - return True - - -class CriticalPathHeuristic: - def __init__(self, automated_planner, critical_path_level=1): - self.automated_planner = automated_planner - - if critical_path_level > 3: - logging.warning( - "Critical Path level is only implemented until 3, forcing it to 3." - ) - self.critical_path_level = 3 - if critical_path_level < 1: - logging.warning( - "Critical Path level has to be at least 1, forcing it to 1." - ) - self.critical_path_level = 1 - else: - self.critical_path_level = critical_path_level - - self.goals = [] - - if self.critical_path_level == 1: - self.goals = self.automated_planner.goals - - if self.critical_path_level == 2: - if len(self.automated_planner.goals) < 2: - logging.warning("Only 1 goal predicate, forcing H2 to H1") - self.goals = self.automated_planner.goals - else: - self.goals = [ - [g1, g2] - for g1 in self.automated_planner.goals - for g2 in self.automated_planner.goals - if g1 != g2 - ] - - if self.critical_path_level == 3: - if len(self.automated_planner.goals) < 2: - logging.warning("Only 1 goal predicate, forcing H3 to H1") - self.goals = self.automated_planner.goals - elif len(self.automated_planner.goals) < 3: - logging.warning("Only 2 goal predicate, forcing H3 to H2") - self.goals = [ - [g1, g2] - for g1 in self.automated_planner.goals - for g2 in self.automated_planner.goals - if g1 != g2 - ] - else: - self.goals = [ - [g1, g2, g3] - for g1 in self.automated_planner.goals - for g2 in self.automated_planner.goals - for g3 in self.automated_planner.goals - if g1 != g2 and g1 != g3 and g2 != g3 - ] - - def __h_max(self, costs): - return max(costs) - - def compute(self, state): - costs = [] - - for subgoal in self.goals: - costs.append(self.__dijkstra_search(state, subgoal)) - - return self.__h_max(costs) - - def __hash(self, node): - sep = ", Dict{Symbol,Any}" - string = str(node.state) - return string.split(sep, 1)[0] + ")" - - def __dijkstra_search(self, state, goal): - def zero_heuristic(): - return 0 - - init = Node( - state, - self.automated_planner, - is_closed=False, - is_open=True, - heuristic=zero_heuristic, - ) - - open_nodes_n = 1 - nodes = dict() - nodes[self.__hash(init)] = init - - while open_nodes_n > 0: - current_key = min( - [n for n in nodes if nodes[n].is_open], - key=(lambda k: nodes[k].f_cost), - ) - current_node = nodes[current_key] - - if self.automated_planner.satisfies(goal, current_node.state): - return current_node.g_cost - - current_node.is_closed = True - current_node.is_open = False - open_nodes_n -= 1 - - actions = self.automated_planner.available_actions(current_node.state) - - for act in actions: - child = Node( - state=self.automated_planner.transition(current_node.state, act), - automated_planner=self.automated_planner, - parent_action=act, - parent=current_node, - heuristic=zero_heuristic, - is_closed=False, - is_open=True, - ) - - child_hash = self.__hash(child) - - if child_hash in nodes: - if nodes[child_hash].is_closed: - continue - - if not nodes[child_hash].is_open: - nodes[child_hash] = child - open_nodes_n += 1 - - else: - if child.g_cost < nodes[child_hash].g_cost: - nodes[child_hash] = child - open_nodes_n += 1 - - else: - nodes[child_hash] = child - open_nodes_n += 1 - return float("inf") diff --git a/jupyddl/heuristics/__init__.py b/jupyddl/heuristics/__init__.py new file mode 100644 index 0000000..96d2f39 --- /dev/null +++ b/jupyddl/heuristics/__init__.py @@ -0,0 +1,49 @@ +"""Heuristics and a name-based registry.""" + +from __future__ import annotations + +from functools import partial + +from .base import Heuristic +from .critical_path import CriticalPathHeuristic +from .delete_relaxation import FFHeuristic, HAddHeuristic, HMaxHeuristic +from .lmcut import LMCutHeuristic +from .simple import BlindHeuristic, GoalCountHeuristic + +# name -> callable(task) -> Heuristic +HEURISTICS = { + "blind": BlindHeuristic, + "goalcount": GoalCountHeuristic, + "hmax": HMaxHeuristic, + "hadd": HAddHeuristic, + "hff": FFHeuristic, + "lmcut": LMCutHeuristic, + "h1": partial(CriticalPathHeuristic, m=1), + "h2": partial(CriticalPathHeuristic, m=2), + "hm": CriticalPathHeuristic, +} + + +def make_heuristic(name: str, task) -> Heuristic: + """Instantiate a heuristic by name (see :data:`HEURISTICS`).""" + try: + factory = HEURISTICS[name] + except KeyError: + raise ValueError( + f"Unknown heuristic '{name}'. Available: {sorted(HEURISTICS)}" + ) from None + return factory(task) + + +__all__ = [ + "Heuristic", + "BlindHeuristic", + "GoalCountHeuristic", + "HMaxHeuristic", + "HAddHeuristic", + "FFHeuristic", + "LMCutHeuristic", + "CriticalPathHeuristic", + "HEURISTICS", + "make_heuristic", +] diff --git a/jupyddl/heuristics/base.py b/jupyddl/heuristics/base.py new file mode 100644 index 0000000..fd5d0dd --- /dev/null +++ b/jupyddl/heuristics/base.py @@ -0,0 +1,19 @@ +"""Heuristic base class.""" + +from __future__ import annotations + + +class Heuristic: + """A heuristic bound to a task; call it on a state to get an estimate. + + Returning ``math.inf`` signals that the state is a (relaxed) dead end. + """ + + name: str = "heuristic" + admissible: bool = False + + def __init__(self, task): + self.task = task + + def __call__(self, state: frozenset) -> float: # pragma: no cover + raise NotImplementedError diff --git a/jupyddl/heuristics/critical_path.py b/jupyddl/heuristics/critical_path.py new file mode 100644 index 0000000..b5d7f02 --- /dev/null +++ b/jupyddl/heuristics/critical_path.py @@ -0,0 +1,80 @@ +"""Critical-path heuristics h^m (Haslum & Geffner, 2000). + +``h^m`` estimates the cost of the most expensive size-``m`` subset of atoms. +``h^1`` equals ``h_max``; higher ``m`` is more informative but costs more. +All ``h^m`` are admissible. +""" + +from __future__ import annotations + +import math +from itertools import combinations + +from .base import Heuristic + + +class CriticalPathHeuristic(Heuristic): + name = "hm" + admissible = True + + def __init__(self, task, m: int = 2): + super().__init__(task) + self.m = m + self.goal = task.goals + self.ops = list(task.relaxed_operators()) # (pre, add, cost) + + def __call__(self, state) -> float: + return self._hm(state) + + def _hm(self, state) -> float: + m = self.m + inf = math.inf + atoms = set(state) | set(self.goal) + for pre, add, _ in self.ops: + atoms |= pre + atoms |= add + atoms = sorted(atoms) + + # Table of costs for every atom-set of size 1..m. + table: dict = {} + sets: list = [] + for size in range(1, m + 1): + for combo in combinations(atoms, size): + fs = frozenset(combo) + table[fs] = 0.0 if fs <= state else inf + sets.append(fs) + + def cost_of(subset: frozenset) -> float: + # Cost of an arbitrary atom-set: table lookup, or (if larger than m) + # the max over its size-m subsets. + if not subset: + return 0.0 + if len(subset) <= m: + return table.get(subset, inf) + best = 0.0 + for combo in combinations(sorted(subset), m): + val = table.get(frozenset(combo), inf) + if val > best: + best = val + return best + + # Value iteration to the fixpoint. + changed = True + while changed: + changed = False + for target in sets: + if table[target] == 0.0: + continue + best = table[target] + for pre, add, cost in self.ops: + if not (add & target): + continue + regressed = (target - add) | pre + val = cost + cost_of(regressed) + if val < best: + best = val + if best < table[target]: + table[target] = best + changed = True + + return cost_of(frozenset(self.goal)) diff --git a/jupyddl/heuristics/delete_relaxation.py b/jupyddl/heuristics/delete_relaxation.py new file mode 100644 index 0000000..15ca8cb --- /dev/null +++ b/jupyddl/heuristics/delete_relaxation.py @@ -0,0 +1,70 @@ +"""Delete-relaxation heuristics: h_max, h_add and the FF heuristic.""" + +from __future__ import annotations + +import math +from collections import deque + +from .base import Heuristic +from .relaxation import RelaxedTask, goal_value, propagate_costs + + +class HMaxHeuristic(Heuristic): + """h_max: the most expensive relaxed goal fact. Admissible.""" + + name = "hmax" + admissible = True + + def __init__(self, task): + super().__init__(task) + self.rt = RelaxedTask(task) + + def __call__(self, state) -> float: + cost, _ = propagate_costs(self.rt, state, additive=False) + return goal_value(cost, self.rt.goal, additive=False) + + +class HAddHeuristic(Heuristic): + """h_add: sum of relaxed goal-fact costs. Informative, not admissible.""" + + name = "hadd" + + def __init__(self, task): + super().__init__(task) + self.rt = RelaxedTask(task) + + def __call__(self, state) -> float: + cost, _ = propagate_costs(self.rt, state, additive=True) + return goal_value(cost, self.rt.goal, additive=True) + + +class FFHeuristic(Heuristic): + """FF heuristic: cost of a relaxed plan extracted from the h_add graph.""" + + name = "hff" + + def __init__(self, task): + super().__init__(task) + self.rt = RelaxedTask(task) + + def __call__(self, state) -> float: + cost, supporter = propagate_costs(self.rt, state, additive=True) + if any(math.isinf(cost[g]) for g in self.rt.goal): + return math.inf + relaxed_plan: set = set() + seen: set = set() + queue = deque(self.rt.goal) + while queue: + fact = queue.popleft() + if fact in state or fact in seen: + continue + seen.add(fact) + op_idx = supporter[fact] + if op_idx < 0: + return math.inf + if op_idx not in relaxed_plan: + relaxed_plan.add(op_idx) + for pre_fact in self.rt.ops[op_idx].pre: + if pre_fact not in state: + queue.append(pre_fact) + return float(sum(self.rt.ops[i].cost for i in relaxed_plan)) diff --git a/jupyddl/heuristics/lmcut.py b/jupyddl/heuristics/lmcut.py new file mode 100644 index 0000000..7eb2157 --- /dev/null +++ b/jupyddl/heuristics/lmcut.py @@ -0,0 +1,145 @@ +"""The LM-cut heuristic (Helmert & Domshlak, 2009). + +LM-cut repeatedly computes h_max, finds a *landmark cut* of operators separating +the initial state from the goal in the justification graph, adds the cut's +cheapest operator cost to the estimate, and discounts the cut operators. It is +one of the strongest admissible heuristics for optimal planning. +""" + +from __future__ import annotations + +import heapq +import math +from dataclasses import dataclass + +from .base import Heuristic + + +@dataclass +class _AugOp: + idx: int + pre: frozenset + add: frozenset + base_cost: int + + +class LMCutHeuristic(Heuristic): + name = "lmcut" + admissible = True + + def __init__(self, task): + super().__init__(task) + nf = task.num_facts + self.INIT = nf # artificial "always true" fact + self.GOAL = nf + 1 # artificial goal fact + self.num = nf + 2 + + self.ops: list[_AugOp] = [] + for pre, add, cost in task.relaxed_operators(): + # Every operator must have at least one precondition for the + # justification graph; use the artificial INIT fact if needed. + pre = pre if pre else frozenset({self.INIT}) + self.ops.append(_AugOp(len(self.ops), pre, add, cost)) + # Artificial goal operator (cost 0) collecting the real goal. + self.goal_op = len(self.ops) + self.ops.append(_AugOp(self.goal_op, task.goals, frozenset({self.GOAL}), 0)) + + self.consumers: list[list[int]] = [[] for _ in range(self.num)] + for op in self.ops: + for f in op.pre: + self.consumers[f].append(op.idx) + + def __call__(self, state) -> float: + base = set(state) + base.add(self.INIT) + source = frozenset(base) + costs = [op.base_cost for op in self.ops] + total = 0.0 + + while True: + hmax, pcf = self._hmax(source, costs) + if hmax[self.GOAL] == 0: + return total + if math.isinf(hmax[self.GOAL]): + return math.inf + + goal_zone = self._goal_zone(costs, pcf) + cut = self._cut(source, goal_zone, pcf, hmax) + if not cut: # safety; should not happen when hmax(goal) > 0 + return math.inf + min_cost = min(costs[oi] for oi in cut) + total += min_cost + for oi in cut: + costs[oi] -= min_cost + + def _hmax(self, source, costs): + inf = math.inf + cost = [inf] * self.num + pcf = [-1] * len(self.ops) + counter = [len(op.pre) for op in self.ops] + pq: list = [] + for f in source: + cost[f] = 0 + heapq.heappush(pq, (0, f)) + + def relax(op: _AugOp): + supporter = max(op.pre, key=lambda p: cost[p]) + value = cost[supporter] + costs[op.idx] + pcf[op.idx] = supporter + if math.isinf(cost[supporter]): + return + for f in op.add: + if value < cost[f]: + cost[f] = value + heapq.heappush(pq, (value, f)) + + for op in self.ops: + if counter[op.idx] == 0: + relax(op) + while pq: + c, f = heapq.heappop(pq) + if c > cost[f]: + continue + for op_idx in self.consumers[f]: + counter[op_idx] -= 1 + if counter[op_idx] == 0: + relax(self.ops[op_idx]) + return cost, pcf + + def _goal_zone(self, costs, pcf): + """Facts that reach the goal through zero-cost justification edges.""" + zone = {self.GOAL} + changed = True + while changed: + changed = False + for op in self.ops: + supporter = pcf[op.idx] + if ( + costs[op.idx] == 0 + and supporter >= 0 + and supporter not in zone + and (op.add & zone) + ): + zone.add(supporter) + changed = True + return zone + + def _cut(self, source, goal_zone, pcf, hmax): + """Operators crossing from the init-reachable region into the goal zone.""" + # Forward-reachable facts from the initial state that stay out of the + # goal zone (the "before" region). + before = {f for f in source if f not in goal_zone} + changed = True + while changed: + changed = False + for op in self.ops: + supporter = pcf[op.idx] + if supporter in before and not math.isinf(hmax[supporter]): + for f in op.add: + if f not in goal_zone and f not in before: + before.add(f) + changed = True + cut = [ + op.idx for op in self.ops if pcf[op.idx] in before and (op.add & goal_zone) + ] + return cut diff --git a/jupyddl/heuristics/relaxation.py b/jupyddl/heuristics/relaxation.py new file mode 100644 index 0000000..4785b73 --- /dev/null +++ b/jupyddl/heuristics/relaxation.py @@ -0,0 +1,98 @@ +"""Delete-relaxation machinery shared by h_max, h_add, h_FF and LM-cut.""" + +from __future__ import annotations + +import heapq +import math +from dataclasses import dataclass + + +@dataclass +class RelaxedOp: + idx: int + pre: frozenset + add: frozenset + cost: int + + +class RelaxedTask: + """Delete-relaxed view of a task: unary-ish operators + goal. + + Conditional effects are already expanded into separate relaxed operators by + :meth:`jupyddl.task.Task.relaxed_operators`. + """ + + def __init__(self, task): + self.num_facts = task.num_facts + self.goal = task.goals + self.ops = [ + RelaxedOp(i, pre, add, cost) + for i, (pre, add, cost) in enumerate(task.relaxed_operators()) + ] + # Index: fact -> operators that have it as a precondition. + self.consumers: list[list[int]] = [[] for _ in range(self.num_facts)] + self.no_pre: list[int] = [] + for op in self.ops: + if op.pre: + for f in op.pre: + self.consumers[f].append(op.idx) + else: + self.no_pre.append(op.idx) + + +def propagate_costs(rt: RelaxedTask, state, additive: bool): + """Generalised Dijkstra computing h_max (``additive=False``) or h_add costs. + + Returns ``(cost, supporter)`` where ``cost[f]`` is the estimated cost to + achieve fact ``f`` and ``supporter[f]`` is the operator index that achieved + it (used for FF's relaxed-plan extraction). + """ + inf = math.inf + cost = [inf] * rt.num_facts + supporter = [-1] * rt.num_facts + counter = [len(op.pre) for op in rt.ops] + pq: list = [] + + for f in state: + if cost[f] != 0: + cost[f] = 0 + heapq.heappush(pq, (0, f)) + + def op_value(op: RelaxedOp) -> float: + if not op.pre: + return op.cost + pre_costs = [cost[p] for p in op.pre] + agg = sum(pre_costs) if additive else max(pre_costs) + return op.cost + agg + + def apply_op(op: RelaxedOp): + value = op_value(op) + if math.isinf(value): + return + for f in op.add: + if value < cost[f]: + cost[f] = value + supporter[f] = op.idx + heapq.heappush(pq, (value, f)) + + for idx in rt.no_pre: + apply_op(rt.ops[idx]) + + while pq: + c, f = heapq.heappop(pq) + if c > cost[f]: + continue + for op_idx in rt.consumers[f]: + counter[op_idx] -= 1 + if counter[op_idx] == 0: + apply_op(rt.ops[op_idx]) + return cost, supporter + + +def goal_value(cost, goal, additive: bool) -> float: + if not goal: + return 0.0 + values = [cost[g] for g in goal] + if any(math.isinf(v) for v in values): + return math.inf + return float(sum(values) if additive else max(values)) diff --git a/jupyddl/heuristics/simple.py b/jupyddl/heuristics/simple.py new file mode 100644 index 0000000..a186bcb --- /dev/null +++ b/jupyddl/heuristics/simple.py @@ -0,0 +1,29 @@ +"""Cheap non-relaxation heuristics.""" + +from __future__ import annotations + +from .base import Heuristic + + +class BlindHeuristic(Heuristic): + """0 in a goal state, otherwise the cheapest operator cost. Admissible.""" + + name = "blind" + admissible = True + + def __init__(self, task): + super().__init__(task) + costs = [op.cost for op in task.operators if op.cost > 0] + self.min_cost = min(costs) if costs else 1 + + def __call__(self, state) -> float: + return 0.0 if self.task.goal_reached(state) else float(self.min_cost) + + +class GoalCountHeuristic(Heuristic): + """Number of unsatisfied goal facts. Fast, informative, not admissible.""" + + name = "goalcount" + + def __call__(self, state) -> float: + return float(len(self.task.goals - state)) diff --git a/jupyddl/metrics.py b/jupyddl/metrics.py deleted file mode 100644 index 6ccf711..0000000 --- a/jupyddl/metrics.py +++ /dev/null @@ -1,38 +0,0 @@ -class Metrics: - def __init__(self): - self.runtime = 0 - self.heuristic_runtimes = [] - self.n_expended = 0 - self.n_reopened = 0 - self.n_evaluated = 0 - self.n_opened = 1 - self.n_generated = 1 - self.deadend_states = 0 - self.total_cost = 0 - - def get_average_heuristic_runtime(self): - if self.heuristic_runtimes: - return sum(self.heuristic_runtimes) / len(self.heuristic_runtimes) - return 0 - - def __str__(self): - if self.heuristic_runtimes: - av = sum(self.heuristic_runtimes) - w = sum(self.heuristic_runtimes) / self.runtime * 100 - else: - av = 0 - w = 0 - return ( - "Expanded %d state(s).\nOpened %d state(s).\nReopened %d state(s).\nEvaluated %d state(s).\nGenerated %d state(s).\nDead ends: %d state(s).\nRuntime: %.2fs.\nTotal heuristic runtime: %.2fs\nComputational weight of heuristic in the search: %.2f%%" - % ( - self.n_expended, - self.n_opened, - self.n_reopened, - self.n_evaluated, - self.n_generated, - self.deadend_states, - self.runtime, - av, - w, - ) - ) diff --git a/jupyddl/node.py b/jupyddl/node.py deleted file mode 100644 index 50471fb..0000000 --- a/jupyddl/node.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging -import time - - -class Node: - def __init__( - self, - state, - automated_planner, - is_closed=None, - is_open=None, - parent_action=None, - parent=None, - g_cost=0, - heuristic=None, - heuristic_based=False, - metric=None, - ): - self.state = state - self.parent_action = parent_action - self.parent = parent - self.automated_planner = automated_planner - temp_cost = automated_planner.pddl.get_value(state, "total-cost") - if temp_cost: - self.g_cost = temp_cost - if heuristic_based: - if heuristic: - clock = time.time() - self.h_cost = heuristic(state) - if metric: - metric.heuristic_runtimes.append(time.time() - clock) - else: - automated_planner.logger.warning( - "Heuristic function wasn't found, forcing it to return zero [Best practice: use the zero_heuristic function]" - ) - self.h_cost = 0 - else: - self.h_cost = 0 - self.f_cost = self.g_cost + self.h_cost - else: - if parent: - self.g_cost = 1 + parent.g_cost - else: - self.g_cost = g_cost - if heuristic_based: - if heuristic: - clock = time.time() - self.h_cost = heuristic(state) - if metric: - metric.heuristic_runtimes.append(time.time() - clock) - else: - automated_planner.logger.warning( - "Heuristic function wasn't found, forcing it to return zero [Best practice: use the zero_heuristic function]" - ) - self.h_cost = 0 - else: - self.h_cost = 0 - self.f_cost = self.g_cost + self.h_cost - - self.is_closed = is_closed - self.is_open = is_open - - def __stringify_state(self, state): - state_str = str(state).replace("', "total-cost =" - ) - state_str = state_str.replace("Dict{Symbol,Any}(", "") - state_str = state_str.replace(" , ", "") - state_str = state_str.replace("))>", "") - return state_str - - def __lt__(self, other): - return self.f_cost <= other.f_cost - - def __str__(self): - state = self.__stringify_state(self.state) - return "Node { %s | g = %.2f | h = %.2f | open = %s | closed = %s }" % ( - state, - self.g_cost, - self.h_cost, - self.is_open, - self.is_closed, - ) - - -class Path: - def __init__(self, nodes): - self.nodes = nodes - - def __str__(self): - return str([str(n) for n in self.nodes]) diff --git a/jupyddl/parser/__init__.py b/jupyddl/parser/__init__.py new file mode 100644 index 0000000..17d70ac --- /dev/null +++ b/jupyddl/parser/__init__.py @@ -0,0 +1,39 @@ +"""PDDL parsing: tokenizer, AST and recursive-descent parser.""" + +from .ast import ( + Action, + Atom, + Condition, + Domain, + Literal, + PDDLError, + Predicate, + Problem, + UnsupportedFeatureError, +) +from .parser import ( + parse, + parse_domain, + parse_domain_file, + parse_problem, + parse_problem_file, +) +from .tokenizer import tokenize + +__all__ = [ + "Action", + "Atom", + "Condition", + "Domain", + "Literal", + "PDDLError", + "Predicate", + "Problem", + "UnsupportedFeatureError", + "parse", + "parse_domain", + "parse_problem", + "parse_domain_file", + "parse_problem_file", + "tokenize", +] diff --git a/jupyddl/parser/ast.py b/jupyddl/parser/ast.py new file mode 100644 index 0000000..a502b57 --- /dev/null +++ b/jupyddl/parser/ast.py @@ -0,0 +1,146 @@ +"""Structured AST for the supported subset of PDDL. + +The subset covered: ``:strips``, ``:typing``, ``:negative-preconditions``, +``:equality``, ``:action-costs`` (via ``(increase (total-cost) k)``), and the +ADL constructs ``forall``/``when`` inside effects (needed by e.g. the *flip* +domain). Numeric fluents other than ``total-cost`` are intentionally not +modelled and are rejected by the parser with a clear error. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +class PDDLError(Exception): + """Base class for all parsing / modelling errors.""" + + +class UnsupportedFeatureError(PDDLError): + """Raised when a PDDL construct outside the supported subset is used.""" + + +@dataclass(frozen=True) +class Atom: + """A (possibly lifted) predicate application, e.g. ``(on ?x ?y)``. + + ``args`` holds terms as raw strings: variables keep their leading ``?`` + while constants/objects are stored verbatim. + """ + + predicate: str + args: tuple[str, ...] = () + + def __str__(self) -> str: + if not self.args: + return f"({self.predicate})" + return f"({self.predicate} {' '.join(self.args)})" + + +@dataclass(frozen=True) +class Literal: + """A positive or negative atom used in preconditions and goals.""" + + atom: Atom + positive: bool = True + + +@dataclass(frozen=True) +class EqualityConstraint: + """An ``(= a b)`` (or its negation) constraint over terms.""" + + left: str + right: str + positive: bool = True + + +@dataclass +class Condition: + """A conjunction of literals plus (in)equality constraints. + + All supported preconditions/goals/effect-conditions are conjunctive; the + parser flattens nested ``and`` and rejects unsupported connectives. + """ + + literals: list[Literal] = field(default_factory=list) + equalities: list[EqualityConstraint] = field(default_factory=list) + # Universally-quantified sub-conditions, expanded over objects at grounding + # time. Each entry is (params, body) where params is [(var, type), ...]. + universals: list[tuple[list[tuple[str, str]], "Condition"]] = field( + default_factory=list + ) + + +# --- Effects ----------------------------------------------------------------- +# Effects are kept as a small tree; grounding expands ``forall`` and normalises +# everything into (condition -> add/delete) conditional effects. + + +@dataclass +class AddEffect: + atom: Atom + + +@dataclass +class DelEffect: + atom: Atom + + +@dataclass +class IncreaseCostEffect: + amount: int + + +@dataclass +class ConjunctiveEffect: + parts: list = field(default_factory=list) + + +@dataclass +class ForallEffect: + params: list[tuple[str, str]] # (variable, type) + body: object + + +@dataclass +class WhenEffect: + condition: Condition + body: object + + +# --- Domain / problem -------------------------------------------------------- + + +@dataclass +class Predicate: + name: str + params: list[tuple[str, str]] # (variable, type) + + +@dataclass +class Action: + name: str + parameters: list[tuple[str, str]] # (variable, type) + precondition: Condition + effect: object # one of the *Effect nodes above + + +@dataclass +class Domain: + name: str + requirements: list[str] + types: dict[str, str] # child type -> parent type ("object" if none) + constants: list[tuple[str, str]] # (name, type) + predicates: list[Predicate] + actions: list[Action] + functions: list[str] = field(default_factory=list) + + +@dataclass +class Problem: + name: str + domain_name: str + objects: list[tuple[str, str]] # (name, type) + init: list[Atom] + goal: Condition + metric_minimize_cost: bool = False diff --git a/jupyddl/parser/parser.py b/jupyddl/parser/parser.py new file mode 100644 index 0000000..235189a --- /dev/null +++ b/jupyddl/parser/parser.py @@ -0,0 +1,235 @@ +"""Recursive-descent PDDL parser producing :mod:`jupyddl.parser.ast` objects.""" + +from __future__ import annotations + +from .ast import ( + Action, + AddEffect, + Atom, + Condition, + ConjunctiveEffect, + DelEffect, + Domain, + EqualityConstraint, + ForallEffect, + IncreaseCostEffect, + Literal, + PDDLError, + Predicate, + Problem, + UnsupportedFeatureError, + WhenEffect, +) +from .tokenizer import tokenize + +_CONNECTIVES = {"and", "or", "not", "imply", "forall", "exists", "when", "="} + + +def _parse_typed_list(items: list) -> list[tuple[str, str]]: + """Parse ``?x ?y - type a b`` into ``[(name, type), ...]`` (default type ``object``).""" + result: list[tuple[str, str]] = [] + pending: list[str] = [] + i = 0 + while i < len(items): + tok = items[i] + if tok == "-": + typ = items[i + 1] + if isinstance(typ, list): + raise UnsupportedFeatureError("'either' types are not supported") + for name in pending: + result.append((name, typ)) + pending = [] + i += 2 + else: + if isinstance(tok, list): + raise PDDLError(f"Unexpected nested list in typed list: {tok}") + pending.append(tok) + i += 1 + result.extend((name, "object") for name in pending) + return result + + +def _parse_atom(expr: list) -> Atom: + if not expr or isinstance(expr[0], list): + raise PDDLError(f"Malformed atom: {expr}") + for arg in expr[1:]: + if isinstance(arg, list): + raise UnsupportedFeatureError( + f"nested/numeric term in atom is not supported: {expr}" + ) + return Atom(expr[0], tuple(expr[1:])) + + +def _flatten_condition(expr, cond: Condition, positive: bool) -> None: + if not isinstance(expr, list): + raise PDDLError(f"Malformed condition: {expr}") + if not expr: # empty () means "true" + return + head = expr[0] + if head == "and": + for sub in expr[1:]: + _flatten_condition(sub, cond, positive) + elif head == "not": + if len(expr) != 2: + raise PDDLError(f"'not' takes exactly one argument: {expr}") + _flatten_condition(expr[1], cond, not positive) + elif head == "=": + cond.equalities.append(EqualityConstraint(expr[1], expr[2], positive)) + elif head == "forall": + if not positive: + raise UnsupportedFeatureError("negated 'forall' is not supported") + cond.universals.append((_parse_typed_list(expr[1]), parse_condition(expr[2]))) + elif head in _CONNECTIVES: + raise UnsupportedFeatureError(f"'{head}' is not supported in conditions") + else: + cond.literals.append(Literal(_parse_atom(expr), positive)) + + +def parse_condition(expr) -> Condition: + cond = Condition() + _flatten_condition(expr, cond, True) + return cond + + +def parse_effect(expr): + if not isinstance(expr, list): + raise PDDLError(f"Malformed effect: {expr}") + if not expr: + return ConjunctiveEffect([]) + head = expr[0] + if head == "and": + return ConjunctiveEffect([parse_effect(sub) for sub in expr[1:]]) + if head == "not": + return DelEffect(_parse_atom(expr[1])) + if head == "forall": + return ForallEffect(_parse_typed_list(expr[1]), parse_effect(expr[2])) + if head == "when": + return WhenEffect(parse_condition(expr[1]), parse_effect(expr[2])) + if head == "increase": + fn = expr[1] + fname = fn[0] if isinstance(fn, list) else fn + if fname != "total-cost": + raise UnsupportedFeatureError( + "numeric fluents other than total-cost are not supported" + ) + return IncreaseCostEffect(int(expr[2])) + if head in _CONNECTIVES: + raise UnsupportedFeatureError(f"'{head}' is not supported in effects") + return AddEffect(_parse_atom(expr)) + + +def _section_key(section) -> str: + return section[0] if section and isinstance(section[0], str) else "" + + +def parse_domain(tokens: list) -> Domain: + if _section_key(tokens) != "define": + raise PDDLError("Domain file must start with (define ...)") + + name = "" + requirements: list[str] = [] + types: dict[str, str] = {} + constants: list[tuple[str, str]] = [] + predicates: list[Predicate] = [] + functions: list[str] = [] + actions: list[Action] = [] + + for section in tokens[1:]: + key = _section_key(section) + if key == "domain": + name = section[1] + elif key == ":requirements": + requirements = list(section[1:]) + elif key == ":types": + for child, parent in _parse_typed_list(section[1:]): + # In a :types list, "child - parent" reads name=child, type=parent. + types[child] = parent + elif key == ":constants": + constants = _parse_typed_list(section[1:]) + elif key == ":predicates": + for pred in section[1:]: + predicates.append(Predicate(pred[0], _parse_typed_list(pred[1:]))) + elif key == ":functions": + for fn in section[1:]: + if isinstance(fn, list) and fn and fn[0] != "-": + functions.append(fn[0]) + elif key == ":action": + actions.append(_parse_action(section)) + elif key in (":durative-action", ":derived"): + raise UnsupportedFeatureError(f"'{key}' is not supported") + return Domain(name, requirements, types, constants, predicates, actions, functions) + + +def _parse_action(section: list) -> Action: + name = section[1] + parameters: list[tuple[str, str]] = [] + precondition = Condition() + effect = ConjunctiveEffect([]) + i = 2 + while i < len(section): + tag = section[i] + val = section[i + 1] + if tag == ":parameters": + parameters = _parse_typed_list(val) + elif tag == ":precondition": + precondition = parse_condition(val) + elif tag == ":effect": + effect = parse_effect(val) + i += 2 + return Action(name, parameters, precondition, effect) + + +def parse_problem(tokens: list) -> Problem: + if _section_key(tokens) != "define": + raise PDDLError("Problem file must start with (define ...)") + + name = "" + domain_name = "" + objects: list[tuple[str, str]] = [] + init: list[Atom] = [] + goal = Condition() + metric = False + + for section in tokens[1:]: + key = _section_key(section) + if key == "problem": + name = section[1] + elif key == ":domain": + domain_name = section[1] + elif key == ":objects": + objects = _parse_typed_list(section[1:]) + elif key == ":init": + for fact in section[1:]: + if not fact: + continue + if fact[0] == "=": + # Only (= (total-cost) 0) style initialisation is accepted. + continue + init.append(_parse_atom(fact)) + elif key == ":goal": + goal = parse_condition(section[1]) + elif key == ":metric": + metric = True + return Problem(name, domain_name, objects, init, goal, metric) + + +def parse(text: str): + """Parse PDDL text into a :class:`Domain` or :class:`Problem`.""" + tokens = tokenize(text) + for section in tokens[1:]: + key = _section_key(section) + if key == "domain": + return parse_domain(tokens) + if key == "problem": + return parse_problem(tokens) + raise PDDLError("Could not determine whether input is a domain or a problem") + + +def parse_domain_file(path: str) -> Domain: + with open(path, "r", encoding="utf-8") as handle: + return parse_domain(tokenize(handle.read())) + + +def parse_problem_file(path: str) -> Problem: + with open(path, "r", encoding="utf-8") as handle: + return parse_problem(tokenize(handle.read())) diff --git a/jupyddl/parser/tokenizer.py b/jupyddl/parser/tokenizer.py new file mode 100644 index 0000000..4539767 --- /dev/null +++ b/jupyddl/parser/tokenizer.py @@ -0,0 +1,46 @@ +"""Tokenizer turning PDDL source text into nested lists of tokens. + +PDDL is an s-expression language, so we lex into parentheses-delimited nested +lists of atoms (strings). Line comments start with ``;``. Case is insensitive +in PDDL, so all tokens are lower-cased. +""" + +from __future__ import annotations + +from .ast import PDDLError + + +def _strip_comments(text: str) -> str: + out = [] + for line in text.splitlines(): + idx = line.find(";") + out.append(line if idx == -1 else line[:idx]) + return "\n".join(out) + + +def tokenize(text: str) -> list: + """Parse PDDL source into a nested list of lower-cased string tokens.""" + text = _strip_comments(text) + # Pad parens so a simple split yields clean tokens. + text = text.replace("(", " ( ").replace(")", " ) ") + tokens = text.split() + + stack: list[list] = [[]] + for tok in tokens: + if tok == "(": + new: list = [] + stack[-1].append(new) + stack.append(new) + elif tok == ")": + if len(stack) == 1: + raise PDDLError("Unbalanced parentheses: unexpected ')'") + stack.pop() + else: + stack[-1].append(tok.lower()) + + if len(stack) != 1: + raise PDDLError("Unbalanced parentheses: missing ')'") + top = stack[0] + if len(top) != 1 or not isinstance(top[0], list): + raise PDDLError("Expected a single top-level s-expression") + return top[0] diff --git a/jupyddl/search/__init__.py b/jupyddl/search/__init__.py new file mode 100644 index 0000000..16db7d8 --- /dev/null +++ b/jupyddl/search/__init__.py @@ -0,0 +1,63 @@ +"""Search algorithms and a name-based planner registry.""" + +from .base import Planner, best_first +from .best_first_planners import ( + AStarSearch, + GreedyBestFirstSearch, + UniformCostSearch, + WeightedAStarSearch, +) +from .enforced_hill_climbing import EnforcedHillClimbing +from .ida_star import IDAStarSearch +from .node import SearchNode, extract_plan +from .result import SearchResult, SearchStats +from .uninformed import ( + BreadthFirstSearch, + DepthFirstSearch, + IterativeDeepeningSearch, +) + +# name -> zero-argument factory returning a fresh Planner instance. +PLANNERS = { + "bfs": BreadthFirstSearch, + "dfs": DepthFirstSearch, + "iddfs": IterativeDeepeningSearch, + "dijkstra": UniformCostSearch, + "gbfs": GreedyBestFirstSearch, + "astar": AStarSearch, + "wastar": WeightedAStarSearch, + "idastar": IDAStarSearch, + "ehc": EnforcedHillClimbing, +} + + +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 + return factory(**kwargs) + + +__all__ = [ + "Planner", + "best_first", + "AStarSearch", + "GreedyBestFirstSearch", + "UniformCostSearch", + "WeightedAStarSearch", + "EnforcedHillClimbing", + "IDAStarSearch", + "BreadthFirstSearch", + "DepthFirstSearch", + "IterativeDeepeningSearch", + "SearchNode", + "SearchResult", + "SearchStats", + "extract_plan", + "PLANNERS", + "make_planner", +] diff --git a/jupyddl/search/base.py b/jupyddl/search/base.py new file mode 100644 index 0000000..43cb7ff --- /dev/null +++ b/jupyddl/search/base.py @@ -0,0 +1,94 @@ +"""Planner base class and the shared best-first search engine. + +``best_first`` is a graph-search with re-opening, lazy heuristic evaluation and +dead-end pruning (a heuristic value of ``inf`` prunes the state). The concrete +best-first planners (uniform-cost, greedy, A*, weighted-A*) are just different +priority functions over ``(g, h)``. +""" + +from __future__ import annotations + +import heapq +import itertools +import math +import time + +from .node import extract_plan, make_child, make_root +from .result import SearchResult, SearchStats + + +class Planner: + """Base class for all planners.""" + + name: str = "planner" + requires_heuristic: bool = False + optimal: bool = False + + def search(self, task, heuristic=None) -> SearchResult: # pragma: no cover + raise NotImplementedError + + +def best_first(task, priority, heuristic=None, reopen=True) -> SearchResult: + """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). + """ + stats = SearchStats() + start = time.perf_counter() + init = task.init + + hcache: dict = {} + + def h_of(state) -> float: + if heuristic is None: + return 0.0 + cached = hcache.get(state) + if cached is None: + cached = heuristic(state) + hcache[state] = cached + stats.evaluated += 1 + return cached + + root = make_root(init) + h0 = h_of(init) + if math.isinf(h0): + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) + + counter = itertools.count() + best_g: dict = {init: 0} + open_list = [(priority(0, h0), next(counter), root)] + + while open_list: + _, _, node = heapq.heappop(open_list) + if node.g > best_g.get(node.state, math.inf): + continue # stale, a cheaper path to this state was found + if task.goal_reached(node.state): + stats.runtime = time.perf_counter() - start + return SearchResult(True, extract_plan(node), node.g, stats) + stats.expanded += 1 + state = node.state + for op in task.operators: + if not op.precond <= state: + continue + succ = op.apply(state) + new_g = node.g + op.cost + stats.generated += 1 + prev = best_g.get(succ, math.inf) + if new_g >= prev: + continue + if not math.isinf(prev): + if not reopen: + continue + stats.reopened += 1 + hv = h_of(succ) + if math.isinf(hv): + stats.deadends += 1 + continue + best_g[succ] = new_g + child = make_child(node, op, succ, op.cost) + heapq.heappush(open_list, (priority(new_g, hv), next(counter), child)) + + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) diff --git a/jupyddl/search/best_first_planners.py b/jupyddl/search/best_first_planners.py new file mode 100644 index 0000000..8702b94 --- /dev/null +++ b/jupyddl/search/best_first_planners.py @@ -0,0 +1,51 @@ +"""Best-first planners defined by their priority function over ``(g, h)``.""" + +from __future__ import annotations + +from .base import Planner, best_first +from .result import SearchResult + + +class UniformCostSearch(Planner): + """Dijkstra's algorithm: priority = g. Cost-optimal, uninformed.""" + + name = "dijkstra" + optimal = True + + def search(self, task, heuristic=None) -> SearchResult: + return best_first(task, lambda g, h: g, heuristic=None) + + +class GreedyBestFirstSearch(Planner): + """Greedy best-first: priority = h. Fast but not optimal.""" + + name = "gbfs" + requires_heuristic = True + + def search(self, task, heuristic=None) -> SearchResult: + return best_first(task, lambda g, h: h, heuristic=heuristic) + + +class AStarSearch(Planner): + """A*: priority = g + h. Cost-optimal with an admissible heuristic.""" + + name = "astar" + requires_heuristic = True + optimal = True + + def search(self, task, heuristic=None) -> SearchResult: + return best_first(task, lambda g, h: g + h, heuristic=heuristic) + + +class WeightedAStarSearch(Planner): + """Weighted A*: priority = g + w * h. Bounded-suboptimal for w > 1.""" + + name = "wastar" + requires_heuristic = True + + def __init__(self, weight: float = 2.0): + self.weight = weight + + def search(self, task, heuristic=None) -> SearchResult: + w = self.weight + return best_first(task, lambda g, h: g + w * h, heuristic=heuristic) diff --git a/jupyddl/search/enforced_hill_climbing.py b/jupyddl/search/enforced_hill_climbing.py new file mode 100644 index 0000000..810c29f --- /dev/null +++ b/jupyddl/search/enforced_hill_climbing.py @@ -0,0 +1,66 @@ +"""Enforced Hill Climbing (EHC), the search that made the FF planner famous. + +From each state it runs a breadth-first probe until it reaches a state with a +strictly better heuristic value, then commits to that path and repeats. It is a +fast satisficing strategy (not complete, not optimal); pair it with an +informative heuristic such as FF. +""" + +from __future__ import annotations + +import time +from collections import deque + +from .base import Planner +from .result import SearchResult, SearchStats + + +class EnforcedHillClimbing(Planner): + name = "ehc" + requires_heuristic = True + + def search(self, task, heuristic=None) -> SearchResult: + stats = SearchStats() + start = time.perf_counter() + current = task.init + current_h = heuristic(current) + stats.evaluated += 1 + plan: list = [] + cost = 0 + while not task.goal_reached(current): + state, ops, path_cost, new_h = self._probe( + task, current, current_h, heuristic, stats + ) + if state is None: # no strictly-improving state reachable + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) + plan.extend(ops) + cost += path_cost + current = state + current_h = new_h + stats.runtime = time.perf_counter() - start + return SearchResult(True, plan, cost, stats) + + def _probe(self, task, start, target_h, heuristic, stats): + """BFS from ``start`` for a state with ``h < target_h`` (or the goal).""" + visited = {start} + queue = deque([(start, [], 0)]) + while queue: + state, ops, cost = queue.popleft() + stats.expanded += 1 + for op in task.applicable_operators(state): + succ = op.apply(state) + stats.generated += 1 + if succ in visited: + continue + visited.add(succ) + new_ops = ops + [op] + new_cost = cost + op.cost + if task.goal_reached(succ): + return succ, new_ops, new_cost, 0 + value = heuristic(succ) + stats.evaluated += 1 + if value < target_h: + return succ, new_ops, new_cost, value + queue.append((succ, new_ops, new_cost)) + return None, None, None, None diff --git a/jupyddl/search/ida_star.py b/jupyddl/search/ida_star.py new file mode 100644 index 0000000..3f144b1 --- /dev/null +++ b/jupyddl/search/ida_star.py @@ -0,0 +1,68 @@ +"""Iterative-deepening A* (IDA*): optimal, memory-light informed search.""" + +from __future__ import annotations + +import math +import time + +from .base import Planner +from .node import extract_plan, make_child, make_root +from .result import SearchResult, SearchStats + + +class IDAStarSearch(Planner): + """IDA*: depth-first search bounded by an increasing ``f = g + h`` threshold. + + Cost-optimal with an admissible heuristic and uses memory linear in the + solution depth. + """ + + name = "idastar" + requires_heuristic = True + optimal = True + + def search(self, task, heuristic=None) -> SearchResult: + stats = SearchStats() + start = time.perf_counter() + hcache: dict = {} + + def h_of(state): + v = hcache.get(state) + if v is None: + v = heuristic(state) + hcache[state] = v + stats.evaluated += 1 + return v + + threshold = h_of(task.init) + root = make_root(task.init) + while not math.isinf(threshold): + found, nxt = self._dfs(task, root, threshold, h_of, stats, {task.init}) + if found is not None: + stats.runtime = time.perf_counter() - start + return SearchResult(True, extract_plan(found), found.g, stats) + threshold = nxt + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) + + def _dfs(self, task, node, threshold, h_of, stats, on_path): + f = node.g + h_of(node.state) + if f > threshold: + return None, f + if task.goal_reached(node.state): + return node, threshold + stats.expanded += 1 + minimum = math.inf + for op in task.applicable_operators(node.state): + succ = op.apply(node.state) + stats.generated += 1 + if succ in on_path: + continue + child = make_child(node, op, succ, op.cost) + on_path.add(succ) + found, nxt = self._dfs(task, child, threshold, h_of, stats, on_path) + on_path.discard(succ) + if found is not None: + return found, threshold + minimum = min(minimum, nxt) + return None, minimum diff --git a/jupyddl/search/node.py b/jupyddl/search/node.py new file mode 100644 index 0000000..850b76c --- /dev/null +++ b/jupyddl/search/node.py @@ -0,0 +1,33 @@ +"""Search-node helpers and plan reconstruction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class SearchNode: + state: frozenset + parent: Optional["SearchNode"] + action: object # Operator that produced this node (None for the root) + g: int # accumulated path cost + depth: int # number of actions from the root + + +def make_root(state: frozenset) -> SearchNode: + return SearchNode(state, None, None, 0, 0) + + +def make_child(parent: SearchNode, action, state: frozenset, cost: int) -> SearchNode: + return SearchNode(state, parent, action, parent.g + cost, parent.depth + 1) + + +def extract_plan(node: SearchNode) -> list: + """Walk parent pointers to recover the ordered list of operators.""" + plan = [] + while node is not None and node.action is not None: + plan.append(node.action) + node = node.parent + plan.reverse() + return plan diff --git a/jupyddl/search/result.py b/jupyddl/search/result.py new file mode 100644 index 0000000..ef1c46f --- /dev/null +++ b/jupyddl/search/result.py @@ -0,0 +1,45 @@ +"""Search result and statistics containers shared by all planners.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class SearchStats: + """Bookkeeping collected while searching (for comparative analysis).""" + + expanded: int = 0 + generated: int = 0 + evaluated: int = 0 # heuristic evaluations + reopened: int = 0 + deadends: int = 0 + runtime: float = 0.0 + + def as_dict(self) -> dict: + return { + "expanded": self.expanded, + "generated": self.generated, + "evaluated": self.evaluated, + "reopened": self.reopened, + "deadends": self.deadends, + "runtime": self.runtime, + } + + +@dataclass +class SearchResult: + """The outcome of a search: a plan (list of operators) plus statistics.""" + + solved: bool + plan: Optional[list] = None # list[Operator] + cost: Optional[int] = None + stats: SearchStats = field(default_factory=SearchStats) + + @property + def plan_length(self) -> Optional[int]: + return None if self.plan is None else len(self.plan) + + def plan_names(self) -> Optional[list]: + return None if self.plan is None else [op.name for op in self.plan] diff --git a/jupyddl/search/uninformed.py b/jupyddl/search/uninformed.py new file mode 100644 index 0000000..60c9249 --- /dev/null +++ b/jupyddl/search/uninformed.py @@ -0,0 +1,117 @@ +"""Uninformed planners: breadth-first, depth-first and iterative deepening.""" + +from __future__ import annotations + +import time +from collections import deque + +from .base import Planner +from .node import extract_plan, make_child, make_root +from .result import SearchResult, SearchStats + + +class BreadthFirstSearch(Planner): + """FIFO breadth-first graph search. Optimal in number of actions.""" + + name = "bfs" + optimal = True # for unit-cost / plan-length + + def search(self, task, heuristic=None) -> SearchResult: + stats = SearchStats() + start = time.perf_counter() + root = make_root(task.init) + if task.goal_reached(task.init): + stats.runtime = time.perf_counter() - start + return SearchResult(True, [], 0, stats) + visited = {task.init} + queue = deque([root]) + while queue: + node = queue.popleft() + stats.expanded += 1 + for op in task.applicable_operators(node.state): + succ = op.apply(node.state) + stats.generated += 1 + if succ in visited: + continue + child = make_child(node, op, succ, op.cost) + if task.goal_reached(succ): + stats.runtime = time.perf_counter() - start + return SearchResult(True, extract_plan(child), child.g, stats) + visited.add(succ) + queue.append(child) + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) + + +class DepthFirstSearch(Planner): + """LIFO depth-first graph search. Complete on finite spaces, not optimal.""" + + name = "dfs" + + def search(self, task, heuristic=None) -> SearchResult: + stats = SearchStats() + start = time.perf_counter() + root = make_root(task.init) + visited = {task.init} + stack = [root] + while stack: + node = stack.pop() + if task.goal_reached(node.state): + stats.runtime = time.perf_counter() - start + return SearchResult(True, extract_plan(node), node.g, stats) + stats.expanded += 1 + for op in task.applicable_operators(node.state): + succ = op.apply(node.state) + stats.generated += 1 + if succ in visited: + continue + visited.add(succ) + stack.append(make_child(node, op, succ, op.cost)) + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) + + +class IterativeDeepeningSearch(Planner): + """Iterative-deepening DFS. Optimal in plan length with low memory.""" + + name = "iddfs" + optimal = True + + def __init__(self, max_depth: int = 1000): + self.max_depth = max_depth + + def search(self, task, heuristic=None) -> SearchResult: + stats = SearchStats() + start = time.perf_counter() + for limit in range(self.max_depth + 1): + found, cutoff = self._dls( + task, make_root(task.init), limit, stats, {task.init} + ) + if found is not None: + stats.runtime = time.perf_counter() - start + return SearchResult(True, extract_plan(found), found.g, stats) + if not cutoff: # search exhausted without hitting the depth limit + break + stats.runtime = time.perf_counter() - start + return SearchResult(False, None, None, stats) + + def _dls(self, task, node, limit, stats, on_path): + if task.goal_reached(node.state): + return node, False + if node.depth == limit: + return None, True + stats.expanded += 1 + cutoff = False + for op in task.applicable_operators(node.state): + succ = op.apply(node.state) + stats.generated += 1 + if succ in on_path: + continue + child = make_child(node, op, succ, op.cost) + on_path.add(succ) + found, cut = self._dls(task, child, limit, stats, on_path) + on_path.discard(succ) + if found is not None: + return found, False + cutoff = cutoff or cut + return None, cutoff diff --git a/jupyddl/task.py b/jupyddl/task.py new file mode 100644 index 0000000..827e878 --- /dev/null +++ b/jupyddl/task.py @@ -0,0 +1,98 @@ +"""Grounded STRIPS(+conditional-effects) task representation. + +A :class:`Task` is a fully grounded planning problem where every fact is an +integer id (for fast set operations). Negative preconditions/goals are compiled +away into *positive normal form* by the grounder, so preconditions and goals are +purely positive sets of fact ids. Conditional effects are retained explicitly so +ADL domains such as *flip* can be solved. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CondEffect: + """A conditional effect: when ``condition`` holds, apply add/delete.""" + + condition: frozenset + add: frozenset + delete: frozenset + + +@dataclass(frozen=True) +class Operator: + """A grounded action over integer fact ids.""" + + name: str + precond: frozenset + add: frozenset + delete: frozenset + cond_effects: tuple = () + cost: int = 1 + + def applicable(self, state: frozenset) -> bool: + return self.precond <= state + + def apply(self, state: frozenset) -> frozenset: + """Return the successor state (conditions evaluated in ``state``).""" + dels = set(self.delete) + adds = set(self.add) + for ce in self.cond_effects: + if ce.condition <= state: + dels |= ce.delete + adds |= ce.add + # Adds take precedence over deletes (standard STRIPS semantics). + return frozenset((state - dels) | adds) + + +@dataclass +class Task: + """A grounded planning task.""" + + name: str + facts: tuple # id -> human-readable fact string + init: frozenset + goals: frozenset + operators: tuple + metric_cost: bool = False + + @property + def num_facts(self) -> int: + return len(self.facts) + + def goal_reached(self, state: frozenset) -> bool: + return self.goals <= state + + def applicable_operators(self, state: frozenset): + for op in self.operators: + if op.precond <= state: + yield op + + def fact_name(self, fact_id: int) -> str: + return self.facts[fact_id] + + def state_str(self, state: frozenset) -> str: + return "{" + ", ".join(sorted(self.facts[f] for f in state)) + "}" + + def relaxed_operators(self): + """Delete-relaxation operator view used by relaxation heuristics. + + Every conditional effect becomes its own relaxed operator whose + precondition is the action precondition conjoined with the effect + condition. This is the standard, sound treatment of conditional effects + under delete relaxation. Returns a list of ``(precond, add, cost)``. + """ + relaxed = [] + for op in self.operators: + if op.add: + relaxed.append((op.precond, op.add, op.cost)) + for ce in op.cond_effects: + if ce.add: + relaxed.append((op.precond | ce.condition, ce.add, op.cost)) + if not op.add and not op.cond_effects: + # Keep operators with only delete effects visible (no-op in the + # relaxation) so nothing downstream assumes non-empty adds. + relaxed.append((op.precond, frozenset(), op.cost)) + return relaxed diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9de11c4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "jupyddl" +version = "1.0.0" +description = "A pure-Python PDDL planning framework: parser, grounding, classical-to-SOTA planners, heuristics and benchmarking." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +authors = [{ name = "Erwin Lejeune" }] +keywords = ["pddl", "planning", "heuristic-search", "a-star", "lm-cut", "automated-planning"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +# The core framework is intentionally dependency-free (stdlib only) so it is +# trivial to embed and build on top of. +dependencies = [] + +[project.optional-dependencies] +viz = ["matplotlib>=3.5"] +dev = ["pytest>=7", "pytest-cov>=4", "flake8>=6", "black>=23"] + +[project.scripts] +jupyddl = "jupyddl.cli:main" + +[project.urls] +Homepage = "https://github.com/APLA-Toolbox/PythonPDDL" +Repository = "https://github.com/APLA-Toolbox/PythonPDDL" + +[tool.hatch.build.targets.wheel] +packages = ["jupyddl"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d8513e2..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -julia==0.5.7 -coloredlogs==15.0.1 -matplotlib==3.5.1 diff --git a/scripts/ipc.py b/scripts/ipc.py deleted file mode 100644 index 0e5103e..0000000 --- a/scripts/ipc.py +++ /dev/null @@ -1,162 +0,0 @@ -import sys -import logging -import coloredlogs -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner -from jupyddl.node import Path - -coloredlogs.install(level="WARNING") - -for a in sys.argv: - if "ipc.py" in a: - sys.argv.remove(a) - break - -if len(sys.argv) != 3: - logging.fatal("Binary should be ran with 3 arguments") - exit() - -domain = sys.argv[0] -problem = sys.argv[1] -output = sys.argv[2] - -apla = AutomatedPlanner(domain, problem, log_level="WARNING") - -logging.debug("Running Critical Path with H1") -path, metrics = apla.astar_best_first_search(heuristic_key="critical_path/1") -actions = apla.get_actions_from_path(path) -path = Path(path) - -logging.debug("Running Critical Path with H2") -path2, metrics2 = apla.astar_best_first_search(heuristic_key="critical_path/2") -actions2 = apla.get_actions_from_path(path2) -path2 = Path(path2) - -logging.debug("Running Critical Path with H3") -path3, metrics3 = apla.astar_best_first_search(heuristic_key="critical_path/3") -actions3 = apla.get_actions_from_path(path3) -path3 = Path(path3) - -logging.debug("Running Relaxed Critical Path with H1") -path4, metrics4 = apla.astar_best_first_search(heuristic_key="relaxed_critical_path/1") -actions4 = apla.get_actions_from_path(path4) -path4 = Path(path4) - -logging.debug("Running Relaxed Critical Path with H2") -path5, metrics5 = apla.astar_best_first_search(heuristic_key="relaxed_critical_path/2") -actions5 = apla.get_actions_from_path(path5) -path5 = Path(path5) - -logging.debug("Running Relaxed Critical Path with H3") -path6, metrics6 = apla.astar_best_first_search(heuristic_key="relaxed_critical_path/3") -actions6 = apla.get_actions_from_path(path6) -path6 = Path(path6) - -logging.debug("Running Delete Relaxation (HMax)") -path7, metrics7 = apla.astar_best_first_search(heuristic_key="delete_relaxation/h_max") -actions7 = apla.get_actions_from_path(path7) -path7 = Path(path7) - -logging.debug("Running Delete Relaxation (HAdd)") -path8, metrics8 = apla.astar_best_first_search(heuristic_key="delete_relaxation/h_add") -actions8 = apla.get_actions_from_path(path8) -path8 = Path(path8) - - -actions_str = "" -for a in actions: - actions_str += str(a) + "\n" - -actions_str2 = "" -for a in actions2: - actions_str2 += str(a) + "\n" - -actions_str3 = "" -for a in actions3: - actions_str3 += str(a) + "\n" - -actions_str4 = "" -for a in actions4: - actions_str4 += str(a) + "\n" - -actions_str5 = "" -for a in actions5: - actions_str5 += str(a) + "\n" - -actions_str6 = "" -for a in actions6: - actions_str6 += str(a) + "\n" - -actions_str7 = "" -for a in actions7: - actions_str7 += str(a) + "\n" - -actions_str8 = "" -for a in actions8: - actions_str8 += str(a) + "\n" - -dump = ( - "A* - Critical Path - H1\n ======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Critical Path - H2\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Critical Path - H3\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Relaxed Critical Path - H1\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Relaxed Critical Path - H2\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Relaxed Citical Path - H3\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Delete Relaxation - H_Max\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - "A* - Delete Relaxation - H_Add\n" - "======PLAN (Nodes)=======\n%s\n" - "======PLAN (Actions)=======\n%s\n" - "======METRICS=======\n%s\n\n" - % ( - str(path), - actions_str, - str(metrics), - str(path2), - actions_str2, - str(metrics2), - str(path3), - actions_str3, - str(metrics3), - str(path4), - actions_str4, - str(metrics4), - str(path5), - actions_str5, - str(metrics5), - str(path6), - actions_str6, - str(metrics6), - str(path7), - actions_str7, - str(metrics7), - str(path8), - actions_str8, - str(metrics8), - ) -) - -f = open(output, "w") -f.write(dump) -f.close() diff --git a/setup.py b/setup.py deleted file mode 100644 index e7dcb4e..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -import setuptools - -with open("requirements.txt") as f: - required = f.read().splitlines() - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - - -setuptools.setup( - name="jupyddl", # Replace with your own username - version="0.4.1", - author="Erwin Lejeune", - author_email="erwinlejeune.pro@gmail.com", - description="Jupyddl is a PDDL planner built on top of a Julia parser", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/apla-toolbox/pythonpddl", - packages=setuptools.find_packages(), - install_requires=required, - classifiers=[ - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3", - "License :: OSI Approved :: Apache Software License", - "Operating System :: Unix", - "Operating System :: MacOS", - "Framework :: Pytest", - ], - python_requires=">=3.6", -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6b25eb9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +"""Shared pytest fixtures and constants for the jupyddl test suite.""" + +from __future__ import annotations + +import os + +import pytest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +EXAMPLES = os.path.join(REPO_ROOT, "pddl-examples") + +# Known optimal costs for the example instances (validated across BFS, Dijkstra +# and A* with an admissible heuristic). +OPTIMAL_COST = { + "blocksworld": 2, + "dinner": 1, + "flip": 3, + "pallet": 12, + "switch": 3, + "tsp": 15, +} + +# Solvable instances without conditional effects (relaxation heuristics are +# admissible on these). ``flip`` is solvable but uses conditional effects. +STRIPS_SOLVABLE = ["blocksworld", "dinner", "pallet", "switch", "tsp"] +SOLVABLE = STRIPS_SOLVABLE + ["flip"] +UNSOLVABLE = ["vehicle"] # broken example data (typos): goal unreachable +UNSUPPORTED = ["grid"] # numeric fluents, out of scope + + +def paths(name: str): + folder = os.path.join(EXAMPLES, name) + return os.path.join(folder, "domain.pddl"), os.path.join(folder, "problem.pddl") + + +@pytest.fixture(scope="session") +def examples_available(): + if not os.path.isdir(EXAMPLES) or not os.listdir(EXAMPLES): + pytest.skip("pddl-examples submodule not initialised") + return EXAMPLES diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c74da5b --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,32 @@ +"""High-level API tests.""" + +from __future__ import annotations + +from jupyddl import build_task, solve, validate_plan + +from conftest import paths + + +def test_solve_end_to_end(examples_available): + d, p = paths("tsp") + result = solve(d, p, search="astar", heuristic="lmcut") + assert result.solved + assert result.cost == 15 + assert result.plan_names()[0].startswith("move(") + + +def test_validate_plan_rejects_broken_plan(examples_available): + task = build_task(*paths("blocksworld")) + result = solve(*paths("blocksworld"), search="bfs", heuristic=None) + assert validate_plan(task, result.plan) + # Dropping the first operator should make the plan invalid. + assert not validate_plan(task, result.plan[1:]) + + +def test_default_heuristic_for_informed_planner(examples_available): + # gbfs requires a heuristic; solve_task should supply a default if omitted. + from jupyddl import solve_task + + task = build_task(*paths("dinner")) + result = solve_task(task, "gbfs") # no heuristic given + assert result.solved diff --git a/tests/test_automated_planner.py b/tests/test_automated_planner.py deleted file mode 100644 index f6eec5a..0000000 --- a/tests/test_automated_planner.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner - - -def test_parsing(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - assert str(apla.problem) != "" and str(apla.domain) != "" - - -def test_available_actions(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - actions = apla.available_actions(apla.initial_state) - assert len(actions) > 0 - - -def test_execute_action(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - actions = apla.available_actions(apla.initial_state) - new_state = apla.transition(apla.initial_state, actions[0]) - assert str(new_state) != str(apla.initial_state) - - -def test_state_has_term(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - is_goal = apla.state_has_term(apla.initial_state, apla.goals[0]) - assert not is_goal - - -def test_state_assertion(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - assert not apla.satisfies(apla.problem.goal, apla.initial_state) - - -def test_bfs(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - path, metrics = apla.breadth_first_search() - plan = apla.get_actions_from_path(path) - plan_state = apla.get_state_def_from_path(path) - assert plan and plan_state and metrics.n_opened > 0 - - -def test_dfs(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - path, metrics = apla.depth_first_search() - plan = apla.get_actions_from_path(path) - plan_state = apla.get_state_def_from_path(path) - assert plan and plan_state and metrics.n_opened > 0 - - -def test_dij(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - path, metrics = apla.dijktra_best_first_search() - plan = apla.get_actions_from_path(path) - plan_state = apla.get_state_def_from_path(path) - assert plan and plan_state and metrics.n_opened > 0 - - -def test_astar(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - path, metrics = apla.astar_best_first_search() - plan = apla.get_actions_from_path(path) - plan_state = apla.get_state_def_from_path(path) - assert plan and plan_state and metrics.n_opened > 0 diff --git a/tests/test_basic_astar.py b/tests/test_basic_astar.py deleted file mode 100644 index 2ce4f77..0000000 --- a/tests/test_basic_astar.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner -from jupyddl.a_star import AStarBestFirstSearch -from jupyddl.heuristics import BasicHeuristic - - -def test_astar_basic(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = BasicHeuristic(apla, "basic/goal_count") - astar = AStarBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_astar_goal(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = BasicHeuristic(apla, "basic/goal_count") - astar = AStarBestFirstSearch(apla, heuristic.compute) - lastnode, metrics = astar.search() - assert lastnode and lastnode.parent and metrics.n_evaluated > 0 - - -def test_astar_path_length(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - path, _ = apla.astar_best_first_search() - assert len(path) > 0 - - -def test_astar_path_no_path(): - apla = AutomatedPlanner( - "pddl-examples/vehicle/domain.pddl", "pddl-examples/vehicle/problem.pddl" - ) - path, _ = apla.astar_best_first_search() - assert len(path) == 0 - - -def test_astar_path_no_heuristic(): - apla = AutomatedPlanner( - "pddl-examples/flip/domain.pddl", "pddl-examples/flip/problem.pddl" - ) - p, _ = apla.astar_best_first_search(heuristic_key="idontexist") - assert not p - - -def test_astar_path_bounded(): - apla = AutomatedPlanner( - "pddl-examples/flip/domain.pddl", "pddl-examples/flip/problem.pddl" - ) - p, _ = apla.astar_best_first_search(heuristic_key="idontexist", node_bound=1) - assert not p diff --git a/tests/test_basic_search.py b/tests/test_basic_search.py deleted file mode 100644 index 93cdac4..0000000 --- a/tests/test_basic_search.py +++ /dev/null @@ -1,122 +0,0 @@ -from jupyddl.automated_planner import AutomatedPlanner -from jupyddl.dijkstra import DijkstraBestFirstSearch, zero_heuristic -from jupyddl.a_star import AStarBestFirstSearch -from jupyddl.bfs import BreadthFirstSearch -from jupyddl.heuristics import BasicHeuristic, DeleteRelaxationHeuristic -from jupyddl.dfs import DepthFirstSearch -from os import path -import coloredlogs -import sys - - -def test_search_dfs(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - dfs = DepthFirstSearch(apla) - path, metrics = dfs.search() - assert path and metrics.n_evaluated > 0 - - -def test_search_dfs_bounded(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - dfs = DepthFirstSearch(apla) - path, _ = dfs.search(node_bound=1) - assert not path - - -def test_search_bfs(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - bfs = BreadthFirstSearch(apla) - path, metrics = bfs.search() # Path, computation time, opened nodes - assert path and metrics.n_evaluated > 0 - - -def test_search_bfs_bounded(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - bfs = BreadthFirstSearch(apla) - path, _ = bfs.search(node_bound=1) # Path, computation time, opened nodes - assert not path - - -def test_search_dijkstra(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - dijk = DijkstraBestFirstSearch(apla) - path, metrics = dijk.search() # Goal, computation_time, opened_nodes(in this order) - assert path and metrics.n_evaluated > 0 # Assert that it took some time to compute - - -def test_search_dijkstra_bounded(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - dijk = DijkstraBestFirstSearch(apla) - path, _ = dijk.search( - node_bound=1 - ) # Goal, computation_time, opened_nodes(in this order) - assert not path - - -def test_search_dijkstra_no_path(): - apla = AutomatedPlanner( - "pddl-examples/vehicle/domain.pddl", "pddl-examples/vehicle/problem.pddl" - ) - dijk = DijkstraBestFirstSearch(apla) - path, metrics = dijk.search() # Goal, computation_time, opened_nodes(in this order) - assert not path and metrics.n_evaluated > 0 - - -def test_search_dfs_no_path(): - apla = AutomatedPlanner( - "pddl-examples/vehicle/domain.pddl", "pddl-examples/vehicle/problem.pddl" - ) - dfs = DepthFirstSearch(apla) - path, metrics = dfs.search() # Goal, computation_time, opened_nodes(in this order) - assert not path and metrics.n_evaluated > 0 - - -def test_search_bfs_no_path(): - apla = AutomatedPlanner( - "pddl-examples/vehicle/domain.pddl", "pddl-examples/vehicle/problem.pddl" - ) - bfs = BreadthFirstSearch(apla) - path, metrics = bfs.search() # Goal, computation_time, opened_nodes(in this order) - assert not path and metrics.n_evaluated > 0 - - -def test_search_astar_basic(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = BasicHeuristic(apla, "basic/goal_count") - astar = AStarBestFirstSearch(apla, heuristic.compute) - ( - path, - metrics, - ) = astar.search() # Goal, computation_time, opened_nodes(in this order) - assert path and metrics.n_evaluated > 0 - - -def test_search_astar_basic_no_path(): - apla = AutomatedPlanner( - "pddl-examples/vehicle/domain.pddl", "pddl-examples/vehicle/problem.pddl" - ) - heuristic = BasicHeuristic(apla, "basic/goal_count") - astar = AStarBestFirstSearch(apla, heuristic.compute) - ( - path, - metrics, - ) = astar.search() # Goal, computation_time, opened_nodes(in this order) - assert not path and metrics.n_evaluated > 0 - - -def test_zero_heuristic(): - assert zero_heuristic() == 0 diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 0000000..9d34cb8 --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,52 @@ +"""Benchmark harness tests.""" + +from __future__ import annotations + +import csv + +from jupyddl.benchmark import ( + discover_instances, + run_benchmark, + summarize, + to_csv, +) + +from conftest import EXAMPLES + + +def test_discover_instances(examples_available): + names = {inst.name for inst in discover_instances(EXAMPLES)} + assert {"blocksworld", "dinner", "tsp"} <= names + + +def test_run_benchmark_and_csv(tmp_path, examples_available): + instances = [ + i + for i in discover_instances(EXAMPLES) + if i.name in ("dinner", "tsp", "grid", "vehicle") + ] + configs = [("astar", "lmcut"), ("gbfs", "hff")] + rows = run_benchmark(instances, configs) + assert len(rows) == len(instances) * len(configs) + + by_key = {(r.instance, r.planner): r for r in rows} + assert by_key[("tsp", "astar")].valid + assert by_key[("tsp", "astar")].cost == 15 + # grid is unsupported -> recorded with an error, not solved. + assert by_key[("grid", "astar")].error + assert not by_key[("grid", "astar")].solved + # vehicle is unsolvable -> solved False but no crash. + assert not by_key[("vehicle", "astar")].solved + + out = tmp_path / "results.csv" + to_csv(rows, str(out)) + with open(out, newline="", encoding="utf-8") as handle: + loaded = list(csv.DictReader(handle)) + assert len(loaded) == len(rows) + + +def test_summarize_coverage(examples_available): + instances = [i for i in discover_instances(EXAMPLES) if i.name == "tsp"] + rows = run_benchmark(instances, [("bfs", None)]) + summary = summarize(rows) + assert summary["bfs"]["coverage"] == 1 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5576d95 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,51 @@ +"""CLI tests for the ``solve`` and ``benchmark`` subcommands.""" + +from __future__ import annotations + +from jupyddl.cli import main + +from conftest import EXAMPLES, paths + + +def test_cli_solve_success(capsys, examples_available): + d, p = paths("tsp") + rc = main(["solve", d, p, "-s", "astar", "-H", "lmcut"]) + out = capsys.readouterr().out + assert rc == 0 + assert "cost 15" in out + assert "Valid: True" in out + + +def test_cli_solve_no_plan(capsys, examples_available): + d, p = paths("vehicle") # broken data -> unsolvable + rc = main(["solve", d, p, "-s", "bfs", "-H", "none"]) + out = capsys.readouterr().out + assert rc == 1 + assert "No plan found" in out + + +def test_cli_benchmark(capsys, tmp_path, examples_available): + csv_path = tmp_path / "out.csv" + rc = main( + [ + "benchmark", + EXAMPLES, + "--planners", + "bfs,astar,gbfs", + "--heuristic", + "hff", + "--csv", + str(csv_path), + ] + ) + out = capsys.readouterr().out + assert rc == 0 + assert "coverage" in out + assert csv_path.exists() + + +def test_cli_solve_weighted_astar(capsys, examples_available): + d, p = paths("pallet") + rc = main(["solve", d, p, "-s", "wastar", "-H", "hff", "-w", "3"]) + assert rc == 0 + assert "Valid: True" in capsys.readouterr().out diff --git a/tests/test_data_analyst.py b/tests/test_data_analyst.py deleted file mode 100644 index 02fcf2e..0000000 --- a/tests/test_data_analyst.py +++ /dev/null @@ -1,203 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.data_analyst import DataAnalyst - - -def test_data_analyst_constructor(): - _ = DataAnalyst() - assert True - - -def test_heuristics_comparer(): - da = DataAnalyst() - da.comparative_astar_heuristic_plot() - - -def test_heuristics_comparer_single(): - da = DataAnalyst() - da.comparative_astar_heuristic_plot( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - ) - - -def test_data_analyst_plot_dfs_one_pddl(): - da = DataAnalyst() - da.plot_dfs( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - ) - assert True - - -def test_data_analyst_plot_bfs_one_pddl(): - da = DataAnalyst() - da.plot_bfs( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - ) - assert True - - -def test_data_analyst_plot_dijkstra_one_pddl(): - da = DataAnalyst() - da.plot_dijkstra( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - ) - assert True - - -def test_data_analyst_plot_astar_h_goal_count_one_pddl(): - da = DataAnalyst() - da.plot_astar( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - ) - assert True - - -def test_data_analyst_plot_dfs(): - da = DataAnalyst() - da.plot_dfs() - assert True - - -def test_data_analyst_plot_bfs(): - da = DataAnalyst() - da.plot_bfs() - assert True - - -def test_data_analyst_plot_dijkstra(): - da = DataAnalyst() - da.plot_dijkstra() - assert True - - -def test_data_analyst_plot_astar_h_goal_count(): - da = DataAnalyst() - da.plot_astar() - assert True - - -def test_data_analyst_plot_dfs_restricted(): - da = DataAnalyst() - da.plot_dfs(max_pddl_instances=2) - assert True - - -def test_data_analyst_plot_bfs_restricted(): - da = DataAnalyst() - da.plot_bfs(max_pddl_instances=2) - assert True - - -def test_data_analyst_plot_dijkstra_restricted(): - da = DataAnalyst() - da.plot_dijkstra(max_pddl_instances=2) - assert True - - -def test_data_analyst_plot_astar_h_goal_count_restricted(): - da = DataAnalyst() - da.plot_astar(max_pddl_instances=2) - assert True - - -def test_data_analyst_plot_astar_h_max(): - da = DataAnalyst() - da.plot_astar(heuristic_key="delete_relaxation/h_max") - assert True - - -def test_data_analyst_plot_greedy_h_goal_count_restricted(): - da = DataAnalyst() - da.plot_greedy_bfs(max_pddl_instances=2) - assert True - - -def test_data_analyst_plot_greedy_hmax(): - da = DataAnalyst() - da.plot_greedy_bfs(heuristic_key="delete_relaxation/h_max") - assert True - - -def test_comparative_no_restrictions(): - da = DataAnalyst() - da.comparative_data_plot() - assert True - - -def test_comparative_no_astar(): - da = DataAnalyst() - da.comparative_data_plot(astar=False) - assert True - - -def test_comparative_no_bfs(): - da = DataAnalyst() - da.comparative_data_plot(bfs=False) - assert True - - -def test_comparative_no_dijkstra(): - da = DataAnalyst() - da.comparative_data_plot(dijkstra=False) - assert True - - -def test_comparative_no_dfs(): - da = DataAnalyst() - da.comparative_data_plot(dfs=False) - assert True - - -def test_comparative_one_pddl(): - da = DataAnalyst() - da.comparative_data_plot( - dfs=False, - bfs=False, - greedy_bfs=True, - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - ) - assert True - - -def test_comparative_use_data_json(): - da = DataAnalyst() - da.comparative_data_plot( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - greedy_bfs=True, - collect_new_data=False, - ) - assert True - - -def test_comparative_zero_h(): - da = DataAnalyst() - da.comparative_data_plot( - domain="pddl-examples/dinner/domain.pddl", - problem="pddl-examples/dinner/problem.pddl", - greedy_bfs=True, - heuristic_key="zero", - ) - assert True - - -def test_success_rate(): - da = DataAnalyst() - da.compute_planners_efficiency() - assert True - - -def test_metrics(): - da = DataAnalyst() - da.plot_metrics() - assert True diff --git a/tests/test_greedy_best_first.py b/tests/test_greedy_best_first.py deleted file mode 100644 index 3429934..0000000 --- a/tests/test_greedy_best_first.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner -from jupyddl.greedy_best_first import GreedyBestFirstSearch -from jupyddl.heuristics import BasicHeuristic, DeleteRelaxationHeuristic - - -def test_greedy_best_first_basic(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = BasicHeuristic(apla, "basic/goal_count") - gbfs = GreedyBestFirstSearch(apla, heuristic.compute) - assert gbfs.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_greedy_best_first_goal(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = BasicHeuristic(apla, "basic/goal_count") - gbfs = GreedyBestFirstSearch(apla, heuristic.compute) - lastnode, _ = gbfs.search() - assert lastnode and lastnode.parent - - -def test_greedy_best_first_path_length(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - path, _ = apla.greedy_best_first_search() - assert len(path) > 0 - - -def test_greedy_best_first_bounded(): - apla = AutomatedPlanner( - "pddl-examples/tsp/domain.pddl", "pddl-examples/tsp/problem.pddl" - ) - path, _ = apla.greedy_best_first_search(node_bound=1) - assert not path - - -def test_greedy_best_first_path_no_path(): - apla = AutomatedPlanner( - "pddl-examples/vehicle/domain.pddl", "pddl-examples/vehicle/problem.pddl" - ) - path, metrics = apla.greedy_best_first_search() - assert not path and metrics.n_evaluated > 0 - - -def test_greedy_best_first_path_no_heuristic(): - apla = AutomatedPlanner( - "pddl-examples/flip/domain.pddl", "pddl-examples/flip/problem.pddl" - ) - p, _ = apla.greedy_best_first_search(heuristic_key="idontexist") - assert not p - - -def test_greedy_best_first_hmax(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = GreedyBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_greedy_best_first_hadd(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = GreedyBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_greedy_best_first_hmax_sensible_domain(): - apla = AutomatedPlanner( - "pddl-examples/grid/domain.pddl", "pddl-examples/grid/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = GreedyBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_greedy_best_first_hadd_sensible_domain(): - apla = AutomatedPlanner( - "pddl-examples/grid/domain.pddl", "pddl-examples/grid/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = GreedyBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) diff --git a/tests/test_grounding.py b/tests/test_grounding.py new file mode 100644 index 0000000..bf0df04 --- /dev/null +++ b/tests/test_grounding.py @@ -0,0 +1,49 @@ +"""Grounding tests: typing, PNF, static pruning, object harvesting.""" + +from __future__ import annotations + +import pytest + +from jupyddl.grounding import ground_files +from jupyddl.parser import UnsupportedFeatureError + +from conftest import SOLVABLE, UNSUPPORTED, paths + + +@pytest.mark.parametrize("name", SOLVABLE) +def test_examples_ground(name, examples_available): + task = ground_files(*paths(name)) + assert task.num_facts > 0 + assert task.operators, "expected at least one grounded operator" + assert task.goals, "expected a non-empty goal" + + +def test_object_harvesting_untyped(examples_available): + # dinner declares no :objects; constants appear only in :init. + task = ground_files(*paths("dinner")) + assert len(task.operators) > 0 + + +def test_negative_precondition_pnf(examples_available): + # tsp uses :negative-preconditions -> complement facts must appear. + task = ground_files(*paths("tsp")) + assert any(name.startswith("(not ") for name in task.facts) + + +def test_apply_operator_reaches_goal(examples_available): + task = ground_files(*paths("blocksworld")) + state = task.init + # Manually applying any applicable operator changes the state. + op = next(task.applicable_operators(state)) + assert op.apply(state) != state + + +@pytest.mark.parametrize("name", UNSUPPORTED) +def test_unsupported_examples(name, examples_available): + with pytest.raises(UnsupportedFeatureError): + ground_files(*paths(name)) + + +def test_conditional_effects_present(examples_available): + task = ground_files(*paths("flip")) + assert any(op.cond_effects for op in task.operators) diff --git a/tests/test_heuristics.py b/tests/test_heuristics.py index 4c20431..17144e9 100644 --- a/tests/test_heuristics.py +++ b/tests/test_heuristics.py @@ -1,44 +1,64 @@ -# -*- coding: utf-8 -*- +"""Heuristic tests: admissibility, dominance, goal/dead-end behaviour.""" -import sys -from os import path +from __future__ import annotations -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner -import jupyddl.heuristics as hs +import math -""" -Testing the heuristics in different situations -To do: - - Run search algorithms and test value of h when at goal -""" +import pytest +from jupyddl.api import build_task +from jupyddl.heuristics import HEURISTICS, make_heuristic -def test_zero_heuristic(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - apla.display_available_heuristics() - heuristic = hs.BasicHeuristic(apla, "basic/zero") - h = heuristic.compute(apla.initial_state) - assert h == 0 +from conftest import OPTIMAL_COST, STRIPS_SOLVABLE, paths +ADMISSIBLE = ["blind", "hmax", "h1", "h2", "lmcut"] -def test_goal_count_heuristic(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - apla.display_available_heuristics() - heuristic = hs.BasicHeuristic(apla, "basic/goal_count") - h = heuristic.compute(apla.initial_state) - assert h != 0 + +@pytest.mark.parametrize("name", STRIPS_SOLVABLE) +@pytest.mark.parametrize("hname", ADMISSIBLE) +def test_admissible_heuristics_never_overestimate(name, hname, examples_available): + task = build_task(*paths(name)) + value = make_heuristic(hname, task)(task.init) + assert value <= OPTIMAL_COST[name] + 1e-9, f"{hname} on {name}: {value}" -def test_delete_relaxation_add_heuristic(): - apla = AutomatedPlanner( - "pddl-examples/tsp/domain.pddl", "pddl-examples/tsp/problem.pddl" +@pytest.mark.parametrize("name", STRIPS_SOLVABLE) +def test_h1_equals_hmax(name, examples_available): + task = build_task(*paths(name)) + assert ( + abs( + make_heuristic("h1", task)(task.init) + - make_heuristic("hmax", task)(task.init) + ) + < 1e-9 ) - apla.display_available_heuristics() - heuristic = hs.DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - h = heuristic.compute(apla.initial_state) - assert h != 0 + + +@pytest.mark.parametrize("name", STRIPS_SOLVABLE) +def test_lmcut_dominates_hmax(name, examples_available): + task = build_task(*paths(name)) + lmcut = make_heuristic("lmcut", task)(task.init) + hmax = make_heuristic("hmax", task)(task.init) + assert lmcut >= hmax - 1e-9 + + +@pytest.mark.parametrize("hname", sorted(HEURISTICS)) +def test_zero_in_goal_state(hname, examples_available): + task = build_task(*paths("blocksworld")) + heuristic = make_heuristic(hname, task) + goal_state = task.init | task.goals # relaxed goal-satisfying state + assert task.goal_reached(goal_state) + assert heuristic(goal_state) == 0 + + +def test_deadend_detected_as_infinite(examples_available): + # The broken vehicle instance has an unreachable goal fact. + task = build_task(*paths("vehicle")) + assert math.isinf(make_heuristic("hmax", task)(task.init)) + assert math.isinf(make_heuristic("lmcut", task)(task.init)) + + +def test_goalcount_counts_open_goals(examples_available): + task = build_task(*paths("pallet")) + gc = make_heuristic("goalcount", task) + assert gc(task.init) == len(task.goals - task.init) diff --git a/tests/test_hsp_astar.py b/tests/test_hsp_astar.py deleted file mode 100644 index aa3a7cd..0000000 --- a/tests/test_hsp_astar.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner -from jupyddl.a_star import AStarBestFirstSearch -from jupyddl.heuristics import DeleteRelaxationHeuristic - - -def test_astar_hmax(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = AStarBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_astar_hadd(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = AStarBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_astar_hmax_sensible_domain(): - apla = AutomatedPlanner( - "pddl-examples/grid/domain.pddl", "pddl-examples/grid/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = AStarBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) - - -def test_astar_hadd_sensible_domain(): - apla = AutomatedPlanner( - "pddl-examples/grid/domain.pddl", "pddl-examples/grid/problem.pddl" - ) - heuristic = DeleteRelaxationHeuristic(apla, "delete_relaxation/h_max") - astar = AStarBestFirstSearch(apla, heuristic.compute) - assert astar.init.h_cost == heuristic.compute(apla.initial_state) diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index 9c99037..0000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.metrics import Metrics - - -def test_metrics(): - m = Metrics() - assert m.n_opened == 1 and m.n_generated and m.get_average_heuristic_runtime() == 0 diff --git a/tests/test_node.py b/tests/test_node.py deleted file mode 100644 index 6d5ea37..0000000 --- a/tests/test_node.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -from os import path - -sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from jupyddl.automated_planner import AutomatedPlanner -from jupyddl.node import Node, Path - - -def test_node_equality_cost(): - apla = AutomatedPlanner( - "pddl-examples/tsp/domain.pddl", "pddl-examples/tsp/problem.pddl" - ) - actions = apla.available_actions(apla.initial_state) - next_state = apla.transition(apla.initial_state, actions[0]) - next_node = Node(next_state, apla, heuristic_based=True) - next_node_v2 = Node(next_state, apla) - - assertion = next_node_v2 < next_node - assertion2 = next_node < next_node_v2 - - assert assertion and assertion2 - - -def test_node_equality_no_cost(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - actions = apla.available_actions(apla.initial_state) - next_state = apla.transition(apla.initial_state, actions[0]) - next_node = Node(next_state, apla, heuristic_based=True) - next_node_v2 = Node(next_state, apla) - - assertion = next_node_v2 < next_node - assertion2 = next_node < next_node_v2 - - assert assertion and assertion2 - - -def test_stringified_node(): - apla = AutomatedPlanner( - "pddl-examples/dinner/domain.pddl", "pddl-examples/dinner/problem.pddl" - ) - actions = apla.available_actions(apla.initial_state) - for act in actions: - next_state = apla.transition(apla.initial_state, act) - next_node = Node(next_state, apla, heuristic_based=True) - assert "