jupyddl is a pure-Python PDDL planning framework (parser, grounder,
planners, heuristics, instrumentation, visualisation, benchmarking). The
Julia/PDDL.jl/PyCall integration has been removed — there is no Julia, no
native build step, and the core has zero runtime dependencies.
- Python ≥ 3.9; a
.venv(created withuv, Python 3.12) with an editable install:uv pip install -e ".[dev]"(addvizfor the matplotlib charts, animations and benchmark dashboards)..venv,web/vendor/andpddl-examples/contents are git-ignored. - The
pddl-examplesgit submodule supplies the small domains/problems the parser and grounder tests use; it must be initialised (git submodule update --init). The larger instances indemos/live in this repository and are always available.
- Tests:
.venv/bin/python -m pytest(add--cov=jupyddl). - Lint (as CI):
flake8 jupyddl tests tools(config in.flake8, max-line 100). Format withblack jupyddl tests tools. - CLI:
.venv/bin/python -m jupyddl.cli solve <domain> <problem> -s astar -H lmcutor... benchmark demos --dashboard out.png. Installed asjupyddltoo. Alsojupyddl animate(MP4/GIF replay) andjupyddl demo(full chart gallery).
tests— lint (flake8 jupyddl tests tools) and the suite on Python 3.9–3.14. Most of the matrix installs.[dev]without matplotlib, which is what nearly everyone installs and keeps the core honest about not importing it; one entry installs.[dev,viz]sojupyddl/viz/is executed rather than skipped.tests/test_viz.pyandtests/test_cli_viz.pyimportorskipmatplotlib — 30 tests with it, 4 without — so that one entry is the only thing standing between the charting surface and no coverage at all.build— builds the sdist and wheel,twine checks them, installs the wheel into a clean venv and plans with it from outside the repository. It deliberately does not repeat the lint: it is there to catch packaging breakage (a module missing from the wheel, an entry point that does not resolve), which an editable install hides.format— runsblackon a push to main and commits the result. It rebuildsweb/distin the same commit; reformatting a bundled source and committing without the rebuild would leave main in a state wherepagesrefuses to deploy.release— fires on av*tag. Builds, refuses a tag that disagrees withpyproject.tomlor a__version__that disagrees with either, runstwine 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.mdis the runbook, including the one-time PyPI trusted-publisher setup only a maintainer can do. Bump the version in two places —pyproject.tomlandjupyddl/__init__.py— and rebuildweb/dist, which carries it too.pages— bundles, refuses to deploy a staleweb/dist, then deploys. Its job is calledbundle, notbuild, because.mergify.ymlkeys a merge rule on a check namedbuildand that has to mean the packaging workflow.
jupyddl/requirements.py— the source of truth for what every PDDL requirement flag does here. Change support for a feature here first; the parser, the CLI, the README table and the web UI all read it.jupyddl/compile.py— PDDL 3 (preferences, trajectory constraints, timed initial literals, object fluents) rewritten into the classical core before grounding. Add front-end features here as source-to-source transformations, not as special cases in the grounder or the search.jupyddl/learn/— learned heuristics. Stdlib-only like the core; NumPy is a speed option and the pure-Python path must keep working. Two stages:train.pyimitatesh*from solved plans,rl.pyoptimises search cost directly. Nothing in the core imports it —jupyddl.heuristicsresolveslearned:<path>lazily inside the loader, so a planner that never asks for one never pays for it.jupyddl/generator.py— reproducible instance generators.jupyddl/trace.py— search observers, events,SearchTrace(JSON).jupyddl/live.py— the terminal dashboard; stdlib only, keep it that way, it is what makes "watch a search" free of dependencies.jupyddl/viz/— everything that imports matplotlib. Nothing in the core may import this package.web/— the Pyodide playground;tools/build_web.pybundles the package sources and demos intoweb/dist(committed). It also writescapabilities.json(the registries) andresearch.json(distilled frompromo/rl-data.json, so the page and the RL video quote one measured run). Those two are rendered before Pyodide loads — the app shell is never hidden, and only the run controls are gated onstate.ready— so a stale bundle briefly states something untrue rather than merely lagging.tests/test_web_bundle.pypins both.tools/make_promo.py— renders the main promo video from measured runs.tools/make_learn_promo.py— the learned-heuristic/RL video. It re-measures everything including both failure modes, so it cannot drift from.docs/;promo/rl-data.jsoncaches the pass, delete it to re-measure.
Conditions are a formula tree in negation normal form: parse_condition
pushes every not down to the literals and rewrites imply, so nothing
downstream sees a negated compound. Quantifiers survive parsing because
expanding them needs the object pool; grounding._dnf expands them and
distributes to DNF, and each disjunct becomes its own operator (named
action(args)#N). Operator.base_name strips that tag for display.
Everything compile.py introduces is named with a leading __, and grounding
collects those into Task.synthetic so Task.visible_plan can hide them. Two
traps worth knowing:
- Synthetic actions must declare a zero cost. Grounding charges 1 for any action with no cost effect, which silently inflates the metric — closing a plan or observing a constraint is bookkeeping, not work.
- Forced vs optional monitors.
sometimecan use an action the planner applies when convenient;at-most-onceandsometime-aftercannot, because the planner would simply decline to notice. Those ride on conditional effects added to every domain action.
Task carries three layers that are inert unless the domain uses them, and the
planners must go through the task rather than the operator to honour them:
- axioms —
Task.apply()re-closes derived predicates after every step, andTask.initial_state()closes the initial state. A planner that callsop.apply(state)directly will silently skip this. - numeric fluents — the state becomes a
State(facts, values)instead of a frozenset. Anything doingset(state)still works (Stateis iterable); usefacts_of(state)when you need the fact set specifically. - durations —
op.durationplusTask.makespan(plan). When timed initial literals introduced a clock,makespanreplays it instead of summing durations: waiting advances time without any action taking that long.
- Instrumentation must stay transparent. Planners only touch the observer
behind
if observer is not None, so the default path keeps its zero-overhead behaviour.tests/test_trace.pyasserts that an observed search returns the same plan and statistics as an unobserved one — do not break that. web/distis generated and committed. After changing anything underjupyddl/(including ablackreformat) runpython tools/build_web.pyand commit the result, ortests/test_web_bundle.pyand the Pages workflow fail. The bundle deliberately excludesjupyddl/viz/(matplotlib is not loaded in the browser). The builder must stay byte-reproducible: it sorts both the directory walk and the JSON keys, because an unsortedos.walkbundles the same sources in a disk-dependent order and the staleness check then fails on a bundle that is not stale.- The playground's Python lives in
web/bootstrap.py, fetched at runtime rather than embedded inworker.js. Do not inline it back into a JS template literal: reStructuredText double-backticks in a docstring terminate the template and break the worker. - Example data quirks (external submodule, do not "fix" in this repo):
griduses numeric fluents and is intentionally unsupported (raisesUnsupportedFeatureError);vehiclehas typos in its problem file (struck/truck,acessible) so its goal is unreachable and it is correctly reported unsolvable. Tests treat both as expected. - Conditional effects (
flip,elevator): the delete-relaxation heuristics (hadd,hff,lmcut,h^m) are not guaranteed admissible on domains with conditional effects because each conditional effect is relaxed into its own operator. For guaranteed-optimal plans there usebfs,dijkstra, orastar/idastarwith theblindheuristic. Optimality tests use these. - Matplotlib is optional: only
jupyddl.vizandjupyddl.benchmark.plot_summaryneed it; run headless withMPLBACKEND=Aggif no display (jupyddl.vizalready forces the Agg backend). - Budgets are cooperative. Python cannot safely interrupt a running search,
so planners poll a
Budgetin their main loop. If you add a planner with an unbounded loop, poll it —bnbandiwwill otherwise run for hours. A run stopped by a budget setsstats.truncated; never report such a run as unsolvable. - The clock is checked every expansion (
Budget.check_every=1). That looks wasteful but is not: one LM-cut expansion can cost tens of milliseconds, and a coarser interval overshoots a short--time-limitenormously.
- Features are keyed on the predicate symbol, never the ground atom, and
normalised per symbol. That is what makes the vector the same length for 4
blocks and 40. Getting
predicate_ofwrong does not raise — it gives every ground atom its own slot and silently destroys transfer, sotests/test_learn.pypins both spellings ((on a b)andmove(a,b)). - The default objective is ranking, not regression. GBFS reads the order
a heuristic imposes, never its values; a model uniformly 30 too high guides
perfectly. Checkpoint selection is on top-1 accuracy for the same reason. The
regression term is kept at a small weight only to anchor a scale, which a
pure ranking loss leaves undefined and
wastarneeds. - Report the distribution, not just the mean. The held-out blocksworld set
has a heavy tail: nine of ten instances land between 58 and 227 expansions and
the tenth is worth thousands, so the mean is close to a report of that one
instance. Two published claims here were wrong because of it — see the
correction in
.docs/rl-for-search.md, which also records the more embarrassing cause: two settings changed in one edit and the improvement was credited to the wrong one. - Two things decide whether the RL stage does anything. It must start from
the imitation solution (search cost is flat over every parameter vector that
solves nothing), and it must tune on instances with headroom — on the
training ladder the heuristic already expands about as many nodes as the plan
is long, so every perturbation scores the same. Measured: tuning on the
training sizes moved the score 12.83 → 12.75; tuning a rung higher moved it
1605 → 64.
learn_heuristicdefaultscem_sizesabove the training ladder. - A learned heuristic is never admissible and must not be used to claim an optimal plan. The suite asserts plans stay valid, which is the invariant that does hold.
- Extend via the registries:
jupyddl.search.PLANNERS,jupyddl.heuristics.HEURISTICSandjupyddl.generator.GENERATORS. The CLI, benchmark harness and web workbench all read from them, so a new entry shows up everywhere for free.