diff --git a/.docs/RELEASING.md b/.docs/RELEASING.md index 1f640ae..34fc817 100644 --- a/.docs/RELEASING.md +++ b/.docs/RELEASING.md @@ -1,46 +1,53 @@ # Releasing jupyddl -## The normal way: merge the release PR - -Releases are **semantic and automatic**. Nobody picks a version number. - -1. Every pull request is squash-merged, so its **title** becomes the commit - on `main`. The `pr-title` check requires a - [Conventional Commit](https://www.conventionalcommits.org) title: - - | Title | Release it triggers | - |---|---| - | `fix: …`, `fix(lmcut): …`, `perf: …` | patch, 2.4.0 → 2.4.1 | - | `feat: …` | minor, 2.4.0 → 2.5.0 | - | `feat!: …`, or a `BREAKING CHANGE:` footer | major, 2.4.0 → 3.0.0 | - | `docs:`, `chore:`, `ci:`, `test:`, `refactor:`, `build:`, `style:` | none on its own; it rides along with the next one | - -2. On every push to `main`, `release-please.yml` keeps **one release PR** - (`chore: release X.Y.Z`) up to date. It bumps `pyproject.toml` and - `jupyddl.__version__`, writes the changelog section from the merged - titles, files the hand-written `## [Unreleased]` notes under it, and - rebuilds `web/dist`, which records the version. The bump is the largest any - merged title asks for. -3. **Merging the release PR is the release.** release-please tags the merge - commit and creates the GitHub Release. It then starts `release.yml` on that - tag, which builds, verifies, publishes to PyPI and attaches the artifacts, - with the same checks as below. - -Want a release after every merge? Merge the release PR right after. Want -batches? Let it collect. Either way the version follows from what went in. - -**One-time repository settings** (a maintainer has to do these): - -- *Settings → Actions → General → Workflow permissions*: tick **Allow GitHub - Actions to create and approve pull requests**. Without it release-please - cannot open its PR. -- *Settings → General → Pull Requests*: allow squash merging and set its - default commit message to **Pull request title**, so the checked title is - what lands on `main`. -- Do **not** make `pr-title` (or `tests`) a required check without an - exception for release-please's PR. A PR opened with the workflow's own - token starts no workflows, so those checks never report on it. - `release.yml` repeats every check that matters before publishing. +## The normal way: merge a pull request + +Releases are **semantic and automatic**. Nobody picks a version number, and +nobody approves anything. **Merging a pull request is the release decision.** + +On every push to `main`, `release.yml` looks up the pull request that was just +merged and takes the largest bump that its title and its commits ask for +(`tools/release.py`): + +| Commit or title | Release | +|---|---| +| `fix: …`, `fix(lmcut): …`, `perf: …` | patch, 2.4.0 → 2.4.1 | +| `feat: …` | minor, 2.4.0 → 2.5.0 | +| `feat!: …`, `fix!: …`, or a `BREAKING CHANGE:` footer | major, 2.4.0 → 3.0.0 | +| `docs:`, `chore:`, `ci:`, `test:`, `refactor:`, `build:`, `style:` | none | + +- **Override with a label.** Add `release:major`, `release:minor`, + `release:patch` or `release:none` to the pull request before merging, for + the rare change whose commits say the wrong thing. +- **Nothing is lost.** Commits on `main` since the last `v*` tag count too. + So a merge whose run was dropped, because several merges landed at once, + still releases with the next one. + +When a release is due, the workflow: + +1. bumps `pyproject.toml`, `jupyddl.__version__` and `CITATION.cff`; +2. turns `## [Unreleased]` in `CHANGELOG.md` into `## [X.Y.Z] - date`, using + the PR title if nobody wrote an entry; +3. rebuilds `web/dist`, which records the version; +4. commits that to `main` as `chore(release): X.Y.Z`, tags `vX.Y.Z`, and + starts `pages`; +5. builds, verifies, publishes to PyPI and cuts the GitHub Release, with every + check described below. + +It pushes with the workflow's own token, which is allowed to push commits and +tags. That is all it needs: there is no release pull request, so the *Allow +GitHub Actions to create pull requests* setting is not needed. Those pushes +start no workflows, so nothing loops. + +**Requirements:** + +- Squash-merge pull requests with the **PR title** as the commit message + (*Settings → General → Pull Requests*). The title is what `pr-title` + checks. +- `main` must accept pushes from GitHub Actions. If you add branch protection, + let `github-actions[bot]` push, as the `format` workflow already needs. +- The `pypi` environment must have no required reviewers, or every release + waits for one. ## By hand diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cc1f455..3c86973 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -48,6 +48,10 @@ check enforces this: - `feat!: drop Python 3.10` → major release - `docs: …`, `test: …`, `chore: …`, `ci: …`, `refactor: …` → no release on their own +Merging *is* releasing: the largest bump asked for by the title or any commit +in the PR ships as soon as it lands on `main`. A `release:major|minor|patch|none` +label overrides that when the commits say the wrong thing. + Write the user-facing story under `## [Unreleased]` in `CHANGELOG.md` as well. The title is one line; the changelog entry is where you explain. [`.docs/RELEASING.md`](../.docs/RELEASING.md) covers the rest. diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index cbddcc6..dc4007c 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -1,8 +1,9 @@ name: pr-title # Pull requests are squash-merged, so the title becomes the commit on main -- -# and release-please reads that commit to decide the next version. A title -# that is not a Conventional Commit is a change the release never mentions. +# and release.yml reads it (with the PR's own commits) to decide whether +# merging releases a new version. A title that is not a Conventional Commit +# asks for no release at all. # # feat: add the GOOSE feature extractor -> minor release # fix(lmcut): handle an empty goal -> patch release @@ -22,7 +23,7 @@ jobs: name: conventional title runs-on: ubuntu-latest steps: - - name: Check the title release-please will read + - name: Check the title release.yml will read env: TITLE: ${{ github.event.pull_request.title }} run: | diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index caed8a8..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: release-please - -# Semantic versioning from what was merged, with no one choosing a number. -# -# Every pull request is squash-merged, so its *title* becomes the commit on -# main, and `pr-title.yml` makes that title a Conventional Commit. From those, -# release-please keeps one open "release PR" up to date: -# -# fix: ... -> patch (2.4.0 -> 2.4.1) -# feat: ... -> minor (2.4.0 -> 2.5.0) -# feat!: ... / a "BREAKING CHANGE:" footer -> major (2.4.0 -> 3.0.0) -# docs:, chore:, ci:, test:, refactor: ... -> no release on their own -# -# The release PR bumps pyproject.toml and jupyddl.__version__, writes the -# changelog section, and (job `finish-release-pr`) files the hand-written -# [Unreleased] notes under it and rebuilds web/dist, which records the -# version. Merging it is the release decision: release-please then tags the -# commit and creates the GitHub Release, and `publish` hands the tag to -# `release.yml`, which builds, verifies and publishes to PyPI exactly as a -# hand-pushed tag would. -# -# Why a dispatch rather than letting the tag trigger release.yml: a tag or -# release created with the workflow's own GITHUB_TOKEN starts no workflows, -# by GitHub's design. workflow_dispatch is the exception, so this is the one -# hand-off that works without a stored personal token. - -on: - push: - branches: [main] - -permissions: - contents: write # release-please: the tag, the release, its branch - pull-requests: write # release-please: open and update the release PR - -concurrency: - group: release-please - cancel-in-progress: false - -jobs: - release-please: - runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - pr: ${{ steps.release.outputs.pr }} - steps: - - id: release - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 - with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - - finish-release-pr: - needs: release-please - if: >- - needs.release-please.outputs.pr != '' - && needs.release-please.outputs.release_created != 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }} - submodules: false - - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 - with: - python-version: '3.12' - - - name: File the hand-written notes and rebuild web/dist - run: python tools/release_pr_fixup.py - - - name: Push the result to the release PR - run: | - if git diff --quiet; then - echo "Release PR already complete." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -am "chore: file the release notes and rebuild web/dist" - git push - - publish: - needs: release-please - if: needs.release-please.outputs.release_created == 'true' - runs-on: ubuntu-latest - permissions: - actions: write # start release.yml - steps: - - name: Hand the new tag to the release workflow - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.release-please.outputs.tag_name }} - run: | - gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f target=pypi - echo "Started release.yml for $TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7819a4e..ed1a266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,36 +1,38 @@ name: release -# Publish a release to PyPI and cut the GitHub Release to match. +# Every merge to main can be a release, and nobody has to approve anything. # -# Normally nobody starts this by hand: merging release-please's release PR -# tags the commit and `release-please.yml` dispatches this workflow on that -# tag (see the comment there for why it is a dispatch). The paths below still -# work and run exactly the same checks. +# On a push to main, `plan` looks up the pull request that was just merged and +# picks the bump its commits ask for (tools/release.py, Conventional Commits): # -# Push a tag and this does the rest: +# feat!: / fix!: / a "BREAKING CHANGE:" footer -> major +# feat: -> minor +# fix: / perf: -> patch +# docs:, chore:, ci:, test:, refactor: ... -> no release # -# git tag -a v2.3.0 -m "jupyddl 2.3.0" && git push origin v2.3.0 +# taking the largest over the PR title, every commit in it, and every commit +# on main since the last tag (so a merge whose run was dropped as a duplicate +# pending run still counts). A `release:major|minor|patch|none` label on the +# PR overrides that. # -# Authentication is PyPI **Trusted Publishing** (OIDC), so there is no API -# token in this repository's secrets to leak or rotate. That requires a -# one-time setup on PyPI — see `.docs/RELEASING.md`. Until it is done the -# `pypi` job fails and everything before it still succeeds, so a tag never -# leaves you with half a release and no artifacts. +# `bump` then bumps pyproject.toml, jupyddl.__version__ and CITATION.cff, +# dates the [Unreleased] changelog section, rebuilds web/dist (which records +# the version), commits that to main and tags it -- with the workflow's own +# token, which may push but may not open pull requests. That is the whole +# trick: no release PR, so no "allow Actions to create pull requests" setting +# and no approval. Its pushes start no workflows, so nothing loops; it starts +# `pages` explicitly so the site shows the new version. # -# A release can also be cut entirely from the Actions tab, without anyone -# pushing a tag from a laptop: +# The same build, checks and PyPI publish also run on a hand-pushed `v*` tag, +# or from the Actions tab (target `testpypi` rehearses without tagging). # -# Actions -> release -> Run workflow -> target: pypi -# -# That path creates the tag itself, at the commit it built and tested, so the -# tag can never point at something that was never verified. Choosing -# `testpypi` instead runs the identical pipeline against TestPyPI and creates -# no tag, which is how to validate a change to this file without spending a -# real version number. PyPI releases are effectively permanent: a version can -# be yanked but never replaced, so the dry run is worth the two minutes. +# Authentication is PyPI **Trusted Publishing** (OIDC) for this file, +# release.yml, in the `pypi` environment -- no stored token. See +# `.docs/RELEASING.md` for the one-time PyPI setup. on: push: + branches: [main] tags: ["v*"] workflow_dispatch: inputs: @@ -41,22 +43,121 @@ on: type: choice options: [testpypi, pypi] +# One release at a time, in merge order, never cancelled halfway. concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false permissions: contents: read jobs: + plan: + name: decide what to release + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + part: ${{ steps.plan.outputs.part }} + note: ${{ steps.plan.outputs.note }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + submodules: false + fetch-depth: 0 # the commits since the last tag count too + + - id: plan + env: + GH_TOKEN: ${{ github.token }} + HEAD_MESSAGE: ${{ github.event.head_commit.message }} + run: | + pr="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0] // empty')" + pr="${pr:-null}" + number="$(jq -r '.number // empty' <<<"$pr")" + commits='[]' + if [ -n "$number" ]; then + commits="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${number}/commits" --paginate \ + --jq '[.[].commit.message]' | jq -s 'add // []')" + fi + jq -n --argjson pr "$pr" --argjson commits "$commits" --arg head "$HEAD_MESSAGE" \ + '{labels: [($pr.labels // [])[].name], + messages: ([$head, ($pr.title // "")] + $commits)}' > pr.json + part="$(python3 tools/release.py plan --since-last-tag < pr.json)" + title="$(jq -r '.title // empty' <<<"$pr")" + note="${title}${number:+ (#${number})}" + echo "PR ${number:-none}: ${title:-$(head -n1 <<<"$HEAD_MESSAGE")} -> release: ${part}" + if [ "$part" != none ]; then echo "part=${part}" >> "$GITHUB_OUTPUT"; fi + { + echo "note<<__NOTE__" + echo "${note:-$(head -n1 <<<"$HEAD_MESSAGE")}" + echo "__NOTE__" + } >> "$GITHUB_OUTPUT" + + bump: + name: bump, commit and tag + needs: plan + if: needs.plan.outputs.part != '' + runs-on: ubuntu-latest + permissions: + contents: write # push the release commit and its tag to main + actions: write # start `pages`: our own push starts no workflows + outputs: + tag: ${{ steps.bump.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: main + fetch-depth: 0 + submodules: false + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 + with: + python-version: '3.12' + + - id: bump + env: + PART: ${{ needs.plan.outputs.part }} + NOTE: ${{ needs.plan.outputs.note }} + GH_TOKEN: ${{ github.token }} + run: | + version="$(python tools/release.py apply "$PART" --note "$NOTE" | tail -n1)" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -qam "chore(release): ${version}" + git tag -a "v${version}" -m "jupyddl ${version}" + # Another push may have landed on main meanwhile (the format job); + # replay the release commit on top of it once rather than fail. + if ! git push origin HEAD:main; then + git fetch origin main + git rebase origin/main + git tag -f -a "v${version}" -m "jupyddl ${version}" + git push origin HEAD:main + fi + git push origin "v${version}" + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main || \ + echo "::warning::could not start pages; it will deploy on the next push" + build: name: build and verify + needs: [plan, bump] + # A merge that asked for a release, a pushed tag, or a run from the tab. + if: >- + !cancelled() + && (needs.bump.result == 'success' + || startsWith(github.ref, 'refs/tags/') + || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ needs.bump.outputs.tag || github.ref }} submodules: false fetch-depth: 0 @@ -66,12 +167,18 @@ jobs: - name: Read the version the package declares id: version + env: + BUMP_TAG: ${{ needs.bump.outputs.tag }} run: | version=$(python -c "import re,pathlib; \ print(re.search(r'^version = \"([^\"]+)\"', \ pathlib.Path('pyproject.toml').read_text(), re.M).group(1))") + tag="${BUMP_TAG}" + if [ -z "$tag" ] && [ "${GITHUB_REF_TYPE}" = tag ]; then tag="${GITHUB_REF_NAME}"; fi echo "version=$version" >> "$GITHUB_OUTPUT" - echo "pyproject declares $version" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "pyproject declares $version${tag:+, releasing $tag}" # A dispatch from a branch creates the tag itself. If that version was # already released, PyPI would quietly skip the files (skip-existing) @@ -85,7 +192,7 @@ jobs: run: | tag="v${{ steps.version.outputs.version }}" if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "::error::$tag already exists. Bump the version (release-please does) before releasing again." + echo "::error::$tag already exists. Bump the version (merging a feat:/fix: PR does) before releasing again." exit 1 fi echo "$tag is new" @@ -94,16 +201,16 @@ jobs: # nobody asked for under a name nobody expects, and PyPI will not let # you take it back. Refuse before anything is built. - name: Refuse a tag that disagrees with the package version - if: startsWith(github.ref, 'refs/tags/') + if: steps.version.outputs.tag != '' run: | - tag="${GITHUB_REF_NAME#v}" + tag="${{ steps.version.outputs.tag }}" declared="${{ steps.version.outputs.version }}" - if [ "$tag" != "$declared" ]; then - echo "::error::tag ${GITHUB_REF_NAME} does not match pyproject version ${declared}." \ + if [ "${tag#v}" != "$declared" ]; then + echo "::error::tag ${tag} does not match pyproject version ${declared}." \ "Retag, or fix the version, but do not publish a mismatch." exit 1 fi - echo "tag ${GITHUB_REF_NAME} matches ${declared}" + echo "tag ${tag} matches ${declared}" # jupyddl exports __version__ as well, and a package whose metadata and # runtime disagree is a support ticket waiting to happen. @@ -125,7 +232,7 @@ jobs: python -m build twine check --strict dist/* - # The same smoke test the `build` workflow runs on every push, repeated + # The same smoke test the `build` workflow runs on every PR, repeated # here because this is the artefact that actually goes out. - name: Install the wheel clean and plan with it working-directory: /tmp @@ -171,9 +278,7 @@ jobs: name: publish to ${{ github.event.inputs.target || 'pypi' }} needs: build runs-on: ubuntu-latest - # The environment is what a PyPI trusted publisher is scoped to, and it is - # also where a required reviewer can be attached if you ever want a human - # to approve each publish. + # The environment is what a PyPI trusted publisher is scoped to. environment: name: ${{ github.event.inputs.target || 'pypi' }} url: https://pypi.org/project/jupyddl/${{ needs.build.outputs.version }} @@ -190,33 +295,28 @@ jobs: repository-url: >- ${{ github.event.inputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || '' }} - # A tag re-run after a partial failure should not fall over on the + # A re-run after a partial failure should not fall over on the # files that already made it. skip-existing: true github-release: name: cut the GitHub release needs: [build, pypi] - # Either a tag was pushed, or someone asked for a real release from the - # Actions tab. A TestPyPI dry run deliberately creates neither tag nor - # release -- it exists to rehearse, not to leave traces. + # A release commit, a pushed tag, or a real release from the Actions tab. + # A TestPyPI dry run deliberately creates neither tag nor release. # - # Deliberately NOT gated on the publish succeeding. A GitHub Release - # records that a version was cut from a verified commit; PyPI is a - # downstream channel that can fail for reasons the code has nothing to do - # with -- auth not configured, an outage, a rate limit. Losing the tag and - # the release because of that would mean no record of the version and a - # full re-run to get one. `build` succeeding is the gate that matters: - # that is where every check lives. A failed publish stays red and visible, - # and re-running just that job finishes the job. + # Deliberately NOT gated on the publish succeeding: the GitHub Release + # records that a version was cut from a verified commit, and `build` is + # where every check lives. A failed publish stays red and visible, and + # re-running just that job finishes the job. if: >- always() && needs.build.result == 'success' - && (startsWith(github.ref, 'refs/tags/') + && (needs.build.outputs.tag != '' || github.event.inputs.target == 'pypi') runs-on: ubuntu-latest permissions: - contents: write # create the tag, the release, and attach the artifacts + contents: write # create the release (and, from the tab, the tag) steps: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -225,12 +325,8 @@ jobs: - name: Create the release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: - # Named rather than inherited from the ref, so the dispatch path - # creates `v` at this commit. The release API creates the - # tag when it does not exist, and the build job has already refused - # to get here if the version were inconsistent anywhere. tag_name: v${{ needs.build.outputs.version }} - target_commitish: ${{ github.sha }} + target_commitish: ${{ needs.build.outputs.sha }} name: jupyddl ${{ needs.build.outputs.version }} body_path: release-notes.md files: dist/* diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index a549f59..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "2.4.0" -} diff --git a/AGENTS.md b/AGENTS.md index 93bd905..707f6d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,24 +42,23 @@ native build step, and the core has zero runtime dependencies. rebuilds `web/dist` in the same commit; reformatting a bundled source and committing without the rebuild would leave main in a state where `pages` refuses to deploy. -- **`release-please`** — on every push to main, keeps one release PR that - bumps the version from the Conventional Commit titles merged since the last - release (`fix` patch, `feat` minor, `!`/`BREAKING CHANGE` major), files the - hand-written `[Unreleased]` notes under the new section and rebuilds - `web/dist` (`tools/release_pr_fixup.py`). Merging it tags the commit and - *dispatches* `release`: a tag made with the workflow token triggers nothing. - **PR titles must be Conventional Commits** (`pr-title` checks); squash-merge - with the PR title as the commit message. `__version__` carries an - `x-release-please-version` marker, and `.release-please-manifest.json` must - agree with it and `pyproject.toml` (`tests/test_release.py`). -- **`release`** — fires on a `v*` tag, or is dispatched by `release-please`. Builds, refuses a tag that disagrees +- **`release`** — on every push to main, `plan` reads the merged PR (title, + its commits, and every commit since the last `v*` tag) and picks the bump + (`tools/release.py`: `fix`/`perf` patch, `feat` minor, `!`/`BREAKING CHANGE` + major; a `release:*` label overrides). `bump` then edits the version in + `pyproject.toml`, `jupyddl/__init__.py` and `CITATION.cff`, dates + `[Unreleased]` in the changelog, rebuilds `web/dist`, commits + `chore(release): X.Y.Z` to main, tags it and starts `pages`. No release PR + and no approval: the workflow token may push but not open PRs, so this is + the shape that needs no extra settings. **PR titles must be Conventional + Commits** (`pr-title` checks). Also fires on a hand-pushed `v*` tag. Builds, refuses a tag that disagrees with `pyproject.toml` or a `__version__` that disagrees with either, runs `twine check --strict`, installs the wheel clean and plans with it, publishes to PyPI over OIDC (no stored token), then cuts the GitHub Release from the changelog section for that version. `.docs/RELEASING.md` is the runbook, including the one-time PyPI trusted-publisher setup only a maintainer can do. - Do not bump versions by hand: release-please does it in both places (and the - manifest) and rebuilds `web/dist`, which carries it too. + Do not bump versions by hand: merging a `feat:`/`fix:` PR does it in all + three places and rebuilds `web/dist`, which carries it too. - **`pages`** — bundles, refuses to deploy a stale `web/dist`, then deploys. Its job is called `bundle`, not `build`: a check named `build` has to mean the packaging workflow, and two checks with one name is a reliable way to gate diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ae01a..00473f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added -- **Semantic releases, decided by what was merged.** Pull request titles are - now Conventional Commits (a `pr-title` check enforces it), and - release-please keeps one release PR whose version follows from them: - `fix` → patch, `feat` → minor, `feat!` → major. Merging that PR tags the - commit and starts the existing PyPI pipeline. The hand-written notes in this - file are filed under the new version automatically, and `web/dist` is - rebuilt with the new version. See `.docs/RELEASING.md`. +- **Every merge is a release, decided by what was merged.** Pull request + titles are Conventional Commits (a `pr-title` check enforces it), and on + each merge `release.yml` takes the largest bump the PR's title and commits + ask for — `fix` → patch, `feat` → minor, `feat!` → major — bumps the version + everywhere, dates this changelog, commits and tags `main`, and publishes to + PyPI. No release PR and nothing to approve. A `release:*` label overrides + the bump. See `.docs/RELEASING.md`. - **The intent to publish, written down.** `.docs/PUBLISHING.md` says why and where we mean to publish jupyddl as citable research software — a Zenodo DOI per release, a JOSS paper, ICAPS workshops for the research results — with a diff --git a/jupyddl/__init__.py b/jupyddl/__init__.py index 7b4d15c..d0185e4 100644 --- a/jupyddl/__init__.py +++ b/jupyddl/__init__.py @@ -29,7 +29,7 @@ TraceRecorder, ) -__version__ = "2.4.0" # x-release-please-version +__version__ = "2.4.0" __all__ = [ "solve", diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index d1f2636..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "python", - "include-component-in-tag": false, - "include-v-in-tag": true, - "pull-request-title-pattern": "chore: release ${version}", - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance" }, - { "type": "revert", "section": "Reverts" }, - { "type": "docs", "section": "Documentation" }, - { "type": "deps", "section": "Dependencies" }, - { "type": "refactor", "section": "Refactoring", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "build", "section": "Build", "hidden": true }, - { "type": "ci", "section": "CI", "hidden": true }, - { "type": "chore", "section": "Chores", "hidden": true }, - { "type": "style", "section": "Style", "hidden": true } - ], - "packages": { - ".": { - "package-name": "jupyddl", - "changelog-path": "CHANGELOG.md", - "extra-files": [ - { "type": "toml", "path": "pyproject.toml", "jsonpath": "$.project.version" }, - { "type": "generic", "path": "jupyddl/__init__.py" } - ] - } - } -} diff --git a/tests/test_release.py b/tests/test_release.py index 8cf2c1b..b527c31 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,68 +1,70 @@ -"""The release automation's assumptions, checked before a release depends on them. +"""The release automation (tools/release.py), checked before a merge relies on it. -release-please bumps three places at once: the manifest, `pyproject.toml` -and `jupyddl.__version__` (found through its `x-release-please-version` -marker). If they disagree, or the marker goes missing, the release PR bumps -some and not others and `release.yml` refuses the result at release time, -the worst moment to find out. +Every merge to main runs `release.yml`, which asks `tools/release.py` what +the merged pull request's commits want released and then cuts it. A mistake +here either ships a version nobody asked for -- which PyPI never lets you take +back -- or silently skips one, so the rules are pinned case by case. """ from __future__ import annotations import importlib.util -import json import os import re +import shutil +import subprocess + +import pytest from conftest import REPO_ROOT import jupyddl -def _read(*parts): - with open(os.path.join(REPO_ROOT, *parts), encoding="utf-8") as handle: - return handle.read() - - -def _fixup(): - path = os.path.join(REPO_ROOT, "tools", "release_pr_fixup.py") - spec = importlib.util.spec_from_file_location("release_pr_fixup", path) +def _release(): + path = os.path.join(REPO_ROOT, "tools", "release.py") + spec = importlib.util.spec_from_file_location("release_tool", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def test_every_version_release_please_bumps_agrees(): - manifest = json.loads(_read(".release-please-manifest.json"))["."] - declared = re.search(r'^version = "([^"]+)"', _read("pyproject.toml"), re.M) - assert manifest == declared.group(1) == jupyddl.__version__ - - -def test_the_version_line_carries_the_release_please_marker(): - line = next( - line - for line in _read("jupyddl", "__init__.py").splitlines() - if line.startswith("__version__") - ) - assert "x-release-please-version" in line - - -def test_every_extra_file_release_please_edits_exists(): - config = json.loads(_read("release-please-config.json")) - for entry in config["packages"]["."]["extra-files"]: - assert os.path.exists(os.path.join(REPO_ROOT, entry["path"])), entry - - -AFTER_RELEASE_PLEASE = """# Changelog - -Intro. - -## [2.5.0](https://example.invalid/compare/v2.4.0...v2.5.0) (2026-11-03) - +def _read(*parts): + with open(os.path.join(REPO_ROOT, *parts), encoding="utf-8") as handle: + return handle.read() -### Features -* plugins ([#164](https://example.invalid/164)) +@pytest.mark.parametrize( + "messages, labels, expected", + [ + (["fix(lmcut): handle an empty goal"], [], "patch"), + (["perf: cache h"], [], "patch"), + (["feat: plugins"], [], "minor"), + (["fix: a", "feat: b", "docs: c"], [], "minor"), # the largest wins + (["feat!: drop Python 3.10"], [], "major"), + (["fix(api)!: rename solve"], [], "major"), + (["fix: a\n\nBREAKING CHANGE: solve() returns a tuple"], [], "major"), + (["docs: guide", "chore(deps): pin", "ci: x", "test: y"], [], None), + (["Update README.md", "Merge branch 'main'"], [], None), # not conventional + (["feat: plugins"], ["release:none"], None), # a label overrides + (["docs: typo"], ["release:patch"], "patch"), + (["fix: a"], ["release:major", "release:patch"], "major"), + # "BREAKING CHANGE" only counts as a footer of a conventional commit. + (["Mention BREAKING CHANGE: nothing"], [], None), + ], +) +def test_the_bump_follows_the_commits(messages, labels, expected): + assert _release().bump_for(messages, labels) == expected + + +@pytest.mark.parametrize( + "part, expected", [("patch", "2.4.1"), ("minor", "2.5.0"), ("major", "3.0.0")] +) +def test_bumped(part, expected): + assert _release().bumped("2.4.0", part) == expected + + +CHANGELOG = """# Changelog ## [Unreleased] @@ -71,24 +73,67 @@ def test_every_extra_file_release_please_edits_exists(): ## [2.4.0] -### Added - Older notes. """ -def test_the_hand_written_notes_are_filed_under_the_new_version(): - filed = _fixup().file_unreleased_notes(AFTER_RELEASE_PLEASE) - assert filed.index("## [Unreleased]") < filed.index("## [2.5.0]") - assert filed.count("## [Unreleased]") == 1 - # release.yml cuts the GitHub Release notes from the version's section. +def test_the_unreleased_notes_become_the_release(): + dated = _release().date_changelog(CHANGELOG, "2.5.0", "2026-11-03") + assert dated.index("## [Unreleased]") < dated.index("## [2.5.0] - 2026-11-03") section = re.search( - r"^## \[2\.5\.0\][^\n]*\n(.*?)(?=^## \[|\Z)", filed, re.M | re.S + r"^## \[2\.5\.0\][^\n]*\n(.*?)(?=^## \[|\Z)", dated, re.M | re.S ).group(1) - assert "* plugins" in section and "Derived facts are retracted." in section - assert "Older notes." not in section - assert _fixup().file_unreleased_notes(filed) == filed # idempotent - - -def test_the_fixup_leaves_an_unreleased_changelog_alone(): - text = _read("CHANGELOG.md") - assert _fixup().file_unreleased_notes(text) == text + assert "Derived facts are retracted." in section and "Older notes" not in section + unreleased = re.search(r"^## \[Unreleased\]\n(.*?)(?=^## \[)", dated, re.M | re.S) + assert unreleased.group(1).strip() == "" + + +def test_an_empty_changelog_takes_the_pr_title_or_refuses(): + empty = CHANGELOG.replace("### Fixed\n- Derived facts are retracted.\n", "") + dated = _release().date_changelog(empty, "2.4.1", "2026-11-03", "fix: x (#9)") + assert "## [2.4.1] - 2026-11-03\n\n- fix: x (#9)" in dated + with pytest.raises(SystemExit): + _release().date_changelog(empty, "2.4.1", "2026-11-03") + + +def test_apply_bumps_every_place_the_version_is_written(tmp_path): + for name in ("pyproject.toml", "CITATION.cff", "CHANGELOG.md"): + shutil.copy(os.path.join(REPO_ROOT, name), tmp_path / name) + (tmp_path / "jupyddl").mkdir() + shutil.copy(os.path.join(REPO_ROOT, "jupyddl", "__init__.py"), tmp_path / "jupyddl") + release = _release() + old = release.current_version(tmp_path) + new = release.apply("minor", root=tmp_path, date="2026-11-03", note="feat: x") + assert new == release.bumped(old, "minor") + assert f'version = "{new}"' in (tmp_path / "pyproject.toml").read_text() + init = (tmp_path / "jupyddl" / "__init__.py").read_text() + assert f'__version__ = "{new}"' in init + assert f"version: {new}" in (tmp_path / "CITATION.cff").read_text() + assert f"## [{new}] - 2026-11-03" in (tmp_path / "CHANGELOG.md").read_text() + + +def test_commits_since_the_last_tag_are_counted(tmp_path): + def git(*args): + subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True) + + git("init", "-q") + git("config", "user.email", "t@example.invalid") + git("config", "user.name", "t") + history = [("chore: start", "v1.0.0"), ("feat: missed", None), ("docs: x", None)] + for message, tag in history: + (tmp_path / "f").write_text(message) + git("add", "f") + git("commit", "-qm", message) + if tag: + git("tag", tag) + release = _release() + messages = release.messages_since_last_tag(tmp_path) + assert messages == ["docs: x", "feat: missed"] + # A docs-only merge still releases the feature whose run was dropped. + assert release.bump_for(["docs: x"] + messages) == "minor" + + +def test_every_version_in_the_repository_agrees(): + declared = re.search(r'^version = "([^"]+)"', _read("pyproject.toml"), re.M) + cited = re.search(r"^version: (.+)$", _read("CITATION.cff"), re.M) + assert declared.group(1) == jupyddl.__version__ == cited.group(1).strip() diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 0000000..fb9bee3 --- /dev/null +++ b/tools/release.py @@ -0,0 +1,170 @@ +"""Decide and cut a release from what a merged pull request contains. + + python tools/release.py plan [--since-last-tag] < pr.json # major|minor|patch|none + python tools/release.py apply PART [--note TEXT] [--date YYYY-MM-DD] + +Run by ``release.yml`` on every push to main. No release pull request, nothing +to approve: the merge *is* the release decision. + +**plan** reads ``{"labels": [...], "messages": [...]}`` -- the merged pull +request's labels, and its title plus every commit message in it -- and picks +the largest bump any of them asks for, by Conventional Commits: + +* ``feat!:``/``fix!:``, or a ``BREAKING CHANGE:`` footer -> major +* ``feat:`` -> minor +* ``fix:`` / ``perf:`` -> patch +* anything else (``docs:``, ``chore:``, ``ci:``, ``test:``) -> none + +A ``release:major|minor|patch|none`` label on the pull request overrides that, +for the rare merge whose commits say the wrong thing. + +``--since-last-tag`` also counts every commit on the branch since the last +``v*`` tag. GitHub keeps only one *pending* run per concurrency group, so of +several quick merges one can be dropped; this way its bump still lands with +the next release instead of being lost. + +**apply** bumps the version in ``pyproject.toml``, ``jupyddl/__init__.py`` and +``CITATION.cff``, turns ``## [Unreleased]`` into ``## [X.Y.Z] - date`` under a +fresh empty ``[Unreleased]``, and rebuilds ``web/dist``, which records the +version. If nobody wrote a changelog entry, ``--note`` (the pull request title) +becomes the entry, so a release never ships with an empty section. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +PARTS = ("major", "minor", "patch") +SUBJECT = re.compile(r"^(?P[a-z]+)(?:\([^)]*\))?(?P!)?: \S") +BREAKING = re.compile(r"^BREAKING[ -]CHANGE: ", re.M) + + +def bump_for(messages, labels=()) -> str | None: + """The bump a merged pull request asks for, or ``None`` for no release.""" + for part in PARTS + ("none",): + if f"release:{part}" in labels: + return None if part == "none" else part + level = 0 + for message in messages: + if not message or not message.strip(): + continue + match = SUBJECT.match(message.strip().splitlines()[0]) + if match is None: + continue # not a Conventional Commit: it asks for nothing + if match["bang"] or BREAKING.search(message): + level = 3 + elif match["type"] == "feat": + level = max(level, 2) + elif match["type"] in ("fix", "perf"): + level = max(level, 1) + return {3: "major", 2: "minor", 1: "patch"}.get(level) + + +def messages_since_last_tag(root: pathlib.Path = ROOT) -> list: + """Every commit message on HEAD since the newest ``v*`` tag reachable from it.""" + + def git(*args): + return subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, text=True + ).stdout + + try: + last = git("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*").strip() + except subprocess.CalledProcessError: + return [] # no release yet: nothing to catch up on + log = git("log", "--format=%B%x1e", f"{last}..HEAD") + return [message.strip() for message in log.split("\x1e") if message.strip()] + + +def bumped(version: str, part: str) -> str: + major, minor, patch = (int(x) for x in version.split(".")) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def _replace(path: pathlib.Path, pattern: str, replacement: str) -> None: + text = path.read_text(encoding="utf-8") + new, count = re.subn(pattern, replacement, text, count=1, flags=re.M) + if count != 1: + raise SystemExit(f"{path}: nothing matched {pattern!r}") + path.write_text(new, encoding="utf-8") + + +def date_changelog(text: str, version: str, date: str, note: str = "") -> str: + """``[Unreleased]`` becomes ``[version] - date``; an empty one goes on top.""" + head = "## [Unreleased]\n" + start = text.index(head) + len(head) + following = re.search(r"^## \[", text[start:], re.M) + end = start + following.start() if following else len(text) + body = text[start:end].strip() + if not body: + if not note: + raise SystemExit("CHANGELOG.md has nothing under [Unreleased]") + body = f"- {note}" + return text[:start] + f"\n## [{version}] - {date}\n\n" + body + "\n\n" + text[end:] + + +def current_version(root: pathlib.Path = ROOT) -> str: + text = (root / "pyproject.toml").read_text(encoding="utf-8") + return re.search(r'^version = "([^"]+)"', text, re.M).group(1) + + +def apply(part: str, root: pathlib.Path = ROOT, date: str = "", note: str = "") -> str: + old = current_version(root) + new = bumped(old, part) + date = date or dt.date.today().isoformat() + _replace(root / "pyproject.toml", r'^version = "[^"]+"', f'version = "{new}"') + _replace( + root / "jupyddl" / "__init__.py", + r'^__version__ = "[^"]+"', + f'__version__ = "{new}"', + ) + _replace(root / "CITATION.cff", r"^version: .+$", f"version: {new}") + changelog = root / "CHANGELOG.md" + changelog.write_text( + date_changelog(changelog.read_text(encoding="utf-8"), new, date, note), + encoding="utf-8", + ) + return new + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + plan = sub.add_parser("plan", help="read the merged PR as JSON on stdin") + plan.add_argument( + "--since-last-tag", + action="store_true", + help="also count every commit since the last v* tag", + ) + cut = sub.add_parser("apply", help="bump, date the changelog, rebuild web/dist") + cut.add_argument("part", choices=PARTS) + cut.add_argument("--note", default="", help="changelog entry if there is none") + cut.add_argument("--date", default="", help="release date (default: today)") + args = parser.parse_args(argv) + + if args.command == "plan": + pr = json.load(sys.stdin) + messages = list(pr.get("messages", [])) + if args.since_last_tag: + messages += messages_since_last_tag() + print(bump_for(messages, pr.get("labels", [])) or "none") + return 0 + version = apply(args.part, date=args.date, note=args.note) + subprocess.run([sys.executable, str(ROOT / "tools" / "build_web.py")], check=True) + print(version) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/release_pr_fixup.py b/tools/release_pr_fixup.py deleted file mode 100644 index 8d12287..0000000 --- a/tools/release_pr_fixup.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Finish a release-please pull request: file the hand-written notes, rebuild the bundle. - -release-please bumps the version and writes a changelog section from the -conventional-commit titles of the merged pull requests. Two things in this -repository it cannot know about: - -1. **The hand-written notes.** Contributors describe their change under - ``## [Unreleased]`` in prose, which says far more than a PR title. - release-please inserts its new section *above* that heading, so this moves - the notes into the new section, under the generated lists, and puts an - empty ``## [Unreleased]`` back on top for the next round. -2. **The web bundle.** ``web/dist/build.json`` records the version, and the - ``pages`` workflow refuses to deploy a bundle that disagrees with the - sources. A release that bumps the version without rebuilding would break - the site the moment it merged. - -Idempotent: on a changelog that is already in shape it changes nothing, so the -workflow can run it after every push release-please makes to its branch. -""" - -from __future__ import annotations - -import pathlib -import re -import subprocess -import sys - -ROOT = pathlib.Path(__file__).resolve().parent.parent -CHANGELOG = ROOT / "CHANGELOG.md" -UNRELEASED = re.compile(r"^## \[Unreleased\][^\n]*\n", re.M) -VERSION = re.compile(r"^## \[?\d+\.\d+\.\d+[^\n]*\n", re.M) -HEADING = re.compile(r"^## ", re.M) - - -def file_unreleased_notes(text: str) -> str: - """Move the ``[Unreleased]`` notes under the newest version heading.""" - unreleased = UNRELEASED.search(text) - newest = VERSION.search(text) - if unreleased is None or newest is None or unreleased.start() < newest.start(): - return text # nothing to file, or already filed - following = HEADING.search(text, unreleased.end()) - end = following.start() if following else len(text) - notes = text[unreleased.end() : end].strip() - text = text[: unreleased.start()] + text[end:] - - # The newest version's section now ends at the next heading after it. - newest = VERSION.search(text) - following = HEADING.search(text, newest.end()) - section_end = following.start() if following else len(text) - section = text[newest.start() : section_end].rstrip() - if notes: - section += "\n\n" + notes - section += "\n\n" - return text[: newest.start()] + "## [Unreleased]\n\n" + section + text[section_end:] - - -def main() -> int: - original = CHANGELOG.read_text(encoding="utf-8") - updated = file_unreleased_notes(original) - if updated != original: - CHANGELOG.write_text(updated, encoding="utf-8") - print("filed the [Unreleased] notes under the new version") - subprocess.run([sys.executable, str(ROOT / "tools" / "build_web.py")], check=True) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/web/dist/jupyddl-sources.json b/web/dist/jupyddl-sources.json index 6e2a648..6cc6775 100644 --- a/web/dist/jupyddl-sources.json +++ b/web/dist/jupyddl-sources.json @@ -1 +1 @@ -{"jupyddl/__init__.py": "\"\"\"jupyddl: a pure-Python PDDL planning framework.\n\nQuickstart::\n\n from jupyddl import solve, build_task, trace_search, validate_plan\n\n result = solve(\"domain.pddl\", \"problem.pddl\", search=\"astar\", heuristic=\"lmcut\")\n print(result.solved, result.cost, result.plan_names())\n\n # ...and watch the search itself\n task = build_task(\"domain.pddl\", \"problem.pddl\")\n result, trace = trace_search(task, \"astar\", \"lmcut\")\n trace.save(\"run.json\")\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .api import build_task, solve, solve_task, trace_search, validate_plan\nfrom .grounding import ground, ground_files\nfrom .heuristics import HEURISTICS, make_heuristic\nfrom .parser import PDDLError, UnsupportedFeatureError, parse\nfrom .search import PLANNERS, SearchResult, make_planner\nfrom .task import Operator, Task\nfrom .trace import (\n MultiObserver,\n SearchEvent,\n SearchObserver,\n SearchTrace,\n TraceRecorder,\n)\n\n__version__ = \"2.4.0\" # x-release-please-version\n\n__all__ = [\n \"solve\",\n \"solve_task\",\n \"build_task\",\n \"trace_search\",\n \"validate_plan\",\n \"ground\",\n \"ground_files\",\n \"make_planner\",\n \"make_heuristic\",\n \"PLANNERS\",\n \"HEURISTICS\",\n \"SearchResult\",\n \"SearchTrace\",\n \"SearchEvent\",\n \"SearchObserver\",\n \"MultiObserver\",\n \"TraceRecorder\",\n \"Task\",\n \"Operator\",\n \"parse\",\n \"PDDLError\",\n \"UnsupportedFeatureError\",\n \"__version__\",\n]\n", "jupyddl/api.py": "\"\"\"High-level convenience API: parse + ground + plan + validate.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .grounding import ground_files\nfrom .heuristics import make_heuristic\nfrom .search import make_planner\nfrom .search.result import SearchResult, make_budget\nfrom .task import Task\nfrom .trace import TraceRecorder\n\n\ndef build_task(domain_path: str, problem_path: str) -> Task:\n \"\"\"Parse and ground a domain/problem pair into a :class:`Task`.\"\"\"\n return ground_files(domain_path, problem_path)\n\n\ndef solve_task(\n task: Task,\n search: str = \"astar\",\n heuristic=None,\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n) -> SearchResult:\n \"\"\"Run ``search`` (optionally with ``heuristic``) on an already-ground task.\n\n Pass ``observer`` (see :mod:`jupyddl.trace`) to record or live-render the\n search as it runs. ``max_expansions`` and ``time_limit`` bound the run; when\n either is hit the planner stops and sets ``result.truncated``, so an empty\n result means \"gave up\", not \"proved unsolvable\".\n\n A planner that needs a heuristic and is not given one gets ``lmcut`` if it\n is optimal (so the default keeps the guarantee) and ``hff`` otherwise.\n ``heuristic=\"none\"`` asks for no guidance at all, which for a planner that\n needs a heuristic means ``blind``.\n \"\"\"\n planner = make_planner(search, **planner_kwargs)\n name = resolve_heuristic(planner, heuristic)\n heur = make_heuristic(name, task) if name else None\n budget = make_budget(max_expansions, time_limit)\n return planner.search(task, heur, observer=observer, budget=budget)\n\n\ndef resolve_heuristic(planner, heuristic):\n \"\"\"The heuristic :func:`solve_task` actually runs ``planner`` with.\n\n ``planner`` is a planner instance; ``heuristic`` is what the caller asked\n for. Returns a registry name or spec, the heuristic object passed in, or\n ``None`` for no heuristic at all. Anything that records which heuristic\n produced a result should ask this rather than echo the request, which for\n ``None`` and ``\"none\"`` names something other than what ran.\n \"\"\"\n if heuristic == \"none\":\n return \"blind\" if planner.requires_heuristic else None\n if not heuristic and planner.requires_heuristic:\n # An optimal planner gets an admissible default: hff would quietly\n # cost it its guarantee.\n return \"lmcut\" if planner.optimal else \"hff\"\n return heuristic or None\n\n\ndef solve(\n domain_path: str,\n problem_path: str,\n search: str = \"astar\",\n heuristic=\"lmcut\",\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n) -> SearchResult:\n \"\"\"Parse, ground and solve a PDDL instance in one call.\"\"\"\n task = build_task(domain_path, problem_path)\n return solve_task(\n task,\n search=search,\n heuristic=heuristic,\n observer=observer,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **planner_kwargs,\n )\n\n\ndef trace_search(\n task: Task,\n search: str = \"astar\",\n heuristic=None,\n max_events: int = 20000,\n record_generated: bool = False,\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n):\n \"\"\"Solve ``task`` while recording the search.\n\n Returns ``(result, trace)`` where ``trace`` is a\n :class:`~jupyddl.trace.SearchTrace` ready to plot, save or replay. An extra\n ``observer`` (a live dashboard, say) is notified alongside the recorder.\n \"\"\"\n recorder = TraceRecorder(max_events=max_events, record_generated=record_generated)\n if observer is not None:\n from .trace import MultiObserver\n\n sink = MultiObserver(recorder, observer)\n else:\n sink = recorder\n result = solve_task(\n task,\n search=search,\n heuristic=heuristic,\n observer=sink,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **planner_kwargs,\n )\n return result, recorder.trace\n\n\ndef validate_plan(task: Task, plan) -> bool:\n \"\"\"Return ``True`` iff applying ``plan`` from the initial state reaches the goal.\n\n Replays through the task rather than the raw operators, so derived\n predicates are closed and numeric fluents are carried at every step \u2014\n validating against ``task.init`` directly would see neither.\n \"\"\"\n state = task.initial_state()\n for op in plan or ():\n if not op.applicable(state):\n return False\n state = task.apply(op, state)\n return task.goal_reached(state)\n", "jupyddl/benchmark.py": "\"\"\"Comparative benchmarking of planners/heuristics over PDDL instances.\n\nExample::\n\n from jupyddl.benchmark import discover_instances, run_benchmark, to_csv\n rows = run_benchmark(discover_instances(\"pddl-examples\"),\n [(\"astar\", \"lmcut\"), (\"gbfs\", \"hff\")])\n to_csv(rows, \"results.csv\")\n\"\"\"\n\nfrom __future__ import annotations\n\nimport csv\nimport glob\nimport os\nfrom dataclasses import asdict, dataclass\nfrom typing import Optional\n\nfrom .api import build_task, resolve_heuristic, solve_task, validate_plan\nfrom .parser import PDDLError\nfrom .search import make_planner\n\n\n@dataclass\nclass Instance:\n name: str\n domain: str\n problem: str\n\n\n@dataclass\nclass BenchmarkRow:\n instance: str\n planner: str\n heuristic: str\n solved: bool\n valid: bool\n cost: Optional[int]\n plan_length: Optional[int]\n expanded: int\n generated: int\n evaluated: int\n runtime: float\n error: str = \"\"\n truncated: bool = False\n #: The configuration, planner arguments included (``wastar/hff(weight=5)``).\n #: ``planner`` and ``heuristic`` alone cannot tell two weights apart.\n config: str = \"\"\n\n\ndef discover_instances(root: str) -> list:\n \"\"\"Find ``/*/`` folders containing both ``domain.pddl`` and ``problem.pddl``.\"\"\"\n instances = []\n for domain in sorted(glob.glob(os.path.join(root, \"*\", \"domain.pddl\"))):\n folder = os.path.dirname(domain)\n problem = os.path.join(folder, \"problem.pddl\")\n if os.path.exists(problem):\n instances.append(Instance(os.path.basename(folder), domain, problem))\n return instances\n\n\ndef config_label(planner: str, heuristic: str = \"\", kwargs=None) -> str:\n \"\"\"Name a configuration: ``planner/heuristic``, plus any planner arguments.\n\n >>> config_label(\"wastar\", \"hff\", {\"weight\": 5})\n 'wastar/hff(weight=5)'\n >>> config_label(\"bfs\")\n 'bfs'\n \"\"\"\n label = f\"{planner}/{heuristic}\" if heuristic else planner\n if kwargs:\n label += \"(\" + \",\".join(f\"{k}={v}\" for k, v in sorted(kwargs.items())) + \")\"\n return label\n\n\ndef row_label(row) -> str:\n \"\"\"The configuration a row belongs to, for rows that predate ``config``.\"\"\"\n return row.config or config_label(row.planner, row.heuristic)\n\n\ndef _heuristic_label(heuristic) -> str:\n if heuristic is None:\n return \"\"\n if isinstance(heuristic, str):\n return heuristic\n return getattr(heuristic, \"name\", None) or type(heuristic).__name__\n\n\ndef _plan_configs(configs) -> list:\n \"\"\"Unpack each config and work out what will actually run, and its label.\n\n The heuristic recorded is the one :func:`~jupyddl.api.solve_task` will run,\n not the one asked for: ``(\"gbfs\", None)`` runs ``hff`` and used to be\n recorded as ``\"\"``, and ``\"none\"`` runs ``blind`` for a planner that needs\n a heuristic. Two config entries never share a label \u2014 a repeated one is\n suffixed ``#2`` \u2014 so :func:`summarize` cannot merge them.\n \"\"\"\n planned = []\n seen: dict = {}\n for cfg in configs:\n planner, requested = cfg[0], cfg[1]\n kwargs = dict(cfg[2]) if len(cfg) > 2 and cfg[2] else {}\n try:\n ran = resolve_heuristic(make_planner(planner, **kwargs), requested)\n except (ValueError, TypeError):\n ran = requested # an unknown planner or argument: the run records it\n ran = _heuristic_label(ran)\n label = config_label(planner, ran, kwargs)\n seen[label] = seen.get(label, 0) + 1\n if seen[label] > 1:\n label = f\"{label}#{seen[label]}\"\n planned.append((planner, requested, kwargs, ran, label))\n return planned\n\n\ndef _failed_row(instance, planner, heuristic, label, exc) -> BenchmarkRow:\n return BenchmarkRow(\n instance,\n planner,\n heuristic,\n False,\n False,\n None,\n None,\n 0,\n 0,\n 0,\n 0.0,\n f\"{type(exc).__name__}: {exc}\",\n config=label,\n )\n\n\ndef run_benchmark(instances, configs, max_expansions=None, time_limit=None) -> list:\n \"\"\"Run each ``(planner, heuristic[, kwargs])`` config on each instance.\n\n ``configs`` entries are ``(planner_name, heuristic_name_or_None)`` or\n ``(planner_name, heuristic_name_or_None, planner_kwargs)``. Each row\n records the heuristic that actually ran and a ``config`` label that\n includes the planner arguments, so ``wastar`` at two weights stays two\n configurations.\n\n ``max_expansions`` and ``time_limit`` bound every individual run, which is\n what keeps one pathological instance from stalling a whole benchmark. A run\n that stops on its budget is recorded with ``truncated=True`` and does not\n count towards coverage -- \"we stopped looking\" is not \"no plan exists\".\n \"\"\"\n planned = _plan_configs(configs)\n rows: list = []\n for inst in instances:\n try:\n task = build_task(inst.domain, inst.problem)\n except (PDDLError, ValueError) as exc:\n for planner, _, _, ran, label in planned:\n rows.append(_failed_row(inst.name, planner, ran, label, exc))\n continue\n for planner, requested, kwargs, ran, label in planned:\n try:\n result = solve_task(\n task,\n planner,\n requested,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **kwargs,\n )\n valid = bool(result.solved and validate_plan(task, result.plan))\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n ran,\n result.solved,\n valid,\n result.cost,\n result.plan_length,\n result.stats.expanded,\n result.stats.generated,\n result.stats.evaluated,\n round(result.stats.runtime, 6),\n \"\",\n result.stats.truncated,\n label,\n )\n )\n except Exception as exc: # keep the benchmark going on a single failure\n rows.append(_failed_row(inst.name, planner, ran, label, exc))\n return rows\n\n\ndef to_csv(rows, path: str) -> None:\n fieldnames = (\n list(asdict(rows[0]).keys())\n if rows\n else [f.name for f in BenchmarkRow.__dataclass_fields__.values()]\n )\n with open(path, \"w\", newline=\"\", encoding=\"utf-8\") as handle:\n writer = csv.DictWriter(handle, fieldnames=fieldnames)\n writer.writeheader()\n for row in rows:\n writer.writerow(asdict(row))\n\n\ndef summarize(rows) -> dict:\n \"\"\"Aggregate coverage and totals per configuration.\n\n Keyed on :func:`row_label`, which carries the planner's arguments. Keyed\n on ``planner/heuristic`` alone, ``wastar`` at weights 1 and 5 merged into\n one entry claiming twice the instances the benchmark had.\n \"\"\"\n summary: dict = {}\n for row in rows:\n key = row_label(row)\n agg = summary.setdefault(\n key, {\"coverage\": 0, \"expanded\": 0, \"runtime\": 0.0, \"instances\": 0}\n )\n agg[\"instances\"] += 1\n agg[\"coverage\"] += int(row.valid)\n agg[\"expanded\"] += row.expanded\n agg[\"runtime\"] += row.runtime\n return summary\n\n\ndef plot_summary(rows, path: str, metric: str = \"expanded\") -> None:\n \"\"\"Bar chart of a metric per configuration (requires the ``viz`` extra).\"\"\"\n import matplotlib\n\n matplotlib.use(\"Agg\")\n import matplotlib.pyplot as plt\n\n summary = summarize(rows)\n labels = list(summary.keys())\n values = [summary[k][metric] for k in labels]\n fig, ax = plt.subplots(figsize=(max(6, len(labels) * 0.9), 4))\n ax.bar(labels, values, color=\"#4C72B0\")\n ax.set_ylabel(f\"total {metric}\")\n ax.set_title(f\"Planner comparison ({metric})\")\n plt.xticks(rotation=45, ha=\"right\")\n plt.tight_layout()\n fig.savefig(path, dpi=120)\n plt.close(fig)\n", "jupyddl/cli.py": "\"\"\"Command-line interface.\n\n``solve``, ``benchmark``, ``animate``, ``demo``, ``requirements``, ``generate``,\n``learn`` and ``plugins``. Every command that searches accepts\n``--max-expansions`` and ``--time-limit``; when a search stops on one of those\nit says so rather than reporting the instance unsolvable.\n\nExit codes, so a script can tell the outcomes apart:\n\n===== =========================================================\n0 solved, and the plan validates\n1 the search finished and found no plan\n2 bad input: a missing file, a parse error, an unknown name\n3 a budget stopped the search before it found a plan\n4 a plan was found but failed validation (a bug: report it)\n===== =========================================================\n\nSet ``JUPYDDL_DEBUG=1`` to get the full traceback instead of a one-line error.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport sys\n\nfrom . import __version__\nfrom .api import build_task, solve_task, trace_search, validate_plan\nfrom .benchmark import (\n discover_instances,\n plot_summary,\n run_benchmark,\n summarize,\n to_csv,\n)\nfrom .heuristics import HEURISTICS, LOADERS\nfrom .parser import PDDLError\nfrom .plugins import describe_plugins, load_plugins\nfrom .search import PLANNERS\n\nEXIT_SOLVED = 0\nEXIT_NO_PLAN = 1\nEXIT_BAD_INPUT = 2\nEXIT_TRUNCATED = 3\nEXIT_INVALID_PLAN = 4\n\nEXIT_CODES = \"\"\"exit codes:\n 0 solved, and the plan validates\n 1 the search finished and found no plan\n 2 bad input: a missing file, a parse error, an unknown name\n 3 a budget stopped the search before it found a plan\n 4 a plan was found but failed validation (a bug: please report it)\"\"\"\n\n\nclass CLIError(Exception):\n \"\"\"A problem with what the user asked for, reported as one line.\"\"\"\n\n\ndef _need_viz(what: str) -> None:\n \"\"\"Fail before searching, not after, when a chart needs matplotlib.\"\"\"\n try:\n import matplotlib # noqa: F401\n except ImportError:\n raise CLIError(\n f\"{what} needs the viz extra: pip install 'jupyddl[viz]'\"\n ) from None\n\n\ndef _add_budget(p, default_time=None) -> None:\n p.add_argument(\n \"--max-expansions\",\n type=int,\n default=None,\n help=\"stop each search after this many node expansions\",\n )\n hint = f\" (default {default_time:g}; 0 for none)\" if default_time else \"\"\n p.add_argument(\n \"--time-limit\",\n type=float,\n default=default_time,\n help=\"stop each search after this many seconds\" + hint,\n )\n\n\ndef _time_limit(args):\n limit = getattr(args, \"time_limit\", None)\n return limit if limit and limit > 0 else None\n\n\ndef _needs_heuristic(planner: str) -> bool:\n # Read from the registry at call time rather than INFORMED_PLANNERS, which\n # is fixed at import and so cannot know about a plugin's planners.\n return bool(getattr(PLANNERS.get(planner), \"requires_heuristic\", False))\n\n\ndef heuristic_spec(value: str) -> str:\n \"\"\"Validate ``-H``: a registry name, ``none``, or ``kind:argument``.\n\n ``choices=`` cannot express this \u2014 a trained heuristic is identified by a\n path that does not exist until someone trains one \u2014 so the check is a type\n function instead. It still rejects typos eagerly, which is the only reason\n ``choices=`` was worth having.\n \"\"\"\n if value in HEURISTICS or value == \"none\":\n return value\n kind, sep, argument = value.partition(\":\")\n if sep and kind in LOADERS:\n if not argument:\n raise argparse.ArgumentTypeError(\n f\"'{kind}:' needs an argument, e.g. {kind}:model.json\"\n )\n return value\n raise argparse.ArgumentTypeError(\n f\"unknown heuristic '{value}'; expected one of \"\n f\"{sorted(HEURISTICS) + ['none']} \"\n f\"or {'/'.join(sorted(LOADERS))}:\"\n )\n\n\ndef _add_solve(sub):\n p = sub.add_parser(\"solve\", help=\"solve a single PDDL instance\")\n p.add_argument(\"domain\")\n p.add_argument(\"problem\")\n p.add_argument(\"-s\", \"--search\", default=\"astar\", choices=sorted(PLANNERS))\n p.add_argument(\n \"-H\",\n \"--heuristic\",\n default=\"lmcut\",\n type=heuristic_spec,\n metavar=\"NAME\",\n help=\"a heuristic name, none, or learned:\",\n )\n p.add_argument(\n \"-w\", \"--weight\", type=float, default=2.0, help=\"weight for weighted A*\"\n )\n p.add_argument(\n \"--live\",\n action=\"store_true\",\n help=\"watch the search live in the terminal (no dependencies)\",\n )\n p.add_argument(\"--trace\", default=None, help=\"write the search trace as JSON\")\n p.add_argument(\n \"--plot\",\n default=None,\n help=\"write a four-panel search-progress chart (PNG; needs the viz extra)\",\n )\n p.add_argument(\n \"--tree\", default=None, help=\"write the radial search-wavefront chart (PNG)\"\n )\n p.add_argument(\n \"--plan-plot\", default=None, help=\"write the plan timeline chart (PNG)\"\n )\n p.add_argument(\"--dark\", action=\"store_true\", help=\"render charts for dark mode\")\n _add_budget(p)\n p.add_argument(\"--quiet\", action=\"store_true\", help=\"do not print the plan\")\n p.set_defaults(func=_cmd_solve)\n\n\ndef _add_benchmark(sub):\n p = sub.add_parser(\"benchmark\", help=\"compare planners over a folder of instances\")\n p.add_argument(\"root\", help=\"folder containing /domain.pddl + problem.pddl\")\n p.add_argument(\"--planners\", default=\"bfs,dijkstra,astar,gbfs,wastar,ehc\")\n p.add_argument(\"--heuristic\", default=\"hff\", help=\"heuristic for informed planners\")\n p.add_argument(\"--csv\", default=None, help=\"write per-run results to this CSV\")\n p.add_argument(\"--plot\", default=None, help=\"write a comparison bar chart (PNG)\")\n p.add_argument(\n \"--dashboard\",\n default=None,\n help=\"write the full benchmark dashboard: coverage, effort, time, heatmap\",\n )\n p.add_argument(\n \"--metric\",\n default=\"expanded\",\n choices=[\n \"expanded\",\n \"generated\",\n \"evaluated\",\n \"runtime\",\n \"cost\",\n \"plan_length\",\n ],\n help=\"what --plot compares\",\n )\n p.add_argument(\"--dark\", action=\"store_true\", help=\"render charts for dark mode\")\n # A default limit: one blind search on a large instance would otherwise\n # stall the whole table, which is what `benchmark demos` used to do.\n _add_budget(p, default_time=10.0)\n p.set_defaults(func=_cmd_benchmark)\n\n\ndef _add_animate(sub):\n p = sub.add_parser(\"animate\", help=\"replay a search as an animation (MP4 or GIF)\")\n p.add_argument(\"domain\")\n p.add_argument(\"problem\")\n p.add_argument(\"-o\", \"--output\", default=\"search.mp4\")\n p.add_argument(\"-s\", \"--search\", default=\"astar\", choices=sorted(PLANNERS))\n p.add_argument(\n \"-H\",\n \"--heuristic\",\n default=\"lmcut\",\n type=heuristic_spec,\n metavar=\"NAME\",\n help=\"a heuristic name, none, or learned:\",\n )\n p.add_argument(\"--fps\", type=int, default=30)\n p.add_argument(\"--seconds\", type=float, default=8.0)\n p.add_argument(\"--dark\", action=\"store_true\")\n _add_budget(p)\n p.set_defaults(func=_cmd_animate)\n\n\ndef _add_demo(sub):\n p = sub.add_parser(\n \"demo\",\n help=\"run the bundled demo instances and write every chart to a folder\",\n )\n p.add_argument(\"-o\", \"--output\", default=\"gallery\", help=\"output folder\")\n p.add_argument(\n \"--root\",\n default=\"demos\",\n help=\"folder of instances (default: demos/, in a source checkout)\",\n )\n p.add_argument(\n \"--both-modes\",\n action=\"store_true\",\n help=\"render a light and a dark variant of every chart\",\n )\n p.add_argument(\n \"--animate\", action=\"store_true\", help=\"also render the search animations\"\n )\n _add_budget(p, default_time=10.0)\n p.set_defaults(func=_cmd_demo)\n\n\n# --------------------------------------------------------------------------\ndef _observers(args):\n \"\"\"Build the observer for a solve, honouring --live/--trace/--plot.\"\"\"\n from .trace import MultiObserver, TraceRecorder\n\n wants_trace = bool(\n args.trace\n or args.plot\n or getattr(args, \"tree\", None)\n or getattr(args, \"plan_plot\", None)\n )\n recorder = TraceRecorder() if wants_trace else None\n dashboard = None\n if args.live:\n from .live import TerminalDashboard\n\n dashboard = TerminalDashboard()\n if recorder and dashboard:\n return MultiObserver(recorder, dashboard), recorder\n return (dashboard or recorder), recorder\n\n\ndef _cmd_solve(args) -> int:\n if args.plot or args.tree or args.plan_plot:\n _need_viz(\"--plot/--tree/--plan-plot\")\n task = build_task(args.domain, args.problem)\n heuristic = args.heuristic # \"none\" is resolved by solve_task\n kwargs = {\"weight\": args.weight} if args.search == \"wastar\" else {}\n observer, recorder = _observers(args)\n result = solve_task(\n task,\n args.search,\n heuristic,\n observer=observer,\n max_expansions=args.max_expansions,\n time_limit=_time_limit(args),\n **kwargs,\n )\n\n trace = recorder.trace if recorder else None\n if trace is not None:\n if args.trace:\n trace.save(args.trace)\n print(f\"Wrote trace to {args.trace}\")\n _write_charts(args, trace)\n\n if not result.solved:\n if not args.live:\n if result.truncated:\n print(\n \"No plan found within the budget \"\n \"(the instance may still be solvable).\"\n )\n else:\n print(\"No plan found.\")\n _print_stats(result)\n return EXIT_TRUNCATED if result.truncated else EXIT_NO_PLAN\n valid = validate_plan(task, result.plan)\n visible = task.visible_plan(result.plan)\n if not args.quiet:\n header = f\"Plan ({len(visible)} steps, cost {result.cost}\"\n if task.temporal:\n header += f\", makespan {task.makespan(result.plan):g}\"\n print(header + \"):\")\n for i, op in enumerate(visible):\n print(f\" {i + 1:3d}. {op.base_name}\")\n print(f\"Valid: {valid}\")\n if not args.live:\n _print_stats(result)\n return EXIT_SOLVED if valid else EXIT_INVALID_PLAN\n\n\ndef _write_charts(args, trace) -> None:\n targets = [\n (args.plot, \"plot_search_progress\"),\n (getattr(args, \"tree\", None), \"plot_search_tree\"),\n (getattr(args, \"plan_plot\", None), \"plot_plan_timeline\"),\n ]\n if not any(path for path, _ in targets):\n return\n from . import viz # _cmd_solve checked it is installed before searching\n\n for path, function in targets:\n if not path:\n continue\n getattr(viz, function)(trace, path, dark=args.dark)\n print(f\"Wrote {path}\")\n\n\ndef _cmd_benchmark(args) -> int:\n if args.plot or args.dashboard:\n _need_viz(\"--plot/--dashboard\")\n try:\n heuristic_spec(args.heuristic)\n except argparse.ArgumentTypeError as exc:\n raise CLIError(str(exc)) from None\n planners = [name.strip() for name in args.planners.split(\",\") if name.strip()]\n unknown = [name for name in planners if name not in PLANNERS]\n if unknown:\n raise CLIError(f\"unknown planner(s) {unknown}; available: {sorted(PLANNERS)}\")\n instances = discover_instances(args.root)\n if not instances:\n raise CLIError(f\"no instances found under {args.root}\")\n configs = [\n (planner, args.heuristic if _needs_heuristic(planner) else None)\n for planner in planners\n ]\n\n rows = run_benchmark(\n instances,\n configs,\n max_expansions=args.max_expansions,\n time_limit=_time_limit(args),\n )\n _print_summary(summarize(rows))\n if args.csv:\n to_csv(rows, args.csv)\n print(f\"\\nWrote per-run results to {args.csv}\")\n if args.plot:\n plot_summary(rows, args.plot, metric=args.metric)\n print(f\"Wrote plot to {args.plot}\")\n if args.dashboard:\n from .viz import plot_benchmark_dashboard\n\n plot_benchmark_dashboard(rows, args.dashboard, dark=args.dark)\n print(f\"Wrote dashboard to {args.dashboard}\")\n return 0\n\n\ndef _cmd_animate(args) -> int:\n _need_viz(\"animate\")\n from .viz import animate_search\n\n task = build_task(args.domain, args.problem)\n _, trace = trace_search(\n task,\n args.search,\n args.heuristic,\n max_expansions=args.max_expansions,\n time_limit=_time_limit(args),\n )\n animate_search(\n trace, args.output, dark=args.dark, fps=args.fps, seconds=args.seconds\n )\n print(f\"Wrote animation to {args.output}\")\n return 0\n\n\ndef _add_requirements(sub):\n p = sub.add_parser(\n \"requirements\",\n help=\"show which PDDL requirement flags are supported, and how\",\n )\n p.add_argument(\n \"--support\",\n default=None,\n help=\"filter by support level: native, compiled, partial or rejected\",\n )\n p.add_argument(\"--json\", action=\"store_true\", help=\"emit machine-readable JSON\")\n p.add_argument(\"--verbose\", action=\"store_true\", help=\"include the full notes\")\n p.set_defaults(func=_cmd_requirements)\n\n\ndef _add_plugins(sub):\n p = sub.add_parser(\n \"plugins\",\n help=\"list planners, heuristics and generators added by installed packages\",\n )\n p.set_defaults(func=_cmd_plugins)\n\n\ndef _cmd_plugins(args) -> int:\n rows = describe_plugins()\n if not rows:\n print(\"No plugins installed. See .docs/EXTENDING.md to write one.\")\n return 0\n for row in rows:\n where = row[\"source\"] or f\"SKIPPED: {row['error']}\"\n print(f\"{row['kind']:<10}{row['name']:<20}{where}\")\n return 0\n\n\ndef _add_generate(sub):\n from .generator import GENERATORS\n\n p = sub.add_parser(\n \"generate\", help=\"generate PDDL instances reproducibly from a seed\"\n )\n p.add_argument(\"kind\", choices=sorted(GENERATORS))\n p.add_argument(\"-o\", \"--output\", default=None, help=\"write into this folder\")\n p.add_argument(\"-n\", \"--size\", type=int, default=4, help=\"instance size\")\n p.add_argument(\"--seed\", type=int, default=0)\n p.add_argument(\n \"--count\",\n type=int,\n default=1,\n help=\"generate a ladder of instances with increasing size\",\n )\n p.add_argument(\n \"--step\", type=int, default=1, help=\"size increment between ladder rungs\"\n )\n p.set_defaults(func=_cmd_generate)\n\n\ndef _add_learn(sub):\n from .generator import GENERATORS\n\n p = sub.add_parser(\n \"learn\",\n help=\"train a heuristic from solved plans, then reinforce it on search cost\",\n description=(\n \"Generate a ladder of instances, solve the small ones, fit a network \"\n \"to the cost-to-go their plans reveal, and optionally tune it against \"\n \"the number of nodes search actually expands. Writes a model that \"\n \"'-H learned:' accepts everywhere.\"\n ),\n )\n p.add_argument(\"kind\", choices=sorted(GENERATORS))\n p.add_argument(\"-o\", \"--output\", default=None, help=\"write the model here\")\n p.add_argument(\n \"--sizes\",\n default=\"3-6\",\n help=\"training ladder, as 'lo-hi' (default 3-6). Keep these small: they \"\n \"have to be solvable optimally\",\n )\n p.add_argument(\n \"--seeds-per-size\",\n type=int,\n default=2,\n help=\"instances per rung; more seeds usually beats more rungs\",\n )\n p.add_argument(\"--seed\", type=int, default=0)\n p.add_argument(\"--epochs\", type=int, default=60)\n p.add_argument(\n \"--rank-weight\",\n type=float,\n default=0.8,\n help=\"share of the objective spent on ordering rather than magnitude; \"\n \"GBFS only reads the order\",\n )\n p.add_argument(\n \"--dagger\",\n type=int,\n default=0,\n metavar=\"ROUNDS\",\n help=\"retrain on the states the heuristic's own search visits\",\n )\n p.add_argument(\n \"--bootstrap\",\n default=None,\n metavar=\"LO-HI\",\n help=\"grow the corpus with harder instances as they become solvable\",\n )\n p.add_argument(\n \"--cem\",\n type=int,\n default=0,\n metavar=\"ITERATIONS\",\n help=\"optimise expansions directly (needs instances with headroom; \"\n \"see --cem-sizes)\",\n )\n p.add_argument(\n \"--cem-sizes\",\n default=None,\n metavar=\"LO-HI\",\n help=\"instances to tune search cost on; defaults to a rung above the \"\n \"training ladder, because the training ladder has no headroom left\",\n )\n p.add_argument(\n \"--evaluate\",\n default=None,\n metavar=\"LO-HI\",\n help=\"after training, benchmark against hff/goalcount on these sizes\",\n )\n p.set_defaults(func=_cmd_learn)\n\n\ndef _range(spec: str):\n \"\"\"Parse ``'3-6'`` or ``'5'`` into a range.\"\"\"\n text = str(spec).strip()\n try:\n if \"-\" in text:\n lo, _, hi = text.partition(\"-\")\n return range(int(lo), int(hi) + 1)\n return range(int(text), int(text) + 1)\n except ValueError:\n raise CLIError(f\"'{spec}' is not a size or a range like 3-6\") from None\n\n\ndef _cmd_learn(args) -> int:\n from .learn import RLConfig, TrainConfig, learn_heuristic\n from .learn.pipeline import (\n evaluate_transfer,\n summarise_transfer,\n tasks_from_generator,\n )\n\n output = args.output or f\"{args.kind}.heur.json\"\n try:\n bundle = learn_heuristic(\n args.kind,\n sizes=_range(args.sizes),\n seeds_per_size=args.seeds_per_size,\n seed=args.seed,\n train_config=TrainConfig(\n epochs=args.epochs, rank_weight=args.rank_weight, seed=args.seed\n ),\n rl_config=RLConfig(seed=args.seed, verbose=True),\n dagger_rounds=args.dagger,\n bootstrap_sizes=_range(args.bootstrap) if args.bootstrap else None,\n cem_iterations=args.cem,\n cem_sizes=_range(args.cem_sizes) if args.cem_sizes else None,\n verbose=True,\n )\n except RuntimeError as exc:\n print(f\"jupyddl: error: {exc}\", file=sys.stderr)\n return EXIT_BAD_INPUT\n\n bundle.save(output)\n print(f\"\\nWrote {output}\")\n print(\n f\" features {bundle.space.size} over {len(bundle.space.vocabulary)} predicates\"\n )\n print(f\" parameters {bundle.model.num_parameters}\")\n metrics = bundle.metrics\n # Say \"held out\" only of what was: a corpus of one instance, or held-out\n # instances whose plans never branched, leave nothing but training data.\n held = metrics.get(\"held_out_instances\", 0)\n if held:\n print(f\" held out MAE {metrics.get('mae', 0):.2f} over {held} instances\")\n else:\n print(f\" training MAE {metrics.get('mae', 0):.2f} (nothing was held out)\")\n if \"top1\" not in metrics:\n print(\" top-1 n/a (the corpus has no ranking groups)\")\n else:\n where = (\n \"held out\"\n if metrics.get(\"ranking_held_out\")\n else \"on training groups; none were held out\"\n )\n print(f\" top-1 {metrics['top1']:.3f} ({where})\")\n print(f\"\\nUse it:\\n jupyddl solve -s gbfs -H learned:{output}\")\n\n if args.evaluate:\n tasks = tasks_from_generator(\n args.kind, _range(args.evaluate), seed=args.seed + 7777, seeds_per_size=2\n )\n print(f\"\\nBenchmark on {len(tasks)} unseen instances (gbfs):\")\n rows = evaluate_transfer(bundle, tasks)\n summary = summarise_transfer(rows)\n print(\n f\" {'heuristic':<12}{'coverage':>10}{'expanded':>12}\"\n f\"{'seconds':>10}{'cost':>8}\"\n )\n for name in [\"learned\"] + [k for k in summary if k != \"learned\"]:\n agg = summary[name]\n cost = \"\u2014\" if agg[\"mean_cost\"] is None else f\"{agg['mean_cost']:.1f}\"\n print(\n f\" {name:<12}{agg['coverage']:>10.2f}{agg['mean_expanded']:>12.0f}\"\n f\"{agg['mean_seconds']:>10.3f}{cost:>8}\"\n )\n common = summary[\"learned\"][\"common\"]\n print(\n \" expanded and seconds are over every instance, an unsolved one \"\n \"charged double;\\n\"\n f\" cost is over the {common} solved by every heuristic that solved any\"\n )\n return 0\n\n\ndef _cmd_requirements(args) -> int:\n from .requirements import as_rows, summary\n\n rows = as_rows()\n if args.support:\n rows = [row for row in rows if row[\"support\"] == args.support]\n if not rows:\n print(\n f\"No requirements with support level '{args.support}'.\", file=sys.stderr\n )\n return EXIT_BAD_INPUT\n\n if args.json:\n import json\n\n print(json.dumps({\"requirements\": rows, \"summary\": summary()}, indent=2))\n return 0\n\n counts = summary()\n print(\n f\"jupyddl PDDL support: {counts['native']} native, \"\n f\"{counts['compiled']} compiled, {counts['partial']} partial, \"\n f\"{counts['rejected']} rejected\\n\"\n )\n print(f\"{'requirement':<30}{'PDDL':<7}{'support':<11}summary\")\n print(\"-\" * 100)\n for row in rows:\n print(f\"{row['name']:<30}{row['pddl']:<7}{row['support']:<11}{row['summary']}\")\n if args.verbose and row[\"note\"]:\n for line in _wrap(row[\"note\"], 92):\n print(f\"{'':<48}{line}\")\n if not args.verbose:\n print(\"\\nRun with --verbose for the details of each compilation.\")\n return 0\n\n\ndef _wrap(text: str, width: int) -> list:\n words = text.split()\n lines, current = [], \"\"\n for word in words:\n if len(current) + len(word) + 1 > width:\n lines.append(current)\n current = word\n else:\n current = f\"{current} {word}\".strip()\n if current:\n lines.append(current)\n return lines\n\n\ndef _cmd_generate(args) -> int:\n from .generator import generate, write_instance\n\n sizes = [args.size + i * args.step for i in range(max(1, args.count))]\n if args.output is None:\n if len(sizes) > 1:\n print(\n \"Generating a ladder needs --output; a single instance can go \"\n \"to stdout but several cannot.\",\n file=sys.stderr,\n )\n return EXIT_BAD_INPUT\n domain, problem = generate(args.kind, size=args.size, seed=args.seed)\n print(\";; ---------- domain.pddl ----------\")\n print(domain)\n print(\";; ---------- problem.pddl ----------\")\n print(problem)\n return 0\n\n for size in sizes:\n folder = write_instance(args.kind, args.output, size=size, seed=args.seed)\n print(f\"Wrote {folder}\")\n return 0\n\n\ndef _cmd_demo(args) -> int:\n \"\"\"Render the whole gallery: per-instance charts plus a benchmark dashboard.\"\"\"\n _need_viz(\"demo\")\n from .viz import (\n plot_benchmark_dashboard,\n plot_plan_timeline,\n plot_planner_comparison,\n plot_search_progress,\n plot_search_tree,\n )\n\n instances = discover_instances(args.root)\n if not instances:\n raise CLIError(f\"no instances found under {args.root}\")\n budget = {\"max_expansions\": args.max_expansions, \"time_limit\": _time_limit(args)}\n os.makedirs(args.output, exist_ok=True)\n modes = [False, True] if args.both_modes else [False]\n\n configs = [(\"astar\", \"lmcut\"), (\"astar\", \"hmax\"), (\"gbfs\", \"hff\"), (\"bfs\", None)]\n for instance in instances:\n print(f\"-- {instance.name}\")\n try:\n task = build_task(instance.domain, instance.problem)\n except Exception as exc:\n print(f\" skipped: {type(exc).__name__}: {exc}\")\n continue\n traces = []\n for planner, heuristic in configs:\n try:\n _, trace = trace_search(task, planner, heuristic, **budget)\n traces.append(trace)\n except Exception as exc:\n print(f\" {planner}/{heuristic}: {type(exc).__name__}: {exc}\")\n if not traces:\n continue\n for dark in modes:\n suffix = \"-dark\" if dark else \"\"\n base = os.path.join(args.output, instance.name)\n plot_search_progress(traces[0], f\"{base}-progress{suffix}.png\", dark=dark)\n plot_search_tree(traces[0], f\"{base}-tree{suffix}.png\", dark=dark)\n plot_plan_timeline(traces[0], f\"{base}-plan{suffix}.png\", dark=dark)\n plot_planner_comparison(traces, f\"{base}-compare{suffix}.png\", dark=dark)\n if args.animate:\n from .viz import animate_search\n\n animate_search(\n traces[0], os.path.join(args.output, f\"{instance.name}-search.mp4\")\n )\n\n rows = run_benchmark(\n instances,\n [\n (\"astar\", \"lmcut\"),\n (\"astar\", \"hmax\"),\n (\"gbfs\", \"hff\"),\n (\"ehc\", \"hff\"),\n (\"bfs\", None),\n (\"dijkstra\", None),\n ],\n **budget,\n )\n to_csv(rows, os.path.join(args.output, \"benchmark.csv\"))\n for dark in modes:\n suffix = \"-dark\" if dark else \"\"\n plot_benchmark_dashboard(\n rows, os.path.join(args.output, f\"benchmark{suffix}.png\"), dark=dark\n )\n print(f\"\\nGallery written to {args.output}/\")\n return 0\n\n\ndef _print_stats(result) -> None:\n s = result.stats\n print(\n f\"Stats: expanded={s.expanded} generated={s.generated} \"\n f\"evaluated={s.evaluated} reopened={s.reopened} \"\n f\"deadends={s.deadends} runtime={s.runtime:.4f}s\"\n )\n\n\ndef _print_summary(summary) -> None:\n # A label carries its planner arguments now, so it can outgrow a fixed column.\n width = max([20] + [len(key) + 2 for key in summary])\n print(f\"{'config':<{width}}{'coverage':>12}{'expanded':>12}{'runtime(s)':>12}\")\n print(\"-\" * (width + 36))\n for key, agg in summary.items():\n cov = f\"{agg['coverage']}/{agg['instances']}\"\n print(f\"{key:<{width}}{cov:>12}{agg['expanded']:>12}{agg['runtime']:>12.3f}\")\n\n\ndef main(argv=None) -> int:\n # Before the parser is built: ``--search`` and ``generate`` take their\n # ``choices`` from the registries, which is where plugins land.\n load_plugins()\n parser = argparse.ArgumentParser(\n prog=\"jupyddl\",\n description=\"Pure-Python PDDL planning framework.\",\n epilog=EXIT_CODES,\n formatter_class=argparse.RawDescriptionHelpFormatter,\n )\n parser.add_argument(\"--version\", action=\"version\", version=f\"jupyddl {__version__}\")\n sub = parser.add_subparsers(dest=\"command\", required=True)\n _add_solve(sub)\n _add_benchmark(sub)\n _add_animate(sub)\n _add_demo(sub)\n _add_requirements(sub)\n _add_generate(sub)\n _add_learn(sub)\n _add_plugins(sub)\n args = parser.parse_args(argv)\n try:\n return args.func(args)\n except (CLIError, PDDLError, OSError, ValueError, KeyError) as exc:\n # The user's input, not a bug: one line, not a traceback. A KeyError\n # here is a lookup by a name the user typed.\n if os.environ.get(\"JUPYDDL_DEBUG\"):\n raise\n message = exc.args[0] if isinstance(exc, KeyError) and exc.args else exc\n kind = \"\" if isinstance(exc, (CLIError, OSError)) else f\"{type(exc).__name__}: \"\n print(f\"jupyddl: error: {kind}{message}\", file=sys.stderr)\n return EXIT_BAD_INPUT\n\n\nif __name__ == \"__main__\": # pragma: no cover\n sys.exit(main())\n", "jupyddl/compile.py": "\"\"\"Compile PDDL 3 constructs down to the classical core.\n\nPreferences, trajectory constraints, timed initial literals and object fluents\nare all rewritten here, at the AST level, *before* grounding. The grounder and\nthe search never learn they existed \u2014 which is the point: one representation to\noptimise, and every new front-end feature is a source-to-source transformation\nrather than another special case in the hot loop.\n\nEach compilation is documented in :mod:`jupyddl.requirements`, including what it\ncosts you. The synthetic actions introduced along the way are all named with a\nleading ``__``; :attr:`jupyddl.task.Task.synthetic` collects them so a printed\nplan shows only the actions the domain author wrote.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport copy\nfrom dataclasses import replace\nfrom itertools import product\n\nfrom .parser.ast import (\n Action,\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n ConjunctiveEffect,\n Constraint,\n DelEffect,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Predicate,\n Preference,\n Problem,\n TimedEffect,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\n\n# Every fact and action this module invents is prefixed, so they cannot collide\n# with a domain's own names and are easy to filter out of a plan.\nPREFIX = \"__\"\nCLOCK = FluentRef(\"__time\", ())\n\n__all__ = [\"compile_problem\", \"SYNTHETIC_PREFIX\"]\n\nSYNTHETIC_PREFIX = PREFIX\n\n\n# --------------------------------------------------------------------------\n# small AST helpers\n# --------------------------------------------------------------------------\ndef _fact(name: str, *args) -> Atom:\n return Atom(name, tuple(args))\n\n\ndef _holds(name: str, *args) -> Literal:\n return Literal(_fact(name, *args), True)\n\n\ndef _absent(name: str, *args) -> Literal:\n return Literal(_fact(name, *args), False)\n\n\ndef _conjoin(*parts):\n \"\"\"And() over the parts, dropping trivially-true ones.\"\"\"\n kept = [p for p in parts if p is not None and not isinstance(p, Truth)]\n if not kept:\n return Truth(True)\n if len(kept) == 1:\n return kept[0]\n return And(tuple(kept))\n\n\ndef _free():\n \"\"\"An explicit zero cost.\n\n Grounding charges 1 for any action without a cost effect, which is right for\n a domain action and wrong for the bookkeeping this module invents: closing\n the plan or observing that a constraint held must not show up in the metric.\n \"\"\"\n return IncreaseCostEffect(0.0)\n\n\ndef _also(effect, *extra):\n \"\"\"Append effects to an existing effect tree.\"\"\"\n parts = list(effect.parts) if isinstance(effect, ConjunctiveEffect) else [effect]\n parts.extend(extra)\n return ConjunctiveEffect(parts)\n\n\ndef _negate(formula):\n \"\"\"Negation normal form of ``not formula``.\"\"\"\n if isinstance(formula, Truth):\n return Truth(not formula.value)\n if isinstance(formula, Literal):\n return Literal(formula.atom, not formula.positive)\n if isinstance(formula, EqualityConstraint):\n return EqualityConstraint(formula.left, formula.right, not formula.positive)\n if isinstance(formula, Comparison):\n flip = {\"<\": \">=\", \"<=\": \">\", \">\": \"<=\", \">=\": \"<\", \"=\": \"!=\", \"!=\": \"=\"}\n return Comparison(flip[formula.op], formula.left, formula.right)\n if isinstance(formula, And):\n return Or(tuple(_negate(p) for p in formula.parts))\n if isinstance(formula, Or):\n return And(tuple(_negate(p) for p in formula.parts))\n if isinstance(formula, Forall):\n return Exists(formula.params, _negate(formula.body))\n if isinstance(formula, Exists):\n return Forall(formula.params, _negate(formula.body))\n raise PDDLError(f\"cannot negate {formula!r}\")\n\n\ndef _is_domain_action(action: Action) -> bool:\n return not action.name.startswith(PREFIX)\n\n\ndef _changes_the_world(action: Action) -> bool:\n \"\"\"Domain actions, and the firing of a timed literal.\n\n These are the only actions that change a fact the domain author wrote, so\n every state on a plan's trajectory except the last is the state one of\n them is applied in. The other synthetic actions only move the clock or\n set bookkeeping facts.\n \"\"\"\n return _is_domain_action(action) or action.name.startswith(f\"{PREFIX}fire-til-\")\n\n\ndef _declare(domain: Domain, name: str, params=()) -> None:\n if all(p.name != name for p in domain.predicates):\n domain.predicates.append(Predicate(name, list(params)))\n\n\ndef _add_precondition(\n domain: Domain, formula, only_domain_actions=True, during=False\n) -> None:\n \"\"\"Conjoin ``formula`` onto every action's precondition.\n\n ``during`` also requires it of a durative action's ``late_condition``, i.e.\n in the state between its at-start and at-end effects -- when there is such\n a state: without at-start effects it is the state the action starts in.\n \"\"\"\n for index, action in enumerate(domain.actions):\n if only_domain_actions and not _is_domain_action(action):\n continue\n changes = {\"precondition\": _conjoin(action.precondition, formula)}\n if during and _has_start_effects(action.effect):\n changes[\"late_condition\"] = _conjoin(action.late_condition, formula)\n domain.actions[index] = replace(action, **changes)\n\n\ndef _add_conditional_effect(domain: Domain, condition, body) -> None:\n \"\"\"Give every action that changes the world a ``when condition body`` effect.\n\n Used for monitors that must not be optional: the planner cannot decline to\n notice that a constraint's trigger became true. That includes a timed\n literal firing: the state it fires in is on the trajectory too, and a\n monitor that skipped it would miss a trigger the clock produced.\n \"\"\"\n for index, action in enumerate(domain.actions):\n if not _changes_the_world(action):\n continue\n domain.actions[index] = replace(\n action, effect=_also(action.effect, WhenEffect(condition, body))\n )\n\n\ndef _is_empty_effect(effect) -> bool:\n \"\"\"True when ``effect`` changes no fact and no fluent (a cost is neither).\"\"\"\n if isinstance(effect, ConjunctiveEffect):\n return all(_is_empty_effect(part) for part in effect.parts)\n return isinstance(effect, IncreaseCostEffect)\n\n\ndef _has_start_effects(effect) -> bool:\n \"\"\"True for a durative action whose at-start effects are not empty.\"\"\"\n if isinstance(effect, TimedEffect):\n return not _is_empty_effect(effect.start)\n if isinstance(effect, ConjunctiveEffect):\n return any(_has_start_effects(part) for part in effect.parts)\n return False\n\n\ndef _metric_mentions(problem: Problem, name: str) -> bool:\n def walk(expression):\n if isinstance(expression, FluentRef):\n return expression.name == name\n if isinstance(expression, Arithmetic):\n return walk(expression.left) or walk(expression.right)\n return False\n\n return problem.metric is not None and walk(problem.metric[1])\n\n\n# --------------------------------------------------------------------------\n# quantified constraints and preferences\n# --------------------------------------------------------------------------\ndef _object_pool(domain: Domain, problem: Problem) -> dict:\n \"\"\"``type -> objects``, as the grounder will build it.\"\"\"\n from .grounding import _build_type_objects # grounding imports this module\n\n return _build_type_objects(domain, problem)\n\n\ndef _bindings(params, pool: dict):\n \"\"\"Every ``{var: object}`` assignment for ``params``, with its objects.\"\"\"\n variables = [var for var, _ in params]\n for combo in product(*(pool.get(typ, ()) for _, typ in params)):\n yield dict(zip(variables, combo)), combo\n\n\ndef _substitute_term(expression, binding: dict):\n if isinstance(expression, FluentRef):\n if not expression.args and expression.name in binding:\n return FluentRef(binding[expression.name], ())\n args = tuple(binding.get(arg, arg) for arg in expression.args)\n return FluentRef(expression.name, args)\n if isinstance(expression, Arithmetic):\n return Arithmetic(\n expression.op,\n _substitute_term(expression.left, binding),\n _substitute_term(expression.right, binding),\n )\n return expression\n\n\ndef _substitute(formula, binding: dict):\n \"\"\"``formula`` with the variables in ``binding`` replaced by objects.\"\"\"\n if isinstance(formula, Literal):\n atom = formula.atom\n args = tuple(binding.get(arg, arg) for arg in atom.args)\n return Literal(Atom(atom.predicate, args), formula.positive)\n if isinstance(formula, EqualityConstraint):\n return EqualityConstraint(\n binding.get(formula.left, formula.left),\n binding.get(formula.right, formula.right),\n formula.positive,\n )\n if isinstance(formula, Comparison):\n return Comparison(\n formula.op,\n _substitute_term(formula.left, binding),\n _substitute_term(formula.right, binding),\n )\n if isinstance(formula, (And, Or)):\n return type(formula)(tuple(_substitute(p, binding) for p in formula.parts))\n if isinstance(formula, (Forall, Exists)):\n # An inner quantifier shadows the outer variable of the same name.\n bound = {var for var, _ in formula.params}\n inner = {var: obj for var, obj in binding.items() if var not in bound}\n return type(formula)(formula.params, _substitute(formula.body, inner))\n return formula\n\n\n# --------------------------------------------------------------------------\n# object fluents\n# --------------------------------------------------------------------------\ndef compile_object_fluents(domain: Domain, problem: Problem):\n \"\"\"Turn ``(location ?p) - place`` into a predicate plus a uniqueness rule.\n\n ``(= (location ?p) ?x)`` becomes ``(__fn-location ?p ?x)``, and\n ``(assign (location ?p) ?y)`` clears the old value before setting the new\n one, so the predicate stays single-valued. Object fluents used as *nested\n terms* \u2014 ``(at ?t (location ?p))`` \u2014 are refused: flattening those needs a\n fresh existential per occurrence, and the equality form above expresses the\n same thing without the guesswork.\n \"\"\"\n if not domain.object_fluents:\n return domain, problem\n\n fluents = {fluent.name: fluent for fluent in domain.object_fluents}\n\n def predicate_name(name: str) -> str:\n return f\"{PREFIX}fn-{name}\"\n\n for fluent in domain.object_fluents:\n params = list(fluent.params) + [(\"?__value\", fluent.result_type)]\n _declare(domain, predicate_name(fluent.name), params)\n\n def object_value(fluent: str, value) -> str:\n \"\"\"The object a term names, refusing anything that is not one.\"\"\"\n if isinstance(value, Number):\n raise UnsupportedFeatureError(\n f\"'{fluent}' returns an object, so it cannot be compared with \"\n \"or set to a number\"\n )\n if isinstance(value, FluentRef) and (value.args or value.name in fluents):\n # (loc ?q) here would be read as the *object* \"loc\".\n raise UnsupportedFeatureError(\n f\"'{fluent}' is compared with or set to the value of another \"\n f\"function, {value}; that is not supported \u2014 introduce a \"\n \"variable for it, e.g. (and (= (loc ?q) ?x) ...)\"\n )\n if not isinstance(value, FluentRef):\n raise UnsupportedFeatureError(\n f\"'{fluent}' returns an object, so arithmetic on it is meaningless\"\n )\n return value.name\n\n def rewrite_condition(formula):\n if isinstance(formula, Comparison) and formula.op in (\"=\", \"!=\"):\n left, right = formula.left, formula.right\n for a, b in ((left, right), (right, left)):\n if isinstance(a, FluentRef) and a.name in fluents:\n if isinstance(b, FluentRef) and b.name in fluents:\n raise UnsupportedFeatureError(\n \"comparing two object fluents directly is not \"\n \"supported; introduce a variable for one of them\"\n )\n value = object_value(a.name, b)\n atom = _fact(predicate_name(a.name), *a.args, value)\n return Literal(atom, formula.op == \"=\")\n return formula\n if isinstance(formula, And):\n return And(tuple(rewrite_condition(p) for p in formula.parts))\n if isinstance(formula, Or):\n return Or(tuple(rewrite_condition(p) for p in formula.parts))\n if isinstance(formula, Forall):\n return Forall(formula.params, rewrite_condition(formula.body))\n if isinstance(formula, Exists):\n return Exists(formula.params, rewrite_condition(formula.body))\n return formula\n\n def rewrite_effect(effect):\n if isinstance(effect, ConjunctiveEffect):\n return ConjunctiveEffect([rewrite_effect(p) for p in effect.parts])\n if isinstance(effect, TimedEffect):\n return TimedEffect(rewrite_effect(effect.start), rewrite_effect(effect.end))\n if isinstance(effect, ForallEffect):\n return ForallEffect(effect.params, rewrite_effect(effect.body))\n if isinstance(effect, WhenEffect):\n return WhenEffect(\n rewrite_condition(effect.condition), rewrite_effect(effect.body)\n )\n if isinstance(effect, NumericEffect) and effect.target.name in fluents:\n if effect.op != \"assign\":\n raise UnsupportedFeatureError(\n f\"'{effect.op}' is arithmetic, but '{effect.target.name}' \"\n \"returns an object; only 'assign' is meaningful\"\n )\n fluent = fluents[effect.target.name]\n name = predicate_name(fluent.name)\n new = object_value(fluent.name, effect.value)\n # Clear whatever the function used to return, then set the new\n # value: that is what keeps it a function rather than a relation.\n clear = ForallEffect(\n [(\"?__old\", fluent.result_type)],\n WhenEffect(\n _holds(name, *effect.target.args, \"?__old\"),\n DelEffect(_fact(name, *effect.target.args, \"?__old\")),\n ),\n )\n return ConjunctiveEffect(\n [clear, AddEffect(_fact(name, *effect.target.args, new))]\n )\n return effect\n\n def rewrite_constraint(constraint):\n if isinstance(constraint, Preference):\n return replace(constraint, body=rewrite_constraint(constraint.body))\n args = tuple(rewrite_condition(arg) for arg in constraint.args)\n return replace(constraint, args=args)\n\n for index, action in enumerate(domain.actions):\n late = action.late_condition\n domain.actions[index] = replace(\n action,\n precondition=rewrite_condition(action.precondition),\n effect=rewrite_effect(action.effect),\n late_condition=None if late is None else rewrite_condition(late),\n )\n for index, rule in enumerate(domain.derived):\n domain.derived[index] = replace(rule, body=rewrite_condition(rule.body))\n\n problem.goal = rewrite_condition(problem.goal)\n for index, preference in enumerate(problem.preferences):\n problem.preferences[index] = replace(\n preference, body=rewrite_condition(preference.body)\n )\n # Trajectory constraints read the same terms; left alone, an equality on an\n # object fluent would be taken for a numeric comparison.\n domain.constraints = [rewrite_constraint(c) for c in domain.constraints]\n problem.constraints = [rewrite_constraint(c) for c in problem.constraints]\n\n # `(= (location p1) depot)` in :init becomes a plain fact.\n for fluent_ref, value in problem.init_objects.items():\n if fluent_ref.name not in fluents:\n raise PDDLError(\n f\"'{fluent_ref.name}' is assigned an object in :init but is not \"\n \"declared as an object fluent in :functions\"\n )\n problem.init.append(\n _fact(predicate_name(fluent_ref.name), *fluent_ref.args, value)\n )\n problem.init_objects = {}\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# timed initial literals\n# --------------------------------------------------------------------------\ndef compile_timed_initials(domain: Domain, problem: Problem):\n \"\"\"Give the model a clock, and make each timed literal fire off it.\n\n Elapsed time becomes the numeric fluent ``(__time)``, advanced by each\n durative action's duration. Every timed literal gets a zero-cost\n ``__fire-til-k`` action guarded by ``(>= (__time) t)``, and a\n ``__wait-til-k`` action that lets the planner advance the clock to ``t``\n when it wants the literal to happen.\n\n The literal must not be *skipped*: every domain action therefore carries\n ``(or (< (__time) t) (__til-k))`` for each timed literal, so once the clock\n passes ``t`` nothing else may happen until the literal has fired.\n\n Literals must also fire **in time order**, which is a separate constraint:\n ``(at 0 (open))`` and ``(at 3 (not (open)))`` describe a shop that opens then\n shuts, but firing them the other way round would leave it open forever. Each\n firing therefore requires every earlier literal to have fired already.\n\n Because actions do not overlap, a literal scheduled strictly inside an\n action's duration fires immediately after it rather than during it. The\n goal carries the same guards, so a plan cannot end in a state a due literal\n would already have changed.\n \"\"\"\n if not problem.timed_initials:\n return domain, problem\n if _metric_mentions(problem, \"total-time\"):\n raise UnsupportedFeatureError(\n \"a (total-time) metric with timed initial literals is not supported: \"\n \"waiting for a literal takes time that no action is charged for, so \"\n \"the search would not minimise the makespan\"\n )\n\n problem.init_numeric = dict(problem.init_numeric)\n problem.init_numeric.setdefault(CLOCK, 0.0)\n\n # The clock only moves if something moves it.\n for index, action in enumerate(domain.actions):\n if action.duration is None or not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action,\n effect=_also(\n action.effect, NumericEffect(\"increase\", CLOCK, action.duration)\n ),\n )\n\n guards = []\n earlier: list = []\n # \"Literal j is the first one not fired yet, and it is not due\": one of\n # these holds exactly when every due literal has fired.\n caught_up = []\n for k, timed in enumerate(sorted(problem.timed_initials, key=lambda t: t.time)):\n marker = f\"{PREFIX}til-{k}\"\n _declare(domain, marker)\n due = Comparison(\">=\", CLOCK, Number(timed.time))\n not_due = Comparison(\"<\", CLOCK, Number(timed.time))\n # Everything scheduled before this must already have happened.\n in_order = _conjoin(*[_holds(name) for name in earlier])\n\n body = (\n AddEffect(timed.literal.atom)\n if timed.literal.positive\n else DelEffect(timed.literal.atom)\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}fire-til-{k}\",\n [],\n _conjoin(due, _absent(marker), in_order),\n ConjunctiveEffect([body, AddEffect(_fact(marker)), _free()]),\n )\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}wait-til-{k}\",\n [],\n # Waiting past an event that has not happened yet would skip it.\n _conjoin(not_due, in_order),\n ConjunctiveEffect(\n [NumericEffect(\"assign\", CLOCK, Number(timed.time)), _free()]\n ),\n )\n )\n guards.append(Or((not_due, _holds(marker))))\n caught_up.append(_conjoin(in_order, _absent(marker), not_due))\n earlier.append(marker)\n caught_up.append(_conjoin(*[_holds(name) for name in earlier]))\n\n # A due literal blocks everything else until it has fired -- including the\n # end of the plan. Without the goal guard, a durative action that overran a\n # literal could end the plan in a state the literal would have changed.\n # Literals fire in order, so the goal can say it with one disjunct per\n # literal rather than the 2^k a conjunction of the guards expands to.\n for guard in guards:\n _add_precondition(domain, guard)\n problem.goal = _conjoin(problem.goal, Or(tuple(caught_up)))\n problem.timed_initials = []\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# trajectory constraints\n# --------------------------------------------------------------------------\ndef compile_constraints(domain: Domain, problem: Problem):\n \"\"\"Compile ``(:constraints ...)`` into preconditions, monitors and goals.\n\n * ``always phi`` \u2014 conjoined onto every action's precondition and onto the\n goal. Every state on a plan's trajectory is either the initial state, a\n state an action is taken from, or the final state, so those three checks\n cover all of them.\n * ``at-end phi`` \u2014 conjoined onto the goal.\n * ``sometime phi`` \u2014 a zero-cost ``__observe`` action, applicable exactly\n when ``phi`` holds, sets a monitor fact the goal then requires.\n * ``sometime-before phi psi`` \u2014 the same monitor for ``psi``, plus\n ``always (phi implies monitor)``.\n * ``sometime-after phi psi`` \u2014 a *forced* monitor: every action records an\n outstanding obligation when ``phi`` holds without ``psi``, and discharges\n it when ``psi`` holds. Monitors read the state an action is applied in,\n so the final state is checked by the goal instead: ``psi`` holds there,\n or nothing is outstanding and ``phi`` does not hold either.\n * ``at-most-once phi`` \u2014 forced monitors for \"phi has held\" and \"phi has\n since stopped\", plus ``always not (phi and stopped)``.\n\n Forced monitors ride on conditional effects, which the planner cannot\n decline; optional ones ride on actions, which it applies when convenient.\n The difference matters: a constraint the planner could satisfy by *not\n looking* would not be a constraint. Forced monitors ride on the actions\n that fire timed literals as well, for the same reason.\n\n A constraint under ``forall`` becomes one constraint per binding.\n \"\"\"\n constraints = list(domain.constraints) + list(problem.constraints)\n if not constraints:\n return domain, problem\n\n for constraint in constraints:\n if isinstance(constraint, Preference):\n raise UnsupportedFeatureError(\n f\"the soft constraint '{constraint.name}' is not supported: \"\n \"preferences are supported over goals, not over trajectory \"\n \"constraints\"\n )\n constraints = _expand_constraints(domain, problem, constraints)\n\n # A durative action with at-start effects passes through a state between\n # its start and its end. `always` is checked there (see below), but the\n # monitors only see the states actions are applied in, so they would miss\n # it: refuse rather than accept a plan whose middle broke the constraint.\n if any(_has_start_effects(action.effect) for action in domain.actions):\n for constraint in constraints:\n if constraint.kind not in (\"always\", \"at-end\"):\n raise UnsupportedFeatureError(\n f\"a '{constraint.kind}' constraint over a domain whose \"\n \"durative actions have at-start effects is not supported: \"\n \"its monitor cannot see the state between an action's start \"\n \"and end. Only 'always' and 'at-end' are\"\n )\n\n goal_parts = [problem.goal]\n invariants = []\n\n for index, constraint in enumerate(constraints):\n kind = constraint.kind\n if kind == \"always\":\n invariants.append(constraint.args[0])\n elif kind == \"at-end\":\n goal_parts.append(constraint.args[0])\n elif kind == \"sometime\":\n marker = _observation_monitor(domain, index, constraint.args[0])\n goal_parts.append(_holds(marker))\n elif kind == \"sometime-before\":\n trigger, earlier = constraint.args\n marker = _observation_monitor(domain, index, earlier)\n # \"phi implies the monitor\" as an invariant: by the time phi holds,\n # psi must already have been observed.\n invariants.append(Or((_negate(trigger), _holds(marker))))\n elif kind == \"sometime-after\":\n trigger, follower = constraint.args\n marker = f\"{PREFIX}pending-{index}\"\n _declare(domain, marker)\n _add_conditional_effect(\n domain,\n _conjoin(trigger, _negate(follower)),\n AddEffect(_fact(marker)),\n )\n _add_conditional_effect(domain, follower, DelEffect(_fact(marker)))\n # No monitor reads the final state, so the goal does: `psi` there\n # discharges any obligation, including one the last action's\n # monitor just recorded; otherwise nothing may be outstanding and\n # the final state must not create a fresh one.\n goal_parts.append(\n Or((follower, _conjoin(_absent(marker), _negate(trigger))))\n )\n elif kind == \"at-most-once\":\n phi = constraint.args[0]\n seen = f\"{PREFIX}amo-seen-{index}\"\n closed = f\"{PREFIX}amo-closed-{index}\"\n _declare(domain, seen)\n _declare(domain, closed)\n _add_conditional_effect(domain, phi, AddEffect(_fact(seen)))\n _add_conditional_effect(\n domain,\n _conjoin(_negate(phi), _holds(seen)),\n AddEffect(_fact(closed)),\n )\n # Holding again after an interval has closed is the second interval.\n invariants.append(Or((_negate(phi), _absent(closed))))\n else: # pragma: no cover - the parser rejects anything else\n raise UnsupportedFeatureError(f\"unsupported constraint '{kind}'\")\n\n for invariant in invariants:\n # Every action, not just the domain's own. The coverage argument above\n # holds only if each state on the trajectory is either taken from by an\n # action that checks the invariant or is the final state \u2014 and the\n # actions timed initial literals compile to *change facts*. Exempting\n # them let a plan step through a state the invariant forbade: a literal\n # that clears `(safe)` at t=3 and one that restores it at t=4 fire\n # back-to-back with nothing checking the state in between. `during`\n # also checks the state between a durative action's start and end.\n _add_precondition(domain, invariant, only_domain_actions=False, during=True)\n goal_parts.append(invariant)\n\n problem.goal = _conjoin(*goal_parts)\n domain.constraints = []\n problem.constraints = []\n return domain, problem\n\n\ndef _expand_constraints(domain: Domain, problem: Problem, constraints) -> list:\n \"\"\"One constraint per binding of a ``forall`` around it.\"\"\"\n if not any(constraint.params for constraint in constraints):\n return constraints\n pool = _object_pool(domain, problem)\n expanded = []\n for constraint in constraints:\n if not constraint.params:\n expanded.append(constraint)\n continue\n for binding, _ in _bindings(constraint.params, pool):\n args = tuple(_substitute(arg, binding) for arg in constraint.args)\n expanded.append(Constraint(constraint.kind, args))\n return expanded\n\n\ndef _observation_monitor(domain: Domain, index: int, formula) -> str:\n \"\"\"A fact the planner can set, for free, whenever ``formula`` holds.\"\"\"\n marker = f\"{PREFIX}seen-{index}\"\n _declare(domain, marker)\n domain.actions.append(\n Action(\n f\"{PREFIX}observe-{index}\",\n [],\n _conjoin(formula, _absent(marker)),\n ConjunctiveEffect([AddEffect(_fact(marker)), _free()]),\n )\n )\n return marker\n\n\n# --------------------------------------------------------------------------\n# preferences\n# --------------------------------------------------------------------------\ndef compile_preferences(domain: Domain, problem: Problem):\n \"\"\"Turn each soft goal into a priced choice: satisfy it, or pay for it.\n\n A closing phase makes this sound. ``__close`` ends the plan \u2014 every action\n before it, the domain's own and the ones firing timed literals alike,\n requires that it has *not* happened \u2014 after which each preference is\n resolved by one of two zero-parameter actions: a free one that requires the\n preference to hold, or one costing the metric's ``(is-violated p)`` weight\n that does not. Cost-optimal search then picks whichever is cheaper, which is\n exactly what minimising the metric means.\n\n Freezing the state first is the point: without it the planner could satisfy\n a preference halfway through and then break it, and still be paid for it.\n\n Under a metric, a preference it does not mention costs nothing to violate \u2014\n it has no effect on plan quality. Without a metric each costs 1. A\n preference under ``forall`` is one preference per binding, each priced at\n the name's weight, as ``(is-violated p)`` counts violated instances.\n \"\"\"\n declared = {preference.name for preference in problem.preferences}\n for name in problem.violation_weights:\n if name not in declared:\n raise PDDLError(\n f\"the metric prices (is-violated {name}), but the goal declares \"\n f\"no preference named '{name}'\"\n )\n if not problem.preferences:\n return domain, problem\n\n default_weight = 0.0 if problem.metric is not None else 1.0\n instances = [] # (name, label, body)\n pool = None\n for preference in problem.preferences:\n if not preference.params:\n instances.append((preference.name, preference.name, preference.body))\n continue\n if pool is None:\n pool = _object_pool(domain, problem)\n for binding, objects in _bindings(preference.params, pool):\n label = \"-\".join((preference.name,) + tuple(objects))\n instances.append(\n (preference.name, label, _substitute(preference.body, binding))\n )\n\n closed = f\"{PREFIX}closed\"\n _declare(domain, closed)\n # Everything stops once the plan is closed, timed literals included: a\n # literal firing afterwards would change the state the preferences were\n # just paid out on.\n _add_precondition(domain, _absent(closed), only_domain_actions=False)\n\n domain.actions.append(\n Action(\n f\"{PREFIX}close\",\n [],\n _absent(closed),\n ConjunctiveEffect([AddEffect(_fact(closed)), _free()]),\n )\n )\n\n goal_parts = [problem.goal, _holds(closed)]\n for index, (name, label, body) in enumerate(instances):\n done = f\"{PREFIX}pref-{index}\"\n _declare(domain, done)\n weight = float(problem.violation_weights.get(name, default_weight))\n if weight < 0:\n raise PDDLError(\n f\"preference '{name}' has a negative violation \"\n \"weight, which would reward breaking it\"\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}satisfy-{label}\",\n [],\n _conjoin(_holds(closed), _absent(done), body),\n ConjunctiveEffect([AddEffect(_fact(done)), _free()]),\n )\n )\n # The penalty rides on the action cost, so plain cost-optimal search\n # optimises the metric without knowing what a preference is.\n domain.actions.append(\n Action(\n f\"{PREFIX}violate-{label}\",\n [],\n _conjoin(_holds(closed), _absent(done)),\n ConjunctiveEffect([AddEffect(_fact(done)), _increase_cost(weight)]),\n )\n )\n goal_parts.append(_holds(done))\n\n problem.goal = _conjoin(*goal_parts)\n problem.preferences = []\n return domain, problem\n\n\ndef _increase_cost(amount: float):\n return IncreaseCostEffect(amount)\n\n\n# --------------------------------------------------------------------------\n# entry point\n# --------------------------------------------------------------------------\ndef compile_problem(domain: Domain, problem: Problem):\n \"\"\"Apply every PDDL 3 compilation, in the order they depend on each other.\n\n Object fluents go first because they rewrite terms everywhere else reads;\n preferences go last because their closing phase must sit outside the\n machinery the other compilations add.\n\n The inputs are copied first: every compilation edits the domain and problem\n it is given, and a parsed domain is routinely grounded against several\n problems. Compiling in place leaked one problem's synthetic actions into the\n next and consumed the domain's own constraints on the first.\n \"\"\"\n domain, problem = copy.deepcopy(domain), copy.deepcopy(problem)\n domain, problem = compile_object_fluents(domain, problem)\n domain, problem = compile_timed_initials(domain, problem)\n domain, problem = compile_constraints(domain, problem)\n domain, problem = compile_preferences(domain, problem)\n return domain, problem\n", "jupyddl/generator.py": "\"\"\"Generate PDDL domains and problems, reproducibly.\n\nBenchmarks live or die on their instance set, and hand-writing a scaling ladder\nis tedious. Each generator here takes a size and a ``seed`` and emits PDDL text,\nso a whole difficulty curve is one comprehension \u2014 and re-running the same seed\ngives byte-identical files, which is what makes a published experiment\nreproducible.\n\n::\n\n from jupyddl.generator import generate, GENERATORS\n\n domain, problem = generate(\"blocksworld\", size=10, seed=7)\n ladder = [generate(\"gripper\", size=n, seed=1) for n in range(2, 12)]\n\nEvery generator is exercised by the test suite, which grounds and solves what it\nproduces \u2014 a generator that emits unsolvable or unparseable PDDL is a bug.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport random\nfrom typing import Optional\n\n__all__ = [\n \"GENERATORS\",\n \"generate\",\n \"describe_generators\",\n \"generate_blocksworld\",\n \"generate_gripper\",\n \"generate_logistics\",\n \"generate_rovers\",\n \"generate_random_strips\",\n \"generate_numeric_transport\",\n \"generate_temporal_workshop\",\n]\n\n\ndef _objects(prefix: str, count: int) -> list:\n return [f\"{prefix}{i + 1}\" for i in range(count)]\n\n\n# --------------------------------------------------------------------------\n# blocksworld\n# --------------------------------------------------------------------------\nBLOCKSWORLD_DOMAIN = \"\"\"\\\n;; Blocksworld -- generated by jupyddl.generator\n(define (domain blocksworld)\n (:requirements :strips :typing)\n (:types block - object)\n (:predicates\n (on ?x - block ?y - block)\n (ontable ?x - block)\n (clear ?x - block)\n (handempty)\n (holding ?x - block))\n\n (:action pick-up\n :parameters (?x - block)\n :precondition (and (clear ?x) (ontable ?x) (handempty))\n :effect (and (not (ontable ?x)) (not (clear ?x))\n (not (handempty)) (holding ?x)))\n\n (:action put-down\n :parameters (?x - block)\n :precondition (holding ?x)\n :effect (and (not (holding ?x)) (clear ?x) (handempty) (ontable ?x)))\n\n (:action stack\n :parameters (?x - block ?y - block)\n :precondition (and (holding ?x) (clear ?y))\n :effect (and (not (holding ?x)) (not (clear ?y))\n (clear ?x) (handempty) (on ?x ?y)))\n\n (:action unstack\n :parameters (?x - block ?y - block)\n :precondition (and (on ?x ?y) (clear ?x) (handempty))\n :effect (and (holding ?x) (clear ?y) (not (clear ?x))\n (not (handempty)) (not (on ?x ?y)))))\n\"\"\"\n\n\ndef _random_towers(blocks, rng) -> list:\n \"\"\"Partition ``blocks`` into a random set of towers (bottom-first).\"\"\"\n shuffled = list(blocks)\n rng.shuffle(shuffled)\n towers: list = []\n index = 0\n while index < len(shuffled):\n height = rng.randint(1, max(1, len(shuffled) - index))\n towers.append(shuffled[index : index + height])\n index += height\n return towers\n\n\ndef _tower_facts(towers) -> list:\n facts = []\n for tower in towers:\n facts.append(f\"(ontable {tower[0]})\")\n for lower, upper in zip(tower, tower[1:]):\n facts.append(f\"(on {upper} {lower})\")\n facts.append(f\"(clear {tower[-1]})\")\n return facts\n\n\ndef _goal_facts(towers) -> list:\n facts = []\n for tower in towers:\n facts.append(f\"(ontable {tower[0]})\")\n for lower, upper in zip(tower, tower[1:]):\n facts.append(f\"(on {upper} {lower})\")\n return facts\n\n\ndef generate_blocksworld(size: int = 6, seed: int = 0):\n \"\"\"``size`` blocks in random towers, to be rearranged into other towers.\n\n The goal is re-drawn until some goal fact is false in the initial state,\n so no seed yields an instance that is solved before it starts. The test\n used to compare the two tower lists in order, which missed the same towers\n listed differently, and was skipped for two blocks \u2014 where 75 of the first\n 200 seeds came out already solved.\n \"\"\"\n rng = random.Random(seed)\n blocks = _objects(\"b\", max(2, size))\n start = _random_towers(blocks, rng)\n initial = set(_tower_facts(start))\n goal_towers = _random_towers(blocks, rng)\n while set(_goal_facts(goal_towers)) <= initial:\n goal_towers = _random_towers(blocks, rng)\n goal_facts = _goal_facts(goal_towers)\n\n problem = f\"\"\"\\\n;; {len(blocks)} blocks, seed {seed} -- generated by jupyddl.generator\n(define (problem blocksworld-{len(blocks)}-{seed})\n (:domain blocksworld)\n (:objects {' '.join(blocks)} - block)\n (:init\n (handempty)\n {chr(10).join(' ' + f for f in _tower_facts(start)).strip()})\n (:goal (and\n {chr(10).join(' ' + f for f in goal_facts).strip()})))\n\"\"\"\n return BLOCKSWORLD_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# gripper\n# --------------------------------------------------------------------------\nGRIPPER_DOMAIN = \"\"\"\\\n;; Gripper -- generated by jupyddl.generator\n(define (domain gripper)\n (:requirements :strips :typing)\n (:types room ball gripper - object)\n (:predicates\n (at-robby ?r - room)\n (at ?b - ball ?r - room)\n (free ?g - gripper)\n (carry ?b - ball ?g - gripper))\n\n (:action move\n :parameters (?from - room ?to - room)\n :precondition (at-robby ?from)\n :effect (and (at-robby ?to) (not (at-robby ?from))))\n\n (:action pick\n :parameters (?b - ball ?r - room ?g - gripper)\n :precondition (and (at ?b ?r) (at-robby ?r) (free ?g))\n :effect (and (carry ?b ?g) (not (at ?b ?r)) (not (free ?g))))\n\n (:action drop\n :parameters (?b - ball ?r - room ?g - gripper)\n :precondition (and (carry ?b ?g) (at-robby ?r))\n :effect (and (at ?b ?r) (free ?g) (not (carry ?b ?g)))))\n\"\"\"\n\n\ndef generate_gripper(size: int = 4, seed: int = 0, grippers: int = 2):\n \"\"\"``size`` balls, each to be carried to the room it does not start in.\n\n The seed decides which room each ball starts in and which room the robot\n starts in. Every ball has to change rooms, so plan length still grows\n linearly with ``size`` and no instance is solved before it starts.\n\n This is a generalisation of the IPC instance, in which every ball starts\n in room A. That family has exactly one instance per size, and it is what\n this generator used to emit for every seed \u2014 the seed reached only the\n problem name \u2014 so a train/test split that varied the seed at a fixed\n size held out a copy of a training instance.\n \"\"\"\n rng = random.Random(seed)\n balls = _objects(\"ball\", max(1, size))\n hands = _objects(\"gripper\", max(1, grippers))\n rooms = (\"rooma\", \"roomb\")\n start = {ball: rng.choice(rooms) for ball in balls}\n init = [f\"(at-robby {rng.choice(rooms)})\"]\n init += [f\"(free {g})\" for g in hands]\n init += [f\"(at {b} {start[b]})\" for b in balls]\n goals = [f\"(at {b} {rooms[1 - rooms.index(start[b])]})\" for b in balls]\n\n problem = f\"\"\"\\\n;; {len(balls)} balls, {len(hands)} grippers, seed {seed}\n(define (problem gripper-{len(balls)}-{seed})\n (:domain gripper)\n (:objects\n rooma roomb - room\n {' '.join(balls)} - ball\n {' '.join(hands)} - gripper)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()})))\n\"\"\"\n return GRIPPER_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# logistics\n# --------------------------------------------------------------------------\nLOGISTICS_DOMAIN = \"\"\"\\\n;; Logistics with action costs -- generated by jupyddl.generator\n(define (domain logistics)\n (:requirements :strips :typing :action-costs)\n (:types\n truck airplane - vehicle\n package vehicle - thing\n airport location - place\n city - object)\n (:predicates\n (at ?t - thing ?p - place)\n (in ?p - package ?v - vehicle)\n (in-city ?p - place ?c - city))\n (:functions (total-cost))\n\n (:action load\n :parameters (?p - package ?v - vehicle ?l - place)\n :precondition (and (at ?p ?l) (at ?v ?l))\n :effect (and (not (at ?p ?l)) (in ?p ?v) (increase (total-cost) 1)))\n\n (:action unload\n :parameters (?p - package ?v - vehicle ?l - place)\n :precondition (and (in ?p ?v) (at ?v ?l))\n :effect (and (not (in ?p ?v)) (at ?p ?l) (increase (total-cost) 1)))\n\n (:action drive\n :parameters (?t - truck ?from - place ?to - place ?c - city)\n :precondition (and (at ?t ?from) (in-city ?from ?c) (in-city ?to ?c))\n :effect (and (not (at ?t ?from)) (at ?t ?to) (increase (total-cost) 2)))\n\n (:action fly\n :parameters (?a - airplane ?from - airport ?to - airport)\n :precondition (at ?a ?from)\n :effect (and (not (at ?a ?from)) (at ?a ?to) (increase (total-cost) 6))))\n\"\"\"\n\n\ndef generate_logistics(size: int = 3, seed: int = 0, cities: int = 2):\n \"\"\"``size`` packages across ``cities`` cities, each with an airport and depot.\"\"\"\n rng = random.Random(seed)\n cities = max(2, cities)\n packages = _objects(\"pkg\", max(1, size))\n city_names = _objects(\"city\", cities)\n airports = [f\"apt{i + 1}\" for i in range(cities)]\n depots = [f\"depot{i + 1}\" for i in range(cities)]\n trucks = [f\"truck{i + 1}\" for i in range(cities)]\n\n init = [\"(= (total-cost) 0)\"]\n for i in range(cities):\n init.append(f\"(in-city {airports[i]} {city_names[i]})\")\n init.append(f\"(in-city {depots[i]} {city_names[i]})\")\n init.append(f\"(at {trucks[i]} {depots[i]})\")\n init.append(f\"(at plane1 {airports[0]})\")\n\n goals = []\n for package in packages:\n source = rng.randrange(cities)\n target = rng.randrange(cities)\n while target == source and cities > 1:\n target = rng.randrange(cities)\n init.append(f\"(at {package} {rng.choice([airports[source], depots[source]])})\")\n goals.append(f\"(at {package} {rng.choice([airports[target], depots[target]])})\")\n\n problem = f\"\"\"\\\n;; {len(packages)} packages, {cities} cities, seed {seed}\n(define (problem logistics-{len(packages)}-{seed})\n (:domain logistics)\n (:objects\n {' '.join(city_names)} - city\n {' '.join(trucks)} - truck\n plane1 - airplane\n {' '.join(packages)} - package\n {' '.join(airports)} - airport\n {' '.join(depots)} - location)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()}))\n (:metric minimize (total-cost)))\n\"\"\"\n return LOGISTICS_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# rovers (ADL: disjunction + quantification)\n# --------------------------------------------------------------------------\nROVERS_DOMAIN = \"\"\"\\\n;; Rovers -- exercises disjunctive and quantified preconditions.\n(define (domain rovers)\n (:requirements :strips :typing :adl)\n (:types rover waypoint objective - object)\n (:predicates\n (at ?r - rover ?w - waypoint)\n (can-traverse ?a ?b - waypoint)\n (visible ?o - objective ?w - waypoint)\n (imaged ?o - objective)\n (sampled ?w - waypoint)\n (analysed ?w - waypoint)\n (reported ?o - objective))\n\n (:action navigate\n :parameters (?r - rover ?from ?to - waypoint)\n :precondition (and (at ?r ?from) (can-traverse ?from ?to))\n :effect (and (not (at ?r ?from)) (at ?r ?to)))\n\n (:action sample\n :parameters (?r - rover ?w - waypoint)\n :precondition (and (at ?r ?w) (not (sampled ?w)))\n :effect (sampled ?w))\n\n (:action analyse\n :parameters (?w - waypoint)\n :precondition (sampled ?w)\n :effect (analysed ?w))\n\n ;; An objective can be imaged from any waypoint that sees it.\n (:action image\n :parameters (?r - rover ?o - objective)\n :precondition (exists (?w - waypoint) (and (at ?r ?w) (visible ?o ?w)))\n :effect (imaged ?o))\n\n ;; Reporting accepts either a picture or a full sample analysis.\n (:action report\n :parameters (?o - objective)\n :precondition (or (imaged ?o)\n (exists (?w - waypoint)\n (and (visible ?o ?w) (analysed ?w))))\n :effect (reported ?o)))\n\"\"\"\n\n\ndef generate_rovers(size: int = 3, seed: int = 0, waypoints: int = 5):\n \"\"\"``size`` objectives over a connected waypoint graph. Uses `or` and `exists`.\"\"\"\n rng = random.Random(seed)\n waypoints = max(2, waypoints)\n points = _objects(\"wp\", waypoints)\n objectives = _objects(\"obj\", max(1, size))\n\n init = [\"(at rover1 wp1)\"]\n # A spanning path guarantees the graph is connected, so every instance is\n # solvable; the extra edges just give the planner choices.\n for a, b in zip(points, points[1:]):\n init.append(f\"(can-traverse {a} {b})\")\n init.append(f\"(can-traverse {b} {a})\")\n for _ in range(waypoints):\n a, b = rng.sample(points, 2)\n init.append(f\"(can-traverse {a} {b})\")\n\n for objective in objectives:\n for point in rng.sample(points, rng.randint(1, min(2, len(points)))):\n init.append(f\"(visible {objective} {point})\")\n\n problem = f\"\"\"\\\n;; {len(objectives)} objectives, {waypoints} waypoints, seed {seed}\n(define (problem rovers-{len(objectives)}-{seed})\n (:domain rovers)\n (:objects\n rover1 - rover\n {' '.join(points)} - waypoint\n {' '.join(objectives)} - objective)\n (:init\n {chr(10).join(' ' + f for f in sorted(set(init))).strip()})\n (:goal (and\n {chr(10).join(f' (reported {o})' for o in objectives).strip()})))\n\"\"\"\n return ROVERS_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# numeric transport\n# --------------------------------------------------------------------------\nNUMERIC_DOMAIN = \"\"\"\\\n;; Transport with fuel -- exercises numeric fluents.\n(define (domain numeric-transport)\n (:requirements :strips :typing :numeric-fluents)\n (:types truck location package - object)\n (:predicates\n (at ?t - truck ?l - location)\n (road ?a ?b - location)\n (carrying ?p - package ?t - truck)\n (package-at ?p - package ?l - location))\n (:functions\n (fuel ?t - truck)\n (distance ?a ?b - location))\n\n (:action drive\n :parameters (?t - truck ?from ?to - location)\n :precondition (and (at ?t ?from) (road ?from ?to)\n (>= (fuel ?t) (distance ?from ?to)))\n :effect (and (not (at ?t ?from)) (at ?t ?to)\n (decrease (fuel ?t) (distance ?from ?to))))\n\n (:action refuel\n :parameters (?t - truck)\n :precondition (< (fuel ?t) 40)\n :effect (assign (fuel ?t) 60))\n\n (:action load\n :parameters (?p - package ?t - truck ?l - location)\n :precondition (and (at ?t ?l) (package-at ?p ?l))\n :effect (and (not (package-at ?p ?l)) (carrying ?p ?t)))\n\n (:action unload\n :parameters (?p - package ?t - truck ?l - location)\n :precondition (and (at ?t ?l) (carrying ?p ?t))\n :effect (and (not (carrying ?p ?t)) (package-at ?p ?l))))\n\"\"\"\n\n\ndef generate_numeric_transport(size: int = 2, seed: int = 0, locations: int = 4):\n \"\"\"``size`` packages on a fuel-limited road network.\"\"\"\n rng = random.Random(seed)\n locations = max(2, locations)\n places = _objects(\"loc\", locations)\n packages = _objects(\"pkg\", max(1, size))\n\n init = [\"(at truck1 loc1)\", \"(= (fuel truck1) 50)\"]\n for a, b in zip(places, places[1:]):\n distance = rng.choice([10, 15, 20])\n init.append(f\"(road {a} {b})\")\n init.append(f\"(road {b} {a})\")\n init.append(f\"(= (distance {a} {b}) {distance})\")\n init.append(f\"(= (distance {b} {a}) {distance})\")\n\n goals = []\n for package in packages:\n init.append(f\"(package-at {package} {places[0]})\")\n goals.append(f\"(package-at {package} {places[-1]})\")\n\n problem = f\"\"\"\\\n;; {len(packages)} packages, {locations} locations, seed {seed}\n(define (problem numeric-transport-{len(packages)}-{seed})\n (:domain numeric-transport)\n (:objects\n truck1 - truck\n {' '.join(places)} - location\n {' '.join(packages)} - package)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()})))\n\"\"\"\n return NUMERIC_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# temporal workshop\n# --------------------------------------------------------------------------\nTEMPORAL_DOMAIN = \"\"\"\\\n;; Workshop -- exercises durative actions (sequential compilation).\n(define (domain workshop)\n (:requirements :strips :typing :durative-actions)\n (:types part - object)\n (:predicates\n (cut ?p - part)\n (drilled ?p - part)\n (painted ?p - part)\n (finished ?p - part))\n\n (:durative-action cut\n :parameters (?p - part)\n :duration (= ?duration 2)\n :condition (and (at start (not (cut ?p))))\n :effect (and (at end (cut ?p))))\n\n (:durative-action drill\n :parameters (?p - part)\n :duration (= ?duration 3)\n :condition (and (over all (cut ?p)))\n :effect (and (at end (drilled ?p))))\n\n (:durative-action paint\n :parameters (?p - part)\n :duration (= ?duration 5)\n :condition (and (over all (drilled ?p)))\n :effect (and (at end (painted ?p))))\n\n (:durative-action inspect\n :parameters (?p - part)\n :duration (= ?duration 1)\n :condition (and (over all (painted ?p)))\n :effect (and (at end (finished ?p)))))\n\"\"\"\n\n\ndef generate_temporal_workshop(size: int = 2, seed: int = 0):\n \"\"\"``size`` parts through a cut/drill/paint/inspect pipeline, with durations.\n\n The seed decides how far along the pipeline each part already is \u2014 raw,\n cut, drilled or painted. None starts finished, so every instance has work\n left in it. (The seed used to reach only the problem name: every part\n started raw, and twenty seeds gave one instance.)\n \"\"\"\n rng = random.Random(seed)\n parts = _objects(\"part\", max(1, size))\n stages = (\"cut\", \"drilled\", \"painted\")\n init = []\n for part in parts:\n done = rng.randint(0, len(stages))\n init += [f\"({stage} {part})\" for stage in stages[:done]]\n problem = f\"\"\"\\\n;; {len(parts)} parts, seed {seed}\n(define (problem workshop-{len(parts)}-{seed})\n (:domain workshop)\n (:objects {' '.join(parts)} - part)\n (:init {' '.join(init)})\n (:goal (and\n {chr(10).join(f' (finished {p})' for p in parts).strip()})))\n\"\"\"\n return TEMPORAL_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# random STRIPS\n# --------------------------------------------------------------------------\ndef generate_random_strips(\n size: int = 8,\n seed: int = 0,\n actions: int = 10,\n goal_size: int = 3,\n):\n \"\"\"A random STRIPS instance built backwards from a guaranteed solution.\n\n Purely random operators almost always give an unsolvable problem, which\n makes for a useless benchmark. Instead this plants a random *chain*: each\n step in the chain has an action that turns the previous state into the next,\n so a plan of known length exists. The remaining actions are noise the\n planner has to search past. At least one goal fact is false initially.\n \"\"\"\n rng = random.Random(seed)\n size = max(4, size)\n facts = [f\"p{i + 1}\" for i in range(size)]\n chain_length = max(2, min(actions // 2, size))\n\n init_facts = set(rng.sample(facts, max(1, size // 3)))\n state = set(init_facts)\n operators = []\n\n # --- the planted solution ---------------------------------------------\n for step in range(chain_length):\n candidates = [f for f in facts if f not in state]\n if not candidates:\n break\n added = rng.choice(candidates)\n precondition = sorted(rng.sample(sorted(state), min(2, len(state))))\n deleted = []\n if len(state) > 1 and rng.random() < 0.4:\n deleted = [\n (\n rng.choice([f for f in sorted(state) if f not in precondition])\n if len(state) > len(precondition)\n else None\n )\n ]\n deleted = [d for d in deleted if d]\n operators.append((f\"solve{step + 1}\", precondition, [added], deleted))\n state.add(added)\n state.difference_update(deleted)\n\n goal_facts = sorted(rng.sample(sorted(state), min(goal_size, len(state))))\n if not goal_facts or set(goal_facts) <= init_facts:\n # Every fact drawn already holds initially, which makes the instance\n # solved before it starts. Swap one for a fact the chain achieved. Only\n # this branch draws from ``rng``, so every instance that never needed\n # it is byte-identical to what this generator always produced.\n fresh = sorted(state - init_facts)\n if not fresh:\n # The chain undid itself. One more planted step, adding a fact the\n # initial state lacks, keeps the goal reachable and non-trivial.\n added = rng.choice(sorted(set(facts) - init_facts))\n precondition = sorted(rng.sample(sorted(state), min(2, len(state))))\n operators.append((f\"solve{len(operators) + 1}\", precondition, [added], []))\n state.add(added)\n fresh = [added]\n replacement = rng.choice(fresh)\n if goal_facts:\n goal_facts[rng.randrange(len(goal_facts))] = replacement\n else:\n goal_facts = [replacement]\n goal_facts = sorted(goal_facts)\n\n # --- distractors -------------------------------------------------------\n for index in range(max(0, actions - len(operators))):\n precondition = sorted(rng.sample(facts, rng.randint(1, 2)))\n added = sorted(rng.sample(facts, rng.randint(1, 2)))\n deleted = [f for f in rng.sample(facts, 1) if f not in added]\n operators.append((f\"noise{index + 1}\", precondition, added, deleted))\n\n def action_text(name, pre, add, delete):\n conditions = \" \".join(f\"({p})\" for p in pre) or \"\"\n effects = \" \".join(f\"({a})\" for a in add)\n effects += \" \" + \" \".join(f\"(not ({d}))\" for d in delete)\n return (\n f\" (:action {name}\\n\"\n f\" :precondition (and {conditions})\\n\"\n f\" :effect (and {effects.strip()}))\"\n )\n\n domain = \"\\n\".join(\n [\n \";; Random STRIPS -- generated by jupyddl.generator\",\n f\"(define (domain random-strips-{size}-{seed})\",\n \" (:requirements :strips)\",\n \" (:predicates \" + \" \".join(f\"({f})\" for f in facts) + \")\",\n \"\",\n \"\\n\\n\".join(action_text(*op) for op in operators),\n \")\",\n ]\n )\n problem = f\"\"\"\\\n;; {size} facts, {len(operators)} actions, seed {seed}\n(define (problem random-strips-{size}-{seed})\n (:domain random-strips-{size}-{seed})\n (:init {' '.join(f'({f})' for f in sorted(init_facts))})\n (:goal (and {' '.join(f'({f})' for f in goal_facts)})))\n\"\"\"\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# registry\n# --------------------------------------------------------------------------\nGENERATORS = {\n \"blocksworld\": generate_blocksworld,\n \"gripper\": generate_gripper,\n \"logistics\": generate_logistics,\n \"rovers\": generate_rovers,\n \"numeric-transport\": generate_numeric_transport,\n \"workshop\": generate_temporal_workshop,\n \"random-strips\": generate_random_strips,\n}\n\n# What each generator is meant to stress, for the CLI and the playground.\nGENERATOR_NOTES = {\n \"blocksworld\": \"Classic block stacking. Deep plans, heavy plateaus.\",\n \"gripper\": \"Balls between two rooms. High branching factor.\",\n \"logistics\": \"Trucks and a plane with action costs.\",\n \"rovers\": \"ADL: disjunctive and existential preconditions.\",\n \"numeric-transport\": \"Numeric fluents: fuel consumption and refuelling.\",\n \"workshop\": \"Durative actions; plans report a makespan.\",\n \"random-strips\": \"Random operators around a planted solution chain.\",\n}\n\n\ndef generate(kind: str, size: int = 4, seed: int = 0, **kwargs):\n \"\"\"Generate ``(domain_text, problem_text)`` for a named generator.\n\n An unregistered ``kind`` triggers plugin discovery once (see\n :mod:`jupyddl.plugins`) before it is reported as unknown.\n \"\"\"\n factory = GENERATORS.get(kind)\n if factory is None:\n from .plugins import load_plugins\n\n load_plugins()\n factory = GENERATORS.get(kind)\n if factory is None:\n raise ValueError(f\"Unknown generator '{kind}'. Available: {sorted(GENERATORS)}\")\n return factory(size=size, seed=seed, **kwargs)\n\n\ndef describe_generators() -> list:\n \"\"\"Serialisable metadata for the CLI and the web workbench.\"\"\"\n return [\n {\n \"name\": name,\n \"summary\": GENERATOR_NOTES.get(name)\n or (GENERATORS[name].__doc__ or \"\").strip().split(\"\\n\")[0],\n }\n for name in sorted(GENERATORS)\n ]\n\n\ndef write_instance(\n kind: str,\n folder: str,\n size: int = 4,\n seed: int = 0,\n name: Optional[str] = None,\n **kwargs,\n) -> str:\n \"\"\"Generate an instance and write ``//{domain,problem}.pddl``.\"\"\"\n import os\n\n domain, problem = generate(kind, size=size, seed=seed, **kwargs)\n target = os.path.join(folder, name or f\"{kind}-{size:02d}-{seed}\")\n os.makedirs(target, exist_ok=True)\n with open(os.path.join(target, \"domain.pddl\"), \"w\", encoding=\"utf-8\") as handle:\n handle.write(domain)\n with open(os.path.join(target, \"problem.pddl\"), \"w\", encoding=\"utf-8\") as handle:\n handle.write(problem)\n return target\n", "jupyddl/grounding.py": "\"\"\"Grounding: turn a parsed :class:`Domain` + :class:`Problem` into a\ngrounded :class:`~jupyddl.task.Task`.\n\nPipeline:\n\n1. Build the ``type -> objects`` table (with type-hierarchy closure).\n2. Instantiate every action over all type-consistent parameter tuples. The\n precondition formula is expanded (quantifiers over the object pool) and\n distributed into DNF; **each disjunct becomes its own grounded operator**, so\n the search only ever sees conjunctive preconditions.\n3. Detect static predicates (never added, deleted, or derived) and use the\n initial state to prune infeasible action instances and simplify conditions.\n4. Compile negative preconditions/goals into *positive normal form* by\n introducing complement facts ``(not ...)`` and maintaining them on every\n operator that touches the underlying atom.\n5. Ground derived-predicate rules into :class:`~jupyddl.task.Axiom` objects.\n6. Collect numeric fluents, compile their expressions into closures over the\n state's value vector, and encode everything as integer ids.\n\nA disjunctive goal is compiled to a single artificial goal fact achieved by one\nzero-cost operator per disjunct; those operators are recorded in\n``Task.synthetic`` so they can be hidden when a plan is printed.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import defaultdict\nfrom dataclasses import dataclass, field\nfrom itertools import product\n\nfrom .parser.ast import (\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n ConjunctiveEffect,\n DelEffect,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Problem,\n TimedEffect,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\nfrom .compile import CLOCK as CLOCK_FLUENT\nfrom .compile import SYNTHETIC_PREFIX, compile_problem\nfrom .parser.parser import parse_domain_file, parse_problem_file\nfrom .task import Axiom, CondEffect, Operator, Task\n\n# Distributing a deeply disjunctive precondition into DNF can blow up\n# combinatorially. Stop with a clear message rather than exhausting memory.\nMAX_DISJUNCTS = 20000\n\nGOAL_FACT = Atom(\"__goal-reached__\", ())\nTOTAL_COST = \"total-cost\"\n\n\ndef _ground_atom(atom: Atom, subst: dict) -> Atom:\n return Atom(atom.predicate, tuple(subst.get(a, a) for a in atom.args))\n\n\ndef _ground_fluent(ref: FluentRef, subst: dict) -> FluentRef:\n return FluentRef(ref.name, tuple(subst.get(a, a) for a in ref.args))\n\n\ndef _ground_expression(expr, subst: dict):\n if isinstance(expr, Number):\n return expr\n if isinstance(expr, FluentRef):\n return _ground_fluent(expr, subst)\n if isinstance(expr, Arithmetic):\n return Arithmetic(\n expr.op,\n _ground_expression(expr.left, subst),\n _ground_expression(expr.right, subst),\n )\n raise PDDLError(f\"unexpected numeric expression node: {expr!r}\")\n\n\ndef _build_type_objects(domain: Domain, problem: Problem):\n \"\"\"Map every type to the set of objects that inhabit it (hierarchy-aware).\"\"\"\n parent = dict(domain.types)\n\n def ancestors(typ: str):\n chain = [typ]\n seen = {typ}\n cur = typ\n while cur in parent and parent[cur] not in (None, \"object\", cur):\n cur = parent[cur]\n if cur in seen:\n break\n seen.add(cur)\n chain.append(cur)\n return chain\n\n type_objects: dict = defaultdict(set)\n declared: set = set()\n for name, typ in list(domain.constants) + list(problem.objects):\n declared.add(name)\n for anc in ancestors(typ):\n type_objects[anc].add(name)\n type_objects[\"object\"].add(name)\n\n # Robustness: some (toy) problems omit the :objects section and only mention\n # constants in :init/:goal. Treat any such undeclared constant as an object\n # of the root type so untyped domains still ground.\n for name in _harvest_constants(problem):\n if name not in declared:\n type_objects[\"object\"].add(name)\n\n for typ in set(list(parent) + list(parent.values())):\n type_objects.setdefault(typ, set())\n return {typ: tuple(sorted(objs)) for typ, objs in type_objects.items()}\n\n\ndef _harvest_constants(problem: Problem) -> set:\n found: set = set()\n for atom in problem.init:\n found.update(arg for arg in atom.args if not arg.startswith(\"?\"))\n for ref in problem.init_numeric:\n found.update(arg for arg in ref.args if not arg.startswith(\"?\"))\n\n def walk(formula):\n if isinstance(formula, Literal):\n found.update(a for a in formula.atom.args if not a.startswith(\"?\"))\n elif isinstance(formula, (And, Or)):\n for part in formula.parts:\n walk(part)\n elif isinstance(formula, (Exists, Forall)):\n walk(formula.body)\n\n walk(problem.goal)\n return found\n\n\n# --------------------------------------------------------------------------\n# condition formulas -> DNF\n# --------------------------------------------------------------------------\n@dataclass\nclass _Disjunct:\n \"\"\"One ground conjunction: positive atoms, negative atoms, comparisons.\"\"\"\n\n pos: set = field(default_factory=set)\n neg: set = field(default_factory=set)\n comparisons: list = field(default_factory=list)\n\n def merged(self, other: \"_Disjunct\") -> \"_Disjunct\":\n return _Disjunct(\n self.pos | other.pos,\n self.neg | other.neg,\n self.comparisons + other.comparisons,\n )\n\n @property\n def contradictory(self) -> bool:\n return bool(self.pos & self.neg)\n\n\nTRUE_DNF = [_Disjunct()]\nFALSE_DNF: list = []\n\n\ndef _dnf(formula, subst: dict, type_objects: dict) -> list:\n \"\"\"Expand quantifiers and distribute ``formula`` into a list of disjuncts.\n\n An empty list means *unsatisfiable*; a list holding one empty disjunct means\n *trivially true*.\n \"\"\"\n if formula is None:\n return TRUE_DNF\n\n if isinstance(formula, Truth):\n return TRUE_DNF if formula.value else FALSE_DNF\n\n if isinstance(formula, Literal):\n atom = _ground_atom(formula.atom, subst)\n if formula.positive:\n return [_Disjunct(pos={atom})]\n return [_Disjunct(neg={atom})]\n\n if isinstance(formula, EqualityConstraint):\n left = subst.get(formula.left, formula.left)\n right = subst.get(formula.right, formula.right)\n holds = (left == right) == formula.positive\n return TRUE_DNF if holds else FALSE_DNF\n\n if isinstance(formula, Comparison):\n grounded = Comparison(\n formula.op,\n _ground_expression(formula.left, subst),\n _ground_expression(formula.right, subst),\n )\n return [_Disjunct(comparisons=[grounded])]\n\n if isinstance(formula, And):\n result = TRUE_DNF\n for part in formula.parts:\n result = _cross(result, _dnf(part, subst, type_objects))\n if not result:\n return FALSE_DNF\n return result\n\n if isinstance(formula, Or):\n out: list = []\n for part in formula.parts:\n out.extend(_dnf(part, subst, type_objects))\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n \"disjunctive condition expands past \"\n f\"{MAX_DISJUNCTS} cases; simplify the domain or split the action\"\n )\n return out\n\n if isinstance(formula, Forall):\n result = TRUE_DNF\n for sub in _quantifier_substitutions(formula.params, subst, type_objects):\n result = _cross(result, _dnf(formula.body, sub, type_objects))\n if not result:\n return FALSE_DNF\n return result\n\n if isinstance(formula, Exists):\n out = []\n for sub in _quantifier_substitutions(formula.params, subst, type_objects):\n out.extend(_dnf(formula.body, sub, type_objects))\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n \"existential condition expands past \"\n f\"{MAX_DISJUNCTS} cases; the object pool is too large\"\n )\n return out\n\n raise PDDLError(f\"unexpected condition node: {formula!r}\")\n\n\ndef _quantifier_substitutions(params, subst: dict, type_objects: dict):\n pools = [type_objects.get(typ, ()) for (_, typ) in params]\n for combo in product(*pools):\n extended = dict(subst)\n for (var, _), obj in zip(params, combo):\n extended[var] = obj\n yield extended\n\n\ndef _cross(left: list, right: list) -> list:\n \"\"\"Distribute a conjunction of two DNFs, dropping contradictory disjuncts.\"\"\"\n if not left or not right:\n return FALSE_DNF\n out = []\n for a in left:\n for b in right:\n merged = a.merged(b)\n if not merged.contradictory:\n out.append(merged)\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n f\"conjunction of disjunctions expands past {MAX_DISJUNCTS} cases\"\n )\n return out\n\n\n# --------------------------------------------------------------------------\n# effects\n# --------------------------------------------------------------------------\n@dataclass\nclass _RawOp:\n name: str\n pre_pos: set\n pre_neg: set\n comparisons: list\n add: set\n delete: set\n cond: list # (cpos, cneg, cadd, cdel)\n numeric: list # (op, FluentRef, expression)\n cost: float\n duration: float\n synthetic: bool = False\n\n\ndef _new_acc() -> dict:\n \"\"\"An empty accumulator for one ground action's effects.\"\"\"\n return {\n \"add\": set(),\n \"delete\": set(),\n \"cond\": [],\n \"numeric\": [],\n \"cost\": 0.0,\n \"has_cost\": False,\n # (increase (total-cost) ): evaluated once the instance is\n # known, from the static functions in :init.\n \"cost_exprs\": [],\n # A durative action's at-start phase, kept for regressing its\n # over-all and at-end conditions.\n \"start\": None,\n }\n\n\ndef _collect_effect(eff, subst, type_objects, cpos, cneg, acc):\n if isinstance(eff, ConjunctiveEffect):\n for part in eff.parts:\n _collect_effect(part, subst, type_objects, cpos, cneg, acc)\n elif isinstance(eff, AddEffect):\n atom = _ground_atom(eff.atom, subst)\n if cpos or cneg:\n acc[\"cond\"].append((frozenset(cpos), frozenset(cneg), {atom}, set()))\n else:\n acc[\"add\"].add(atom)\n elif isinstance(eff, DelEffect):\n atom = _ground_atom(eff.atom, subst)\n if cpos or cneg:\n acc[\"cond\"].append((frozenset(cpos), frozenset(cneg), set(), {atom}))\n else:\n acc[\"delete\"].add(atom)\n elif isinstance(eff, IncreaseCostEffect):\n if cpos or cneg:\n # An operator has one cost. Charging this unconditionally would\n # bill for an effect that did not fire.\n raise UnsupportedFeatureError(\n \"(increase (total-cost) ...) inside a 'when' is not supported: \"\n \"an action's cost may not depend on the state\"\n )\n if isinstance(eff.amount, (int, float)):\n acc[\"cost\"] += eff.amount\n else:\n acc[\"cost_exprs\"].append(_ground_expression(eff.amount, subst))\n acc[\"has_cost\"] = True\n elif isinstance(eff, NumericEffect):\n if cpos or cneg:\n raise UnsupportedFeatureError(\n \"numeric effects inside a 'when' are not supported\"\n )\n acc[\"numeric\"].append(\n (\n eff.op,\n _ground_fluent(eff.target, subst),\n _ground_expression(eff.value, subst),\n )\n )\n elif isinstance(eff, ForallEffect):\n for sub in _quantifier_substitutions(eff.params, subst, type_objects):\n _collect_effect(eff.body, sub, type_objects, cpos, cneg, acc)\n elif isinstance(eff, WhenEffect):\n # A disjunctive effect condition splits into one conditional effect per\n # disjunct, which is exactly equivalent.\n for disjunct in _dnf(eff.condition, subst, type_objects):\n if disjunct.comparisons:\n raise UnsupportedFeatureError(\n \"numeric comparisons inside a 'when' condition are not supported\"\n )\n _collect_effect(\n eff.body,\n subst,\n type_objects,\n cpos | disjunct.pos,\n cneg | disjunct.neg,\n acc,\n )\n elif isinstance(eff, TimedEffect):\n if cpos or cneg: # pragma: no cover - the parser never nests one\n raise PDDLError(\"a durative action's timed effects cannot be conditional\")\n start, end = _new_acc(), _new_acc()\n _collect_effect(eff.start, subst, type_objects, set(), set(), start)\n _collect_effect(eff.end, subst, type_objects, set(), set(), end)\n _sequence(start, end, acc)\n else:\n raise PDDLError(f\"unexpected effect node: {eff!r}\")\n\n\ndef _maybe_changed(phase: dict) -> set:\n \"\"\"Facts a phase's *conditional* effects may add or delete.\"\"\"\n out: set = set()\n for _, _, cadd, cdel in phase[\"cond\"]:\n out |= cadd | cdel\n return out\n\n\ndef _regress(pos, neg, start: dict, what: str):\n \"\"\"Rewrite a condition on the state *after* ``start`` as one on the state\n before it, or return None when ``start`` makes it false.\n\n A fact the start phase adds is true afterwards (adds win), one it deletes\n is false, and anything else is read from the earlier state unchanged. A\n fact a start-phase ``when`` may change depends on whether that fired, which\n one conjunction cannot say: refused.\n \"\"\"\n maybe = _maybe_changed(start)\n keep_pos, keep_neg = set(), set()\n for atom in pos:\n if atom in start[\"add\"]:\n continue\n _refuse_if_maybe(atom, maybe, what)\n if atom in start[\"delete\"]:\n return None\n keep_pos.add(atom)\n for atom in neg:\n if atom in start[\"add\"]:\n return None\n _refuse_if_maybe(atom, maybe, what)\n if atom in start[\"delete\"]:\n continue\n keep_neg.add(atom)\n return keep_pos, keep_neg\n\n\ndef _refuse_if_maybe(atom, maybe: set, what: str) -> None:\n if atom in maybe:\n raise UnsupportedFeatureError(\n f\"{what} reads {atom}, which an at-start conditional effect may \"\n \"change; that is not supported\"\n )\n\n\ndef _refuse_numeric_overlap(written: set, read: set, what: str) -> None:\n overlap = sorted(str(ref) for ref in written & read)\n if overlap:\n raise UnsupportedFeatureError(\n f\"{what} reads {', '.join(overlap)}, which an at-start effect of the \"\n \"same action changes; that is not supported\"\n )\n\n\ndef _sequence(start: dict, end: dict, acc: dict) -> None:\n \"\"\"Fold a durative action's at-start phase, then its at-end phase, into one.\n\n The at-end effects happen second, so they win a conflict: an at-start add\n of something the end deletes is dropped, and an at-start delete of\n something the end adds loses to the add anyway. At-end ``when`` conditions\n read the state after the at-start effects, so they are regressed through\n them. Two cases one operator cannot express are refused: an at-end\n conditional delete of something the start adds (adds win inside one\n operator, so the delete could never take effect), and numeric at-end\n effects that read or write what the at-start ones wrote.\n \"\"\"\n end_add, end_delete = set(end[\"add\"]), set(end[\"delete\"])\n end_cond = []\n for cpos, cneg, cadd, cdel in end[\"cond\"]:\n regressed = _regress(cpos, cneg, start, \"an at-end conditional effect\")\n if regressed is None:\n continue # the start phase made its condition false\n if not regressed[0] and not regressed[1]:\n end_add |= cadd # ...or true, so it always fires\n end_delete |= cdel\n continue\n end_cond.append((frozenset(regressed[0]), frozenset(regressed[1]), cadd, cdel))\n start_add = start[\"add\"] - end_delete\n start_cond = [\n (cpos, cneg, cadd - end_delete, cdel)\n for cpos, cneg, cadd, cdel in start[\"cond\"]\n ]\n may_add = set(start_add)\n for _, _, cadd, _ in start_cond:\n may_add |= cadd\n for _, _, _, cdel in end_cond:\n clash = sorted(str(atom) for atom in cdel & may_add)\n if clash:\n raise UnsupportedFeatureError(\n f\"an at-end conditional effect deletes {', '.join(clash)}, which \"\n \"the at-start effects add; that is not supported\"\n )\n\n written = {target for _, target, _ in start[\"numeric\"]}\n for _, target, value in end[\"numeric\"]:\n read: set = {target}\n _collect_fluents(value, read)\n _refuse_numeric_overlap(written, read, \"a numeric at-end effect\")\n\n acc[\"add\"] |= start_add | end_add\n acc[\"delete\"] |= start[\"delete\"] | end_delete\n acc[\"cond\"].extend(start_cond + end_cond)\n acc[\"numeric\"].extend(start[\"numeric\"] + end[\"numeric\"])\n acc[\"cost\"] += start[\"cost\"] + end[\"cost\"]\n acc[\"has_cost\"] = acc[\"has_cost\"] or start[\"has_cost\"] or end[\"has_cost\"]\n acc[\"cost_exprs\"].extend(start[\"cost_exprs\"] + end[\"cost_exprs\"])\n acc[\"start\"] = start\n\n\ndef _late_disjuncts(action, subst, type_objects, acc) -> list:\n \"\"\"A durative action's over-all and at-end conditions, as pre-state DNF.\n\n They hold in the state after the at-start effects; regressing them through\n those effects turns them into conditions on the state the action starts in,\n which is all a single sequential operator can check.\n \"\"\"\n start = acc[\"start\"] or _new_acc()\n written = {target for _, target, _ in start[\"numeric\"]}\n what = f\"an over-all or at-end condition of '{action.name}'\"\n out = []\n for disjunct in _dnf(action.late_condition, subst, type_objects):\n regressed = _regress(disjunct.pos, disjunct.neg, start, what)\n if regressed is None:\n continue\n read: set = set()\n for comparison in disjunct.comparisons:\n _collect_fluents(comparison.left, read)\n _collect_fluents(comparison.right, read)\n _refuse_numeric_overlap(written, read, what)\n out.append(_Disjunct(regressed[0], regressed[1], list(disjunct.comparisons)))\n return out\n\n\ndef _leaf_effects(effect):\n \"\"\"Every add, delete, numeric or cost effect inside ``effect``.\"\"\"\n if isinstance(effect, ConjunctiveEffect):\n for part in effect.parts:\n yield from _leaf_effects(part)\n elif isinstance(effect, (ForallEffect, WhenEffect)):\n yield from _leaf_effects(effect.body)\n elif isinstance(effect, TimedEffect):\n yield from _leaf_effects(effect.start)\n yield from _leaf_effects(effect.end)\n else:\n yield effect\n\n\ndef _effect_predicates(domain: Domain) -> set:\n return {\n eff.atom.predicate\n for action in domain.actions\n for eff in _leaf_effects(action.effect)\n if isinstance(eff, (AddEffect, DelEffect))\n }\n\n\ndef _assigned_functions(domain: Domain) -> set:\n \"\"\"Names of the numeric functions some effect changes; the rest are static.\"\"\"\n return {\n eff.target.name\n for action in domain.actions\n for eff in _leaf_effects(action.effect)\n if isinstance(eff, NumericEffect)\n }\n\n\ndef _cost_functions(domain: Domain) -> set:\n \"\"\"Names of the functions ``(increase (total-cost) )`` reads.\"\"\"\n read: set = set()\n for action in domain.actions:\n for eff in _leaf_effects(action.effect):\n if isinstance(eff, IncreaseCostEffect) and not isinstance(\n eff.amount, (int, float)\n ):\n _collect_fluents(eff.amount, read)\n return {ref.name for ref in read}\n\n\ndef _mentions(expression, name: str) -> bool:\n if isinstance(expression, FluentRef):\n return expression.name == name\n if isinstance(expression, Arithmetic):\n return _mentions(expression.left, name) or _mentions(expression.right, name)\n return False\n\n\ndef _cost_model(problem: Problem) -> str:\n \"\"\"What a domain action costs, according to the metric.\n\n * no metric \u2014 its cost effect, else its duration, else 1: cost-optimal\n means shortest, or makespan-optimal for durative actions;\n * a metric over ``(total-cost)`` \u2014 its cost effect, else 0, as in IPC\n planners;\n * a metric over ``(total-time)`` \u2014 its duration, so the sequential\n makespan is minimised (0 for an instantaneous action);\n * any other metric (only preference penalties) \u2014 0: what an action costs\n does not enter the metric at all.\n\n The parser has already refused metrics of any other shape.\n \"\"\"\n if problem.metric is None:\n return \"default\"\n expression = problem.metric[1]\n if _mentions(expression, \"total-time\"):\n return \"time\"\n if _mentions(expression, TOTAL_COST):\n return \"cost\"\n return \"free\"\n\n\ndef _static_value(expression, values: dict, assigned: set, action_name: str):\n \"\"\"Evaluate a ground cost expression from :init; None if it is undefined.\"\"\"\n if isinstance(expression, Number):\n return float(expression.value)\n if isinstance(expression, FluentRef):\n if expression.name in assigned or expression.name == TOTAL_COST:\n raise UnsupportedFeatureError(\n f\"the cost of '{action_name}' reads {expression}, which an effect \"\n \"changes; (increase (total-cost) ...) may only read static \"\n \"functions, whose value is fixed by :init\"\n )\n return values.get(expression)\n if isinstance(expression, Arithmetic):\n left = _static_value(expression.left, values, assigned, action_name)\n right = _static_value(expression.right, values, assigned, action_name)\n if left is None or right is None:\n return None\n if expression.op == \"+\":\n return left + right\n if expression.op == \"-\":\n return left - right\n if expression.op == \"*\":\n return left * right\n return None if right == 0 else left / right\n raise PDDLError(f\"unexpected numeric expression: {expression!r}\")\n\n\ndef _ground_raw_operators(domain, problem, type_objects) -> list:\n model = _cost_model(problem)\n assigned = _assigned_functions(domain)\n raw = []\n for action in domain.actions:\n synthetic = action.name.startswith(SYNTHETIC_PREFIX)\n pools = [type_objects.get(typ, ()) for (_, typ) in action.parameters]\n for combo in product(*pools):\n subst = {var: obj for (var, _), obj in zip(action.parameters, combo)}\n disjuncts = _dnf(action.precondition, subst, type_objects)\n if not disjuncts:\n continue # precondition is unsatisfiable for this instance\n\n acc = _new_acc()\n _collect_effect(action.effect, subst, type_objects, set(), set(), acc)\n if action.late_condition is not None:\n late = _late_disjuncts(action, subst, type_objects, acc)\n disjuncts = _cross(disjuncts, late)\n if not disjuncts:\n continue\n\n args = \",\".join(combo)\n base = f\"{action.name}({args})\" if combo else action.name\n duration = 0.0\n if action.duration is not None:\n duration = _constant_value(action.duration, subst, action.name)\n\n values = [\n _static_value(expression, problem.init_numeric, assigned, action.name)\n for expression in acc[\"cost_exprs\"]\n ]\n if None in values:\n continue # reads an undefined function: PDDL makes it inapplicable\n declared = acc[\"cost\"] + sum(values)\n\n # Penalties and other bookkeeping costs a compilation declared are\n # always charged; what a domain action costs depends on the metric.\n if acc[\"has_cost\"] and (synthetic or model in (\"default\", \"cost\")):\n cost = declared\n elif model in (\"cost\", \"free\"):\n cost = 0\n elif model == \"time\" or duration:\n # Makespan: a durative action costs its duration.\n cost = duration\n else:\n cost = 1\n if cost < 0:\n raise PDDLError(\n f\"action '{base}' has negative cost {cost}; \"\n \"(increase (total-cost) k) needs k >= 0\"\n )\n\n for index, disjunct in enumerate(disjuncts):\n # Only tag the name when the split is real, so classical domains\n # keep the operator names their users expect.\n name = base if len(disjuncts) == 1 else f\"{base}#{index + 1}\"\n raw.append(\n _RawOp(\n name=name,\n pre_pos=set(disjunct.pos),\n pre_neg=set(disjunct.neg),\n comparisons=list(disjunct.comparisons),\n add=set(acc[\"add\"]),\n delete=set(acc[\"delete\"]),\n cond=list(acc[\"cond\"]),\n numeric=list(acc[\"numeric\"]),\n cost=cost,\n duration=duration,\n )\n )\n return raw\n\n\ndef _constant_value(expr, subst, action_name):\n \"\"\"Evaluate a duration expression that must be constant.\"\"\"\n grounded = _ground_expression(expr, subst)\n if isinstance(grounded, Number):\n return float(grounded.value)\n raise UnsupportedFeatureError(\n f\"the duration of '{action_name}' must be a constant; \"\n \"durations that read numeric fluents are not supported\"\n )\n\n\n# --------------------------------------------------------------------------\n# numeric compilation\n# --------------------------------------------------------------------------\ndef _collect_fluents(expr, out: set) -> None:\n if isinstance(expr, FluentRef):\n out.add(expr)\n elif isinstance(expr, Arithmetic):\n _collect_fluents(expr.left, out)\n _collect_fluents(expr.right, out)\n\n\ndef _compile_expression(expr, index_of: dict):\n \"\"\"Compile a ground numeric expression into a ``values -> float`` closure.\"\"\"\n if isinstance(expr, Number):\n constant = float(expr.value)\n return lambda values: constant\n if isinstance(expr, FluentRef):\n index = index_of[expr]\n return lambda values: values[index]\n if isinstance(expr, Arithmetic):\n left = _compile_expression(expr.left, index_of)\n right = _compile_expression(expr.right, index_of)\n if expr.op == \"+\":\n return lambda values: left(values) + right(values)\n if expr.op == \"-\":\n return lambda values: left(values) - right(values)\n if expr.op == \"*\":\n return lambda values: left(values) * right(values)\n if expr.op == \"/\":\n\n def divide(values):\n denominator = right(values)\n if denominator == 0:\n # Undefined rather than crashing mid-search: an infinite\n # value makes every comparison against it fail.\n return float(\"inf\")\n return left(values) / denominator\n\n return divide\n raise PDDLError(f\"unknown arithmetic operator '{expr.op}'\")\n raise PDDLError(f\"unexpected numeric expression: {expr!r}\")\n\n\ndef _compile_comparison(comparison: Comparison, index_of: dict):\n left = _compile_expression(comparison.left, index_of)\n right = _compile_expression(comparison.right, index_of)\n op = comparison.op\n if op == \"<\":\n return lambda values: left(values) < right(values)\n if op == \"<=\":\n return lambda values: left(values) <= right(values)\n if op == \">\":\n return lambda values: left(values) > right(values)\n if op == \">=\":\n return lambda values: left(values) >= right(values)\n if op == \"=\":\n return lambda values: left(values) == right(values)\n if op == \"!=\":\n return lambda values: left(values) != right(values)\n raise PDDLError(f\"unknown comparison operator '{op}'\")\n\n\ndef _compile_numeric_effect(op: str, index: int, value, index_of: dict):\n compute = _compile_expression(value, index_of)\n if op == \"assign\":\n return (index, compute)\n if op == \"increase\":\n return (index, lambda values: values[index] + compute(values))\n if op == \"decrease\":\n return (index, lambda values: values[index] - compute(values))\n if op == \"scale-up\":\n return (index, lambda values: values[index] * compute(values))\n if op == \"scale-down\":\n\n def scale_down(values):\n divisor = compute(values)\n return float(\"inf\") if divisor == 0 else values[index] / divisor\n\n return (index, scale_down)\n raise PDDLError(f\"unknown numeric assignment '{op}'\")\n\n\n# --------------------------------------------------------------------------\n# encoding\n# --------------------------------------------------------------------------\n@dataclass\nclass _Encoder:\n fact_ids: dict = field(default_factory=dict)\n comp_ids: dict = field(default_factory=dict)\n names: list = field(default_factory=list)\n\n def fact(self, atom: Atom) -> int:\n if atom not in self.fact_ids:\n self.fact_ids[atom] = len(self.names)\n self.names.append(str(atom))\n return self.fact_ids[atom]\n\n def comp(self, atom: Atom) -> int:\n if atom not in self.comp_ids:\n self.comp_ids[atom] = len(self.names)\n self.names.append(f\"(not {atom})\")\n return self.comp_ids[atom]\n\n\ndef _ids(ids) -> frozenset:\n \"\"\"A fact-id set built in sorted order.\n\n A set of small ints iterates in insertion order wherever two ids share a\n hash slot, so a set filled while walking atoms in hash order iterates\n differently under each PYTHONHASHSEED -- enough to change which of two\n tied facts a heuristic picks.\n \"\"\"\n return frozenset(sorted(ids))\n\n\ndef _ground_axioms(domain: Domain, type_objects: dict) -> list:\n \"\"\"Ground every derived-predicate rule into (head atom, disjunct) pairs.\"\"\"\n grounded = []\n for rule in domain.derived:\n for subst in _quantifier_substitutions(rule.params, {}, type_objects):\n head = _ground_atom(rule.head, subst)\n for disjunct in _dnf(rule.body, subst, type_objects):\n if disjunct.comparisons:\n raise UnsupportedFeatureError(\n \"numeric comparisons in a derived predicate are not supported\"\n )\n grounded.append((head, disjunct))\n return grounded\n\n\ndef ground(domain: Domain, problem: Problem) -> Task:\n \"\"\"Ground ``domain`` + ``problem`` into a :class:`Task`.\"\"\"\n # PDDL 3 constructs (preferences, trajectory constraints, timed literals,\n # object fluents) are rewritten into the classical core first, so nothing\n # below this line has to know they exist.\n requirements = tuple(domain.requirements)\n domain, problem = compile_problem(domain, problem)\n type_objects = _build_type_objects(domain, problem)\n init_atoms = set(problem.init)\n\n derived_preds = {rule.head.predicate for rule in domain.derived}\n all_preds = {p.name for p in domain.predicates} | derived_preds\n # A derived predicate never appears in an effect, but it is emphatically not\n # static -- the axioms compute it.\n static_preds = all_preds - _effect_predicates(domain) - derived_preds\n\n def is_static(atom: Atom) -> bool:\n return atom.predicate in static_preds\n\n raw_ops = _ground_raw_operators(domain, problem, type_objects)\n axiom_rules = _ground_axioms(domain, type_objects)\n\n enc = _Encoder()\n tracked_neg: set = set()\n\n def resolve_literals(pos_atoms, neg_atoms):\n \"\"\"Drop static literals that hold, fail on ones that do not.\"\"\"\n pos_fluent, neg_fluent = set(), set()\n for atom in pos_atoms:\n if is_static(atom):\n if atom not in init_atoms:\n return None\n else:\n pos_fluent.add(atom)\n for atom in neg_atoms:\n if is_static(atom):\n if atom in init_atoms:\n return None\n else:\n neg_fluent.add(atom)\n tracked_neg.add(atom)\n return pos_fluent, neg_fluent\n\n # --- resolve static literals, drop infeasible operators ------------------\n resolved = []\n for op in raw_ops:\n pre = resolve_literals(op.pre_pos, op.pre_neg)\n if pre is None:\n continue\n pre_pos_fluent, pre_neg_fluent = pre\n\n add = set(op.add)\n delete = set(op.delete)\n cond = []\n for cpos, cneg, cadd, cdel in op.cond:\n trigger = resolve_literals(cpos, cneg)\n if trigger is None:\n continue # this conditional effect can never fire\n cpos_f, cneg_f = trigger\n if not cpos_f and not cneg_f:\n add |= cadd\n delete |= cdel\n else:\n cond.append((cpos_f, cneg_f, cadd, cdel))\n resolved.append((op, pre_pos_fluent, pre_neg_fluent, add, delete, cond))\n\n # --- numeric fluents that are never defined ------------------------------\n # A fluent with no value in :init that no action assigns stays undefined,\n # and PDDL makes any condition or effect that reads it fail. Reading it as\n # 0 instead would let `(distance a c)` for a road that does not exist cost\n # nothing. (A fluent defined later by an effect is not tracked.)\n assigned = {target for op, *_ in resolved for _, target, _ in op.numeric}\n defined = set(problem.init_numeric) | assigned\n\n def reads_undefined(comparisons, values=()) -> bool:\n read: set = set()\n for comparison in comparisons:\n _collect_fluents(comparison.left, read)\n _collect_fluents(comparison.right, read)\n for value in values:\n _collect_fluents(value, read)\n return any(ref not in defined and ref.name != \"total-cost\" for ref in read)\n\n resolved = [\n entry\n for entry in resolved\n if not reads_undefined(\n entry[0].comparisons, [value for _, _, value in entry[0].numeric]\n )\n ]\n\n # --- goal ----------------------------------------------------------------\n goal_disjuncts = _dnf(problem.goal, {}, type_objects)\n if not goal_disjuncts:\n raise ValueError(\"Goal condition is self-contradictory\")\n\n resolved_goals = []\n unsolvable = True\n for disjunct in goal_disjuncts:\n parts = resolve_literals(disjunct.pos, disjunct.neg)\n if parts is None or reads_undefined(disjunct.comparisons):\n continue # this way of satisfying the goal is statically impossible\n unsolvable = False\n resolved_goals.append((parts[0], parts[1], disjunct.comparisons))\n if not resolved_goals:\n resolved_goals = [(set(), set(), [])]\n\n # --- axioms --------------------------------------------------------------\n resolved_axioms = []\n for head, disjunct in axiom_rules:\n parts = resolve_literals(disjunct.pos, disjunct.neg)\n if parts is None:\n continue\n if any(atom.predicate in derived_preds for atom in parts[1]):\n raise UnsupportedFeatureError(\n f\"the rule for {head} negates a derived predicate; that needs \"\n \"stratified axiom evaluation, which is not supported (negating \"\n \"a derived predicate in a precondition or goal is fine)\"\n )\n resolved_axioms.append((head, parts[0], parts[1]))\n\n # --- numeric fluents -----------------------------------------------------\n # A static function only action costs read, like `(road-length ?a ?b)`, was\n # folded into the operator costs above; it is not a state variable. If a\n # condition reads it as well, the loop below brings that reading back.\n cost_only = _cost_functions(domain)\n fluents: set = {ref for ref in problem.init_numeric if ref.name not in cost_only}\n for op, *_ in resolved:\n for comparison in op.comparisons:\n _collect_fluents(comparison.left, fluents)\n _collect_fluents(comparison.right, fluents)\n for _, target, value in op.numeric:\n fluents.add(target)\n _collect_fluents(value, fluents)\n for _, _, comparisons in resolved_goals:\n for comparison in comparisons:\n _collect_fluents(comparison.left, fluents)\n _collect_fluents(comparison.right, fluents)\n # `total-cost` is bookkeeping handled by operator costs, not a state variable.\n fluents = {ref for ref in fluents if ref.name != \"total-cost\"}\n\n ordered_fluents = sorted(fluents, key=str)\n index_of = {ref: i for i, ref in enumerate(ordered_fluents)}\n numeric_names = tuple(str(ref) for ref in ordered_fluents)\n init_values = tuple(\n float(problem.init_numeric.get(ref, 0.0)) for ref in ordered_fluents\n )\n\n # --- encode facts --------------------------------------------------------\n # Fact ids are handed out in sorted order. Assigning them while walking\n # sets of atoms ties every id to string hashing, so PYTHONHASHSEED changed\n # LM-cut's tie-breaking and with it the expansions and the plan.\n every_atom: set = {a for a in init_atoms if not is_static(a)}\n for op, pp, pn, add, delete, cond in resolved:\n every_atom |= pp | pn | add | delete\n for cpos_f, cneg_f, cadd, cdel in cond:\n every_atom |= cpos_f | cneg_f | cadd | cdel\n for goal_pos, goal_neg, _ in resolved_goals:\n every_atom |= goal_pos | goal_neg\n for head, body_pos, body_neg in resolved_axioms:\n every_atom |= {head} | body_pos | body_neg\n for atom in sorted(every_atom, key=str):\n enc.fact(atom)\n for atom in sorted(tracked_neg, key=str):\n enc.comp(atom)\n\n init_ids = {enc.fact(a) for a in init_atoms if not is_static(a)}\n for atom in tracked_neg:\n cid = enc.comp(atom)\n if atom not in init_atoms:\n init_ids.add(cid)\n\n complement_ids = frozenset(enc.comp(a) for a in tracked_neg)\n\n def encode_add_del(add_atoms, del_atoms):\n add_ids = {enc.fact(a) for a in add_atoms}\n del_ids = {enc.fact(a) for a in del_atoms}\n for a in add_atoms:\n if a in tracked_neg:\n del_ids.add(enc.comp(a))\n for a in del_atoms:\n if a in tracked_neg:\n add_ids.add(enc.comp(a))\n # An atom both added and deleted ends up true (add wins), so its\n # complement must end up false: for complements the delete wins.\n add_ids -= del_ids & complement_ids\n return _ids(add_ids), _ids(del_ids)\n\n operators = []\n for op, pp, pn, add, delete, cond in resolved:\n precond = _ids({enc.fact(a) for a in pp} | {enc.comp(a) for a in pn})\n add_ids, del_ids = encode_add_del(add, delete)\n cond_effects = []\n touched = set(add_ids)\n for cpos_f, cneg_f, cadd, cdel in cond:\n cond_ids = {enc.fact(a) for a in cpos_f} | {enc.comp(a) for a in cneg_f}\n cadd_ids, cdel_ids = encode_add_del(cadd, cdel)\n if cadd_ids or cdel_ids:\n cond_effects.append(CondEffect(_ids(cond_ids), cadd_ids, cdel_ids))\n touched |= cadd_ids\n numeric_pre = tuple(_compile_comparison(c, index_of) for c in op.comparisons)\n numeric_eff = tuple(\n _compile_numeric_effect(kind, index_of[target], value, index_of)\n for kind, target, value in op.numeric\n )\n operators.append(\n Operator(\n op.name,\n precond,\n add_ids,\n del_ids,\n tuple(cond_effects),\n op.cost,\n numeric_pre,\n numeric_eff,\n op.duration,\n complements=(\n frozenset(touched) & complement_ids if cond_effects else frozenset()\n ),\n )\n )\n\n # --- goal encoding, compiling a disjunction into a synthetic fact --------\n synthetic: set = set()\n goal_numeric: tuple = ()\n if len(resolved_goals) == 1:\n goal_pos, goal_neg, comparisons = resolved_goals[0]\n goals = {enc.fact(a) for a in goal_pos} | {enc.comp(a) for a in goal_neg}\n goal_numeric = tuple(_compile_comparison(c, index_of) for c in comparisons)\n else:\n goal_fact = enc.fact(GOAL_FACT)\n goals = {goal_fact}\n for index, (goal_pos, goal_neg, comparisons) in enumerate(resolved_goals):\n name = f\"__reach-goal__#{index + 1}\"\n synthetic.add(name)\n precond = {enc.fact(a) for a in goal_pos} | {enc.comp(a) for a in goal_neg}\n operators.append(\n Operator(\n name,\n _ids(precond),\n frozenset({goal_fact}),\n frozenset(),\n (),\n 0,\n tuple(_compile_comparison(c, index_of) for c in comparisons),\n (),\n 0.0,\n )\n )\n\n if unsolvable:\n sentinel = len(enc.names)\n enc.names.append(\"(unsolvable)\")\n goals.add(sentinel) # never produced by any operator\n\n axioms = tuple(\n Axiom(\n enc.fact(head),\n _ids({enc.fact(a) for a in body_pos} | {enc.comp(a) for a in body_neg}),\n )\n for head, body_pos, body_neg in resolved_axioms\n )\n\n # Anything a compilation introduced is bookkeeping, not something the\n # domain author wrote, so keep it out of printed plans.\n synthetic |= {\n op.name for op in operators if op.base_name.startswith(SYNTHETIC_PREFIX)\n }\n temporal = any(op.duration for op in operators)\n clock_index = index_of.get(CLOCK_FLUENT)\n metric = None\n if problem.metric is not None:\n direction, expression = problem.metric\n metric = f\"{direction} {expression}\"\n\n # Before Task(...): encoding a derived atom no rule produces adds a name,\n # and `facts` below must already include it.\n derived_complements = tuple(\n sorted(\n (enc.fact(atom), enc.comp(atom))\n for atom in tracked_neg\n if atom.predicate in derived_preds\n )\n )\n\n return Task(\n name=problem.name or domain.name,\n facts=tuple(enc.names),\n init=_ids(init_ids),\n goals=_ids(goals),\n operators=tuple(operators),\n metric_cost=problem.metric_minimize_cost,\n axioms=axioms,\n numeric_names=numeric_names,\n init_values=init_values,\n goal_numeric=goal_numeric,\n temporal=temporal,\n clock_index=clock_index,\n metric=metric,\n requirements=requirements,\n synthetic=frozenset(synthetic),\n derived_complements=derived_complements,\n )\n\n\ndef ground_files(domain_path: str, problem_path: str) -> Task:\n \"\"\"Convenience: parse both files and ground them into a :class:`Task`.\"\"\"\n return ground(parse_domain_file(domain_path), parse_problem_file(problem_path))\n", "jupyddl/heuristics/__init__.py": "\"\"\"Heuristics and a name-based registry.\"\"\"\n\nfrom __future__ import annotations\n\nfrom functools import partial\n\nfrom .base import Heuristic\nfrom .critical_path import CriticalPathHeuristic\nfrom .delete_relaxation import FFHeuristic, HAddHeuristic, HMaxHeuristic\nfrom .lmcut import LMCutHeuristic\nfrom .simple import BlindHeuristic, GoalCountHeuristic\n\n# name -> callable(task) -> Heuristic\nHEURISTICS = {\n \"blind\": BlindHeuristic,\n \"goalcount\": GoalCountHeuristic,\n \"hmax\": HMaxHeuristic,\n \"hadd\": HAddHeuristic,\n \"hff\": FFHeuristic,\n \"lmcut\": LMCutHeuristic,\n \"h1\": partial(CriticalPathHeuristic, m=1),\n \"h2\": partial(CriticalPathHeuristic, m=2),\n \"hm\": CriticalPathHeuristic,\n}\n\n\n# A heuristic that needs more than a task to build takes an argument after a\n# colon: ``learned:blocksworld.heur.json``. Keeping this a *string* spec rather\n# than a Python object is what makes a trained heuristic usable from the CLI,\n# the benchmark harness and the web UI without any of them knowing it exists.\nLOADERS = {}\n\n\ndef _load_learned(path: str, task) -> Heuristic:\n \"\"\"Resolve ``learned:``, importing the learning stack only if asked.\"\"\"\n from ..learn.heuristic import HeuristicBundle\n\n return HeuristicBundle.load(path).bind(task)\n\n\nLOADERS[\"learned\"] = _load_learned\n\n\ndef make_heuristic(name, task) -> Heuristic:\n \"\"\"Instantiate a heuristic by name (see :data:`HEURISTICS`).\n\n Accepts a registry name (``\"lmcut\"``), a parameterised spec\n (``\"learned:model.json\"``), or an already-built heuristic, which is passed\n through so callers holding a trained model need not round-trip it to disk.\n An unregistered name triggers plugin discovery once (see\n :mod:`jupyddl.plugins`) before it is reported as unknown.\n \"\"\"\n if not isinstance(name, str):\n # A class or partial from HEURISTICS is a factory: build it for this\n # task. Anything else callable is an already-built ``state -> h``.\n if isinstance(name, (type, partial)):\n return name(task)\n if callable(name):\n return name\n raise TypeError(f\"not a heuristic: {name!r}\")\n if \":\" in name:\n kind, _, argument = name.partition(\":\")\n loader = LOADERS.get(kind)\n if loader is None:\n raise ValueError(\n f\"Unknown parameterised heuristic '{kind}'. \"\n f\"Available: {sorted(LOADERS)}\"\n )\n return loader(argument, task)\n factory = HEURISTICS.get(name)\n if factory is None:\n from ..plugins import load_plugins\n\n load_plugins()\n factory = HEURISTICS.get(name)\n if factory is None:\n raise ValueError(\n f\"Unknown heuristic '{name}'. Available: {sorted(HEURISTICS)}\"\n + (f\" (or {sorted(LOADERS)} with an argument)\" if LOADERS else \"\")\n )\n return factory(task)\n\n\n__all__ = [\n \"Heuristic\",\n \"BlindHeuristic\",\n \"GoalCountHeuristic\",\n \"HMaxHeuristic\",\n \"HAddHeuristic\",\n \"FFHeuristic\",\n \"LMCutHeuristic\",\n \"CriticalPathHeuristic\",\n \"HEURISTICS\",\n \"LOADERS\",\n \"make_heuristic\",\n]\n", "jupyddl/heuristics/base.py": "\"\"\"Heuristic base class.\"\"\"\n\nfrom __future__ import annotations\n\n\nclass Heuristic:\n \"\"\"A heuristic bound to a task; call it on a state to get an estimate.\n\n Returning ``math.inf`` signals that the state is a (relaxed) dead end.\n \"\"\"\n\n name: str = \"heuristic\"\n admissible: bool = False\n\n def __init__(self, task):\n self.task = task\n\n def __call__(self, state: frozenset) -> float: # pragma: no cover\n raise NotImplementedError\n", "jupyddl/heuristics/critical_path.py": "\"\"\"Critical-path heuristics h^m (Haslum & Geffner, 2000).\n\n``h^m`` estimates the cost of the most expensive size-``m`` subset of atoms.\n``h^1`` equals ``h_max``; higher ``m`` is more informative but costs more.\n\nThis computes ``h^m`` over the *delete relaxation*, as the relaxation\nheuristics here do: it is admissible on tasks without conditional effects, but\nit cannot see mutexes the way Haslum and Geffner's full ``h^m`` does.\nWith conditional effects and ``m >= 2`` it can overestimate, and the\ninstance's ``admissible`` flag says so.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom itertools import combinations\n\nfrom ..task import facts_of\nfrom .base import Heuristic\n\n\nclass CriticalPathHeuristic(Heuristic):\n name = \"hm\"\n admissible = True\n\n def __init__(self, task, m: int = 2):\n super().__init__(task)\n self.m = m\n self.goal = task.goals\n self.ops = list(task.relaxed_operators()) # (pre, add, cost)\n if m >= 2 and task.has_conditional_effects:\n self.admissible = False\n\n def __call__(self, state) -> float:\n return self._hm(state)\n\n def _hm(self, state) -> float:\n m = self.m\n inf = math.inf\n state = facts_of(state)\n atoms = set(state) | set(self.goal)\n for pre, add, _ in self.ops:\n atoms |= pre\n atoms |= add\n atoms = sorted(atoms)\n\n # Table of costs for every atom-set of size 1..m.\n table: dict = {}\n sets: list = []\n for size in range(1, m + 1):\n for combo in combinations(atoms, size):\n fs = frozenset(combo)\n table[fs] = 0.0 if fs <= state else inf\n sets.append(fs)\n\n def cost_of(subset: frozenset) -> float:\n # Cost of an arbitrary atom-set: table lookup, or (if larger than m)\n # the max over its size-m subsets.\n if not subset:\n return 0.0\n if len(subset) <= m:\n return table.get(subset, inf)\n best = 0.0\n for combo in combinations(sorted(subset), m):\n val = table.get(frozenset(combo), inf)\n if val > best:\n best = val\n return best\n\n # Value iteration to the fixpoint.\n changed = True\n while changed:\n changed = False\n for target in sets:\n if table[target] == 0.0:\n continue\n best = table[target]\n for pre, add, cost in self.ops:\n if not (add & target):\n continue\n regressed = (target - add) | pre\n val = cost + cost_of(regressed)\n if val < best:\n best = val\n if best < table[target]:\n table[target] = best\n changed = True\n\n return cost_of(frozenset(self.goal))\n", "jupyddl/heuristics/delete_relaxation.py": "\"\"\"Delete-relaxation heuristics: h_max, h_add and the FF heuristic.\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom collections import deque\n\nfrom .base import Heuristic\nfrom .relaxation import RelaxedTask, goal_value, propagate_costs\n\n\nclass HMaxHeuristic(Heuristic):\n \"\"\"h_max: the most expensive relaxed goal fact. Admissible.\"\"\"\n\n name = \"hmax\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, _ = propagate_costs(self.rt, state, additive=False)\n return goal_value(cost, self.rt.goal, additive=False)\n\n\nclass HAddHeuristic(Heuristic):\n \"\"\"h_add: sum of relaxed goal-fact costs. Informative, not admissible.\"\"\"\n\n name = \"hadd\"\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, _ = propagate_costs(self.rt, state, additive=True)\n return goal_value(cost, self.rt.goal, additive=True)\n\n\nclass FFHeuristic(Heuristic):\n \"\"\"FF heuristic: cost of a relaxed plan extracted from the h_add graph.\"\"\"\n\n name = \"hff\"\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, supporter = propagate_costs(self.rt, state, additive=True)\n if any(math.isinf(cost[g]) for g in self.rt.goal):\n return math.inf\n relaxed_plan: set = set()\n seen: set = set()\n queue = deque(self.rt.goal)\n while queue:\n fact = queue.popleft()\n if fact in state or fact in seen:\n continue\n seen.add(fact)\n op_idx = supporter[fact]\n if op_idx < 0:\n return math.inf\n if op_idx not in relaxed_plan:\n relaxed_plan.add(op_idx)\n for pre_fact in self.rt.ops[op_idx].pre:\n if pre_fact not in state:\n queue.append(pre_fact)\n return float(sum(self.rt.ops[i].cost for i in relaxed_plan))\n", "jupyddl/heuristics/lmcut.py": "\"\"\"The LM-cut heuristic (Helmert & Domshlak, 2009).\n\nLM-cut repeatedly computes h_max, finds a *landmark cut* of operators separating\nthe initial state from the goal in the justification graph, adds the cut's\ncheapest operator cost to the estimate, and discounts the cut operators. It is\none of the strongest admissible heuristics for optimal planning.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport heapq\nimport math\nfrom dataclasses import dataclass\n\nfrom .base import Heuristic\n\n\n@dataclass\nclass _AugOp:\n idx: int\n pre: frozenset\n add: frozenset\n base_cost: int\n\n\nclass LMCutHeuristic(Heuristic):\n name = \"lmcut\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n # Each conditional effect is relaxed into its own operator charging the\n # full action cost, and the cost partitioning can then count it twice.\n if task.has_conditional_effects:\n self.admissible = False\n nf = task.num_facts\n self.INIT = nf # artificial \"always true\" fact\n self.GOAL = nf + 1 # artificial goal fact\n self.num = nf + 2\n\n self.ops: list[_AugOp] = []\n for pre, add, cost in task.relaxed_operators():\n # Every operator must have at least one precondition for the\n # justification graph; use the artificial INIT fact if needed.\n pre = pre if pre else frozenset({self.INIT})\n self.ops.append(_AugOp(len(self.ops), pre, add, cost))\n # Artificial goal operator (cost 0) collecting the real goal.\n self.goal_op = len(self.ops)\n goal_pre = task.goals if task.goals else frozenset({self.INIT})\n self.ops.append(_AugOp(self.goal_op, goal_pre, frozenset({self.GOAL}), 0))\n\n self.consumers: list[list[int]] = [[] for _ in range(self.num)]\n for op in self.ops:\n for f in op.pre:\n self.consumers[f].append(op.idx)\n\n def __call__(self, state) -> float:\n base = set(state)\n base.add(self.INIT)\n source = frozenset(base)\n costs = [op.base_cost for op in self.ops]\n total = 0.0\n\n while True:\n hmax, pcf = self._hmax(source, costs)\n if hmax[self.GOAL] == 0:\n return total\n if math.isinf(hmax[self.GOAL]):\n return math.inf\n\n goal_zone = self._goal_zone(costs, pcf)\n cut = self._cut(source, goal_zone, pcf, hmax)\n if not cut: # should not happen when hmax(goal) > 0\n return total # a lower bound either way; inf would prune\n min_cost = min(costs[oi] for oi in cut)\n total += min_cost\n for oi in cut:\n costs[oi] -= min_cost\n\n def _hmax(self, source, costs):\n inf = math.inf\n cost = [inf] * self.num\n pcf = [-1] * len(self.ops)\n counter = [len(op.pre) for op in self.ops]\n pq: list = []\n for f in source:\n cost[f] = 0\n heapq.heappush(pq, (0, f))\n\n def relax(op: _AugOp):\n # Ties go to the higher fact id, so the cut never depends on set order.\n supporter = max(op.pre, key=lambda p: (cost[p], p))\n value = cost[supporter] + costs[op.idx]\n pcf[op.idx] = supporter\n if math.isinf(cost[supporter]):\n return\n for f in op.add:\n if value < cost[f]:\n cost[f] = value\n heapq.heappush(pq, (value, f))\n\n for op in self.ops:\n if counter[op.idx] == 0:\n relax(op)\n while pq:\n c, f = heapq.heappop(pq)\n if c > cost[f]:\n continue\n for op_idx in self.consumers[f]:\n counter[op_idx] -= 1\n if counter[op_idx] == 0:\n relax(self.ops[op_idx])\n return cost, pcf\n\n def _goal_zone(self, costs, pcf):\n \"\"\"Facts that reach the goal through zero-cost justification edges.\"\"\"\n zone = {self.GOAL}\n changed = True\n while changed:\n changed = False\n for op in self.ops:\n supporter = pcf[op.idx]\n if (\n costs[op.idx] == 0\n and supporter >= 0\n and supporter not in zone\n and (op.add & zone)\n ):\n zone.add(supporter)\n changed = True\n return zone\n\n def _cut(self, source, goal_zone, pcf, hmax):\n \"\"\"Operators crossing from the init-reachable region into the goal zone.\"\"\"\n # Forward-reachable facts from the initial state that stay out of the\n # goal zone (the \"before\" region).\n before = {f for f in source if f not in goal_zone}\n changed = True\n while changed:\n changed = False\n for op in self.ops:\n supporter = pcf[op.idx]\n if supporter in before and not math.isinf(hmax[supporter]):\n for f in op.add:\n if f not in goal_zone and f not in before:\n before.add(f)\n changed = True\n cut = [\n op.idx for op in self.ops if pcf[op.idx] in before and (op.add & goal_zone)\n ]\n return cut\n", "jupyddl/heuristics/relaxation.py": "\"\"\"Delete-relaxation machinery shared by h_max, h_add, h_FF and LM-cut.\"\"\"\n\nfrom __future__ import annotations\n\nimport heapq\nimport math\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass RelaxedOp:\n idx: int\n pre: frozenset\n add: frozenset\n cost: int\n\n\nclass RelaxedTask:\n \"\"\"Delete-relaxed view of a task: unary-ish operators + goal.\n\n Conditional effects are already expanded into separate relaxed operators by\n :meth:`jupyddl.task.Task.relaxed_operators`.\n \"\"\"\n\n def __init__(self, task):\n self.num_facts = task.num_facts\n self.goal = task.goals\n self.ops = [\n RelaxedOp(i, pre, add, cost)\n for i, (pre, add, cost) in enumerate(task.relaxed_operators())\n ]\n # Index: fact -> operators that have it as a precondition.\n self.consumers: list[list[int]] = [[] for _ in range(self.num_facts)]\n self.no_pre: list[int] = []\n for op in self.ops:\n if op.pre:\n for f in op.pre:\n self.consumers[f].append(op.idx)\n else:\n self.no_pre.append(op.idx)\n\n\ndef propagate_costs(rt: RelaxedTask, state, additive: bool):\n \"\"\"Generalised Dijkstra computing h_max (``additive=False``) or h_add costs.\n\n Returns ``(cost, supporter)`` where ``cost[f]`` is the estimated cost to\n achieve fact ``f`` and ``supporter[f]`` is the operator index that achieved\n it (used for FF's relaxed-plan extraction).\n \"\"\"\n inf = math.inf\n cost = [inf] * rt.num_facts\n supporter = [-1] * rt.num_facts\n counter = [len(op.pre) for op in rt.ops]\n pq: list = []\n\n for f in state:\n if cost[f] != 0:\n cost[f] = 0\n heapq.heappush(pq, (0, f))\n\n def op_value(op: RelaxedOp) -> float:\n if not op.pre:\n return op.cost\n pre_costs = [cost[p] for p in op.pre]\n agg = sum(pre_costs) if additive else max(pre_costs)\n return op.cost + agg\n\n def apply_op(op: RelaxedOp):\n value = op_value(op)\n if math.isinf(value):\n return\n for f in op.add:\n if value < cost[f]:\n cost[f] = value\n supporter[f] = op.idx\n heapq.heappush(pq, (value, f))\n\n for idx in rt.no_pre:\n apply_op(rt.ops[idx])\n\n while pq:\n c, f = heapq.heappop(pq)\n if c > cost[f]:\n continue\n for op_idx in rt.consumers[f]:\n counter[op_idx] -= 1\n if counter[op_idx] == 0:\n apply_op(rt.ops[op_idx])\n return cost, supporter\n\n\ndef goal_value(cost, goal, additive: bool) -> float:\n if not goal:\n return 0.0\n values = [cost[g] for g in goal]\n if any(math.isinf(v) for v in values):\n return math.inf\n return float(sum(values) if additive else max(values))\n", "jupyddl/heuristics/simple.py": "\"\"\"Cheap non-relaxation heuristics.\"\"\"\n\nfrom __future__ import annotations\n\nfrom ..task import facts_of\nfrom .base import Heuristic\n\n\nclass BlindHeuristic(Heuristic):\n \"\"\"0 in a goal state, otherwise the cheapest operator cost. Admissible.\n\n \"Cheapest\" includes zero-cost operators. Skipping them would make a single\n free action look like it costs something, and every PDDL 3 compilation\n adds free actions -- that overestimate cost A* its optimality.\n \"\"\"\n\n name = \"blind\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n self.min_cost = min((op.cost for op in task.operators), default=0)\n\n def __call__(self, state) -> float:\n return 0.0 if self.task.goal_reached(state) else float(self.min_cost)\n\n\nclass GoalCountHeuristic(Heuristic):\n \"\"\"Number of unsatisfied goal facts. Fast, informative, not admissible.\"\"\"\n\n name = \"goalcount\"\n\n def __call__(self, state) -> float:\n return float(len(self.task.goals - facts_of(state)))\n", "jupyddl/live.py": "\"\"\"A live search dashboard that runs in any terminal, with no dependencies.\n\n:class:`TerminalDashboard` is a :class:`~jupyddl.trace.SearchObserver` that\nrepaints a small block of the terminal while the planner works \u2014 sparklines for\nthe heuristic and the ``f`` frontier, a frontier gauge, live counters and a node\nrate. It is pure standard library (ANSI escapes and Unicode block characters),\nso watching a search never costs you a dependency::\n\n from jupyddl import build_task, solve_task\n from jupyddl.live import TerminalDashboard\n\n task = build_task(\"domain.pddl\", \"problem.pddl\")\n solve_task(task, \"astar\", \"lmcut\", observer=TerminalDashboard())\n\nOn a non-interactive stream it degrades to a periodic one-line progress report,\nso it is also safe to use in CI logs and notebooks.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nimport os\nimport re\nimport shutil\nimport sys\nimport time\n\nfrom .trace import SearchObserver\n\nSPARKS = \"\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\"\nGAUGE_FULL = \"\u2588\"\nGAUGE_EMPTY = \"\u2591\"\n\n# 256-colour ANSI approximations of the jupyddl palette slots.\n_ANSI = {\n \"blue\": \"\\x1b[38;5;33m\",\n \"orange\": \"\\x1b[38;5;208m\",\n \"aqua\": \"\\x1b[38;5;36m\",\n \"yellow\": \"\\x1b[38;5;178m\",\n \"green\": \"\\x1b[38;5;34m\",\n \"muted\": \"\\x1b[38;5;245m\",\n \"bold\": \"\\x1b[1m\",\n \"reset\": \"\\x1b[0m\",\n}\n\n__all__ = [\"TerminalDashboard\", \"sparkline\"]\n\n\ndef sparkline(values, width: int = 40) -> str:\n \"\"\"Render ``values`` as a Unicode sparkline of at most ``width`` cells.\"\"\"\n values = [v for v in values if v is not None and not math.isinf(v)]\n if not values:\n return \"\"\n if len(values) > width: # keep the shape, drop the resolution\n bucket = len(values) / width\n values = [values[min(len(values) - 1, int(i * bucket))] for i in range(width)]\n low, high = min(values), max(values)\n if high == low:\n return SPARKS[3] * len(values)\n span = high - low\n return \"\".join(SPARKS[min(7, int((v - low) / span * 7.999))] for v in values)\n\n\ndef _gauge(fraction: float, width: int = 16) -> str:\n fraction = min(1.0, max(0.0, fraction))\n filled = int(round(fraction * width))\n return GAUGE_FULL * filled + GAUGE_EMPTY * (width - filled)\n\n\ndef _fmt(value) -> str:\n if value is None:\n return \"\u2013\"\n value = int(value)\n if value >= 1_000_000:\n return f\"{value / 1_000_000:.1f}M\"\n if value >= 10_000:\n return f\"{value / 1000:.1f}k\"\n return f\"{value:,}\"\n\n\nclass TerminalDashboard(SearchObserver):\n \"\"\"Repaint a live view of the search in the terminal.\n\n ``interval`` throttles repaints (seconds); ``history`` bounds the sparkline\n buffers so a multi-million-node search still costs constant memory.\n \"\"\"\n\n def __init__(\n self,\n stream=None,\n interval: float = 0.08,\n history: int = 400,\n color: bool = True,\n ):\n self.stream = stream if stream is not None else sys.stderr\n self.interval = interval\n self.history = history\n self.interactive = bool(getattr(self.stream, \"isatty\", lambda: False)())\n self.color = color and self.interactive\n\n self._h: list = []\n self._f: list = []\n self._open: list = []\n self._peak_open = 1\n self._best_h = math.inf\n self._start = time.perf_counter()\n self._last_paint = 0.0\n self._last_line = 0.0\n self._lines = 0\n self._title = \"jupyddl\"\n self._latest = None\n self._bounds = 0\n\n # ------------------------------------------------------------- painting\n def _c(self, key: str, text: str) -> str:\n if not self.color:\n return text\n return f\"{_ANSI[key]}{text}{_ANSI['reset']}\"\n\n def _width(self) -> int:\n # The stream's own terminal, not stdout's: the dashboard usually writes\n # to stderr, and the two can differ.\n try:\n columns = os.get_terminal_size(self.stream.fileno()).columns\n except (AttributeError, OSError, ValueError):\n columns = shutil.get_terminal_size().columns\n return max(20, min(columns - 2, 100))\n\n def _clear(self) -> None:\n if self._lines and self.interactive:\n self.stream.write(f\"\\x1b[{self._lines}A\\x1b[0J\")\n\n def _render(self, final: bool = False) -> None:\n width = self._width()\n spark_width = max(16, width - 30)\n event = self._latest\n expanded = event.expanded if event else 0\n generated = event.generated if event else 0\n evaluated = event.evaluated if event else 0\n elapsed = max(1e-9, time.perf_counter() - self._start)\n rate = expanded / elapsed\n\n current_h = self._h[-1] if self._h else None\n current_f = self._f[-1] if self._f else None\n open_now = self._open[-1] if self._open else 0\n best_h = None if math.isinf(self._best_h) else self._best_h\n\n lines = [\n self._c(\"bold\", self._title),\n self._c(\"muted\", \"\u2500\" * width),\n \" h \"\n + self._c(\"aqua\", sparkline(self._h, spark_width))\n + self._c(\"muted\", f\" now {_num(current_h)} best {_num(best_h)}\"),\n \" f \"\n + self._c(\"blue\", sparkline(self._f, spark_width))\n + self._c(\"muted\", f\" now {_num(current_f)}\"),\n \" frontier \"\n + self._c(\"orange\", _gauge(open_now / max(1, self._peak_open), 18))\n + self._c(\"muted\", f\" {_fmt(open_now)} (peak {_fmt(self._peak_open)})\"),\n \" \"\n + self._c(\"bold\", _fmt(expanded))\n + self._c(\"muted\", \" expanded \")\n + self._c(\"bold\", _fmt(generated))\n + self._c(\"muted\", \" generated \")\n + self._c(\"bold\", _fmt(evaluated))\n + self._c(\"muted\", \" evaluated\"),\n self._c(\"muted\", f\" {_fmt(rate)} nodes/s \u00b7 {elapsed:.2f}s\")\n + (self._c(\"muted\", f\" \u00b7 {self._bounds} bounds\") if self._bounds else \"\"),\n ]\n # A line wider than the terminal wraps, and the cursor-up that erases\n # the previous frame then climbs too few rows, smearing every repaint.\n lines = [_clip(line, width + 1) for line in lines]\n self._clear()\n self.stream.write(\"\\n\".join(lines) + \"\\n\")\n self.stream.flush()\n self._lines = len(lines)\n\n def _maybe_paint(self) -> None:\n now = time.perf_counter()\n if now - self._last_paint < self.interval:\n return\n self._last_paint = now\n if self.interactive:\n self._render()\n else:\n # Non-tty: a single appended line, at most every two seconds.\n if now - self._start > 1 and now - self._last_line >= 2.0:\n event = self._latest\n if event is not None:\n self._last_line = now\n self.stream.write(\n f\" ... {_fmt(event.expanded)} expanded, \"\n f\"{_fmt(event.generated)} generated, \"\n f\"{now - self._start:.1f}s\\n\"\n )\n self.stream.flush()\n\n # -------------------------------------------------------- observer hooks\n def on_start(self, task, planner: str, heuristic: str = \"\") -> None:\n self._start = time.perf_counter()\n label = f\"{planner}/{heuristic}\" if heuristic else planner\n name = getattr(task, \"name\", \"\") or \"task\"\n facts = getattr(task, \"num_facts\", 0)\n operators = len(getattr(task, \"operators\", ()))\n self._title = (\n f\"jupyddl \u00b7 {label} \u00b7 {name} \"\n f\"({facts} facts, {operators} ground actions)\"\n )\n if self.interactive:\n self._render()\n\n def on_expand(\n self,\n state,\n g: float = 0.0,\n h: float = 0.0,\n f: float = 0.0,\n depth: int = 0,\n open_size: int = 0,\n stats=None,\n parent=None,\n action: str = \"\",\n ) -> None:\n self._latest = stats\n if h is not None and not math.isinf(h):\n self._h.append(h)\n self._best_h = min(self._best_h, h)\n self._f.append(f if f else g + (h or 0))\n self._open.append(open_size)\n self._peak_open = max(self._peak_open, open_size)\n for buffer in (self._h, self._f, self._open):\n if len(buffer) > self.history:\n del buffer[: len(buffer) - self.history]\n self._maybe_paint()\n\n def on_bound(self, threshold: float, iteration: int, stats=None) -> None:\n self._bounds += 1\n self._latest = stats or self._latest\n\n def on_finish(self, result) -> None:\n self._latest = getattr(result, \"stats\", None) or self._latest\n if self.interactive:\n self._render(final=True)\n elapsed = time.perf_counter() - self._start\n if result is not None and getattr(result, \"solved\", False):\n verdict = self._c(\n \"green\",\n f\" \u2714 solved \u00b7 cost {result.cost} \u00b7 \"\n f\"{result.plan_length} actions \u00b7 {elapsed:.2f}s\",\n )\n elif getattr(result, \"truncated\", False):\n # Not \"no plan\": the budget ran out before the search finished.\n verdict = self._c(\n \"orange\", f\" \u23f8 stopped by the budget, no plan yet \u00b7 {elapsed:.2f}s\"\n )\n else:\n verdict = self._c(\"orange\", f\" \u2718 no plan found \u00b7 {elapsed:.2f}s\")\n self.stream.write(verdict + \"\\n\")\n self.stream.flush()\n self._lines = 0\n\n\n_ESCAPE = re.compile(r\"\\x1b\\[[0-9;]*m\")\n\n\ndef _clip(line: str, width: int) -> str:\n \"\"\"Cut ``line`` to ``width`` visible characters, keeping colour codes intact.\"\"\"\n out, visible, pos = [], 0, 0\n for match in _ESCAPE.finditer(line):\n text = line[pos : match.start()]\n if visible + len(text) > width:\n out.append(text[: width - visible])\n return \"\".join(out) + \"\\x1b[0m\"\n out.append(text)\n visible += len(text)\n out.append(match.group())\n pos = match.end()\n tail = line[pos:]\n if visible + len(tail) > width:\n return \"\".join(out) + tail[: width - visible] + (\"\\x1b[0m\" if out else \"\")\n return \"\".join(out) + tail\n\n\ndef _num(value) -> str:\n if value is None:\n return \"\u2013\"\n if isinstance(value, float):\n if math.isinf(value):\n return \"\u221e\"\n if value.is_integer():\n return str(int(value))\n return f\"{value:.1f}\"\n return str(value)\n", "jupyddl/parser/__init__.py": "\"\"\"PDDL parsing: tokenizer, AST and recursive-descent parser.\"\"\"\n\nfrom .ast import (\n Action,\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n Conjunct,\n ConjunctiveEffect,\n DelEffect,\n DerivedPredicate,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n Function,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Predicate,\n Problem,\n TimedEffect,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\nfrom .parser import (\n parse,\n parse_condition,\n parse_domain,\n parse_domain_file,\n parse_effect,\n parse_problem,\n parse_problem_file,\n)\nfrom .tokenizer import tokenize\n\n__all__ = [\n \"Action\",\n \"AddEffect\",\n \"And\",\n \"Arithmetic\",\n \"Atom\",\n \"Comparison\",\n \"Conjunct\",\n \"ConjunctiveEffect\",\n \"DelEffect\",\n \"DerivedPredicate\",\n \"Domain\",\n \"EqualityConstraint\",\n \"Exists\",\n \"Forall\",\n \"ForallEffect\",\n \"FluentRef\",\n \"Function\",\n \"IncreaseCostEffect\",\n \"Literal\",\n \"Number\",\n \"NumericEffect\",\n \"Or\",\n \"PDDLError\",\n \"Predicate\",\n \"Problem\",\n \"TimedEffect\",\n \"Truth\",\n \"UnsupportedFeatureError\",\n \"WhenEffect\",\n \"parse\",\n \"parse_condition\",\n \"parse_domain\",\n \"parse_effect\",\n \"parse_problem\",\n \"parse_domain_file\",\n \"parse_problem_file\",\n \"tokenize\",\n]\n", "jupyddl/parser/ast.py": "\"\"\"Structured AST for PDDL.\n\nConditions are stored as a **formula tree in negation normal form**: the parser\npushes every ``not`` down to the atoms and rewrites ``imply``, so the grounder\nonly ever sees negation applied to a literal. Quantifiers stay in the tree\nbecause expanding them needs the object pool, which only exists at grounding\ntime; the grounder then distributes the formula into DNF and emits one operator\nper disjunct.\n\nNumeric fluents and durative actions have their own small node types. See\n:mod:`jupyddl.requirements` for exactly which PDDL requirement flags are\nsupported and how.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\nclass PDDLError(Exception):\n \"\"\"Base class for all parsing / modelling errors.\"\"\"\n\n\nclass UnsupportedFeatureError(PDDLError):\n \"\"\"Raised when a PDDL construct outside the supported subset is used.\"\"\"\n\n\n@dataclass(frozen=True)\nclass Atom:\n \"\"\"A (possibly lifted) predicate application, e.g. ``(on ?x ?y)``.\n\n ``args`` holds terms as raw strings: variables keep their leading ``?``\n while constants/objects are stored verbatim.\n \"\"\"\n\n predicate: str\n args: tuple = ()\n\n def __str__(self) -> str:\n if not self.args:\n return f\"({self.predicate})\"\n return f\"({self.predicate} {' '.join(self.args)})\"\n\n\n@dataclass(frozen=True)\nclass Literal:\n \"\"\"A positive or negative atom used in preconditions and goals.\"\"\"\n\n atom: Atom\n positive: bool = True\n\n\n@dataclass(frozen=True)\nclass EqualityConstraint:\n \"\"\"An ``(= a b)`` (or its negation) constraint over terms.\"\"\"\n\n left: str\n right: str\n positive: bool = True\n\n\n# --- numeric expressions -----------------------------------------------------\n\n\n@dataclass(frozen=True)\nclass Number:\n \"\"\"A numeric literal.\"\"\"\n\n value: float\n\n\n@dataclass(frozen=True)\nclass FluentRef:\n \"\"\"A reference to a numeric fluent, e.g. ``(fuel ?truck)``.\"\"\"\n\n name: str\n args: tuple = ()\n\n def __str__(self) -> str:\n if not self.args:\n return f\"({self.name})\"\n return f\"({self.name} {' '.join(self.args)})\"\n\n\n@dataclass(frozen=True)\nclass Arithmetic:\n \"\"\"A binary arithmetic expression (``+``, ``-``, ``*``, ``/``).\n\n Unary minus is parsed as ``(- 0 x)``.\n \"\"\"\n\n op: str\n left: object\n right: object\n\n\n@dataclass(frozen=True)\nclass Comparison:\n \"\"\"A numeric comparison used in preconditions and goals.\"\"\"\n\n op: str # one of < <= = >= >\n left: object\n right: object\n\n\n# --- condition formulas (negation normal form) -------------------------------\n\n\n@dataclass(frozen=True)\nclass Truth:\n \"\"\"The constant ``true`` \u2014 what an empty ``(and)`` parses to.\"\"\"\n\n value: bool = True\n\n\n@dataclass(frozen=True)\nclass And:\n parts: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Or:\n parts: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Exists:\n params: tuple = () # ((var, type), ...)\n body: object = None\n\n\n@dataclass(frozen=True)\nclass Forall:\n params: tuple = () # ((var, type), ...)\n body: object = None\n\n\n@dataclass\nclass Conjunct:\n \"\"\"One ground DNF disjunct: a conjunction of literals and comparisons.\n\n Produced by the grounder, not by the parser. ``equalities`` are resolved\n during instantiation and never survive into a grounded operator.\n \"\"\"\n\n literals: list = field(default_factory=list)\n equalities: list = field(default_factory=list)\n comparisons: list = field(default_factory=list)\n\n\n# --- effects -----------------------------------------------------------------\n\n\n@dataclass\nclass AddEffect:\n atom: Atom\n\n\n@dataclass\nclass DelEffect:\n atom: Atom\n\n\n@dataclass\nclass IncreaseCostEffect:\n \"\"\"``(increase (total-cost) k)`` \u2014 the classical action-cost shorthand.\n\n ``amount`` is a float, or a numeric expression over *static* functions such\n as ``(road-length ?from ?to)``, which the grounder evaluates from ``:init``.\n \"\"\"\n\n amount: object\n\n\n@dataclass\nclass NumericEffect:\n \"\"\"An assignment to a numeric fluent.\n\n ``op`` is one of ``assign``, ``increase``, ``decrease``, ``scale-up`` or\n ``scale-down``.\n \"\"\"\n\n op: str\n target: FluentRef\n value: object # an arithmetic expression\n\n\n@dataclass\nclass ConjunctiveEffect:\n parts: list = field(default_factory=list)\n\n\n@dataclass\nclass ForallEffect:\n params: list # [(variable, type), ...]\n body: object\n\n\n@dataclass\nclass WhenEffect:\n condition: object # a condition formula\n body: object\n\n\n@dataclass\nclass TimedEffect:\n \"\"\"A durative action's effects, split by when they happen.\n\n ``start`` applies first and ``end`` after it, so an at-end effect overrides\n a conflicting at-start one. The grounder folds the two into one operator;\n see :mod:`jupyddl.requirements` for exactly how.\n \"\"\"\n\n start: object\n end: object\n\n\n# --- domain / problem --------------------------------------------------------\n\n\n@dataclass\nclass Predicate:\n name: str\n params: list # [(variable, type), ...]\n\n\n@dataclass\nclass Function:\n \"\"\"A declared numeric function (``:functions``).\"\"\"\n\n name: str\n params: list = field(default_factory=list)\n\n\n@dataclass\nclass Action:\n name: str\n parameters: list # [(variable, type), ...]\n precondition: object # a condition formula\n effect: object # one of the *Effect nodes above\n # Set when the action came from a (:durative-action ...) block; the value is\n # its duration, and the compilation is documented in jupyddl.requirements.\n # ``precondition`` then holds the at-start conditions, ``effect`` a\n # TimedEffect, and ``late_condition`` the over-all and at-end conditions,\n # which are read in the state *after* the at-start effects.\n duration: object = None\n late_condition: object = None\n\n\n@dataclass\nclass DerivedPredicate:\n \"\"\"A ``(:derived (head ?x) body)`` axiom.\"\"\"\n\n head: Atom\n params: list # [(variable, type), ...]\n body: object # a condition formula\n\n\n@dataclass\nclass Domain:\n name: str\n requirements: list\n types: dict # child type -> parent type (\"object\" if none)\n constants: list # [(name, type), ...]\n predicates: list\n actions: list\n functions: list = field(default_factory=list)\n derived: list = field(default_factory=list)\n object_fluents: list = field(default_factory=list)\n constraints: list = field(default_factory=list)\n\n @property\n def has_durative_actions(self) -> bool:\n return any(action.duration is not None for action in self.actions)\n\n\n@dataclass\nclass Problem:\n name: str\n domain_name: str\n objects: list # [(name, type), ...]\n init: list # [Atom, ...]\n goal: object # a condition formula\n metric_minimize_cost: bool = False\n init_numeric: dict = field(default_factory=dict) # FluentRef -> float\n metric: object = None # (direction, expression) or None\n preferences: list = field(default_factory=list) # [Preference, ...]\n constraints: list = field(default_factory=list) # [Constraint | Preference]\n timed_initials: list = field(default_factory=list) # [TimedInitial, ...]\n init_objects: dict = field(default_factory=dict) # object-fluent assignments\n violation_weights: dict = field(default_factory=dict) # preference -> weight\n\n\n# --- PDDL 3: preferences and trajectory constraints --------------------------\n\n\n@dataclass(frozen=True)\nclass Preference:\n \"\"\"A named soft goal or soft constraint.\n\n ``body`` is either a condition formula (a goal preference) or a\n :class:`Constraint` (a soft trajectory constraint). Violating it is legal;\n the ``:metric`` says what it costs.\n \"\"\"\n\n name: str\n body: object\n # ((var, type), ...) when the preference sat under a ``forall``: it then\n # stands for one preference per binding, all sharing the name.\n params: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Constraint:\n \"\"\"One state-trajectory constraint from a ``(:constraints ...)`` block.\n\n ``kind`` is the PDDL modal operator (``always``, ``sometime``, ``at-end``,\n ``at-most-once``, ``sometime-before``, ``sometime-after``) and ``args`` holds\n its operand formulas, already in negation normal form. ``params`` holds the\n variables of any enclosing ``forall``: the constraint stands for one copy\n per binding, which is not the same as quantifying inside it \u2014\n ``(forall (?x) (sometime (p ?x)))`` lets each ``?x`` have its own moment.\n \"\"\"\n\n kind: str\n args: tuple = ()\n params: tuple = ()\n\n\n@dataclass(frozen=True)\nclass TimedInitial:\n \"\"\"A ``(at