mrlab-ai/PlanForge

PlanForge

PlanForge is a numeric planner. It reads a planning task written in PDDL, grounds it, and searches for a sequential plan.

The task may use numeric fluents. The numbers stay in the task. They are handled by the search and by most of the heuristics rather than compiled away.

PlanForge is written in Rust and is developed by MRlab, the Machine Reasoning Lab at Linköping University. It started as a Rust port of Numeric Fast Downward, which is a fork of Fast Downward. The credits page describes that lineage and the licence terms.

Contact

There is no mailing list. Please use the issue tracker if at all possible, because an answer there stays visible to the next person with the same question.

For anything that does not fit an issue, the contact people are:

A bug report is easiest to act on when it includes the domain file, the problem file, the exact --search string and the output.

How it works

PlanForge runs in two steps. This is the same division of labour that Fast Downward uses.

The first step is translation. It reads a domain file and a problem file, rewrites the constructs that grounding cannot take directly, computes invariants to find finite-domain variables, and grounds the task. The result is a SAS+ task.

The second step is search. It searches the SAS+ task with one of four search algorithms and one of about twenty heuristics. You choose the combination with the --search option. Everything on the search options page is reachable from the one planforge program.

Both steps happen in one process. No SAS+ file is written on the way to the search. A second program, planforge-translator, writes the file when the file itself is what you want.

Version and scope

The current version is 0.1.0.

Numeric conditions and numeric effects are supported. Derived predicates are supported. Durative actions, preferences and soft goals are not supported at all.

Quantifiers and disjunction are supported wherever they appear, which together with conditional effects makes the accepted fragment ADL plus numeric fluents plus axioms. A domain declaring a requirement the translator does not implement is refused by name. See PDDL support.

Building

You need a stable Rust toolchain, version 1.85 or newer. The crates use edition 2024, which is why that is the floor. Nothing else is required. The build does not use a nightly compiler, a C++ library or Python.

git clone https://github.com/mrlab-ai/PlanForge.git
cd PlanForge
cargo build --release

That produces two programs under target/release. planforge is the planner. planforge-translator stops after translation and writes the SAS+ file.

Two optional features are off by default and pull in nothing when they are off. They are described under optional build features.

Solving a task

The repository ships small PDDL tasks under tests/assets, so you can solve something straight after the build. The task used below is a delivery task with two robots and numeric fluents.

./target/release/planforge \
    --search 'astar(lmcutnumeric())' \
    tests/assets/numeric-pddl-files/delivery/domain.pddl \
    tests/assets/numeric-pddl-files/delivery/pfile1.pddl

Quote the value of --search. The parentheses mean something to the shell.

The run prints the plan, then a block of statistics. It also writes the plan to sas_plan in the working directory. Each line of that file is one grounded action in parentheses.

(pick item2 rooma right1 bot1)
(pick item3 rooma left2 bot2)
(pick item1 rooma left1 bot1)
(move bot1 rooma roomc)
(drop item1 roomc left1 bot1)
(pick item4 rooma right2 bot2)
(move bot2 rooma roomb)
(drop item3 roomb left2 bot2)
(drop item4 roomb right2 bot2)
(drop item2 roomc right1 bot1)

The statistics block has the same shape as the one Fast Downward prints. Output and exit codes lists its lines and says what a script should test for.

Searching for a cheapest plan

A* returns a cheapest plan when its heuristic is admissible. A heuristic is admissible if it never estimates the remaining cost as higher than the true remaining cost. Every heuristic in the table below is admissible, so every row returns a cheapest plan.

What changes from row to row is how much search that takes. The rows are real runs of the command above with only --search changed.

A* with four admissible heuristics on tests/assets/numeric-pddl-files/delivery/pfile1.pddl. Default build, one run per row.
--search Plan cost Expanded Evaluated Search time
astar(blind())2235 24241 1750.13 s
astar(domain_abstraction())226 65716 2410.04 s
astar(lmcutnumeric())22735930.03 s
astar_fs(fast=lmcutnumeric(), slow=domain_abstraction())22735930.03 s

The plan cost is 22 in every row. That is not a coincidence of this instance. Each of these configurations is guaranteed to return a cheapest plan, so they have to agree on the cost.

The expansion counts are deterministic. Run a row again and you get the same number.

Search time is not deterministic. The numbers above are one measurement on one machine, and they are in the table only to show the order of magnitude.

Searching for any plan

Greedy best-first search with the FF heuristic is usually much faster. It gives up the guarantee, and it gives it up twice over.

Greedy best-first search orders its open list on h alone. It ignores the cost already paid, so it is not optimal even with an admissible heuristic. The FF heuristic is not admissible, so it can estimate a remaining cost that is higher than the true one. Either of those alone is enough to remove the guarantee.

So the cost that gbfs(ff()) returns is an upper bound on the cheapest cost, and nothing more. Do not read it as a plan cost you can compare with the table above.

Two fixtures under gbfs(ff()), next to the cheapest cost for the same instance. The cheapest cost was obtained separately with astar(lmcutnumeric()).
Instance gbfs(ff()) cost Expanded Cheapest cost Expanded by A*
delivery/pfile1.pddl22112273
expedition/pfile1.pddl542 786263 583

On delivery the returned cost happens to be the cheapest one, after 11 expansions instead of 73. That is the trap. A satisficing run that gets lucky looks exactly like an optimal one.

On expedition the same configuration returns 54 where the cheapest plan costs 26. The plan is more than twice as expensive as it needs to be. It also expanded 2 786 states against A*'s 3 583, so it did not save much search either.

Neither result is a defect. An inadmissible heuristic under a greedy search is allowed to produce both of them. The guarantee is the only thing that tells you which of the two you are holding.

Running a pre-translated task

Translation is repeated on every run. When you want to search the same task many times, translate it once to a SAS+ file and give the planner that file instead.

./target/release/planforge-translator translate \
    tests/assets/numeric-pddl-files/delivery/domain.pddl \
    tests/assets/numeric-pddl-files/delivery/pfile1.pddl \
    --output delivery.sas

./target/release/planforge --search 'astar(lmcutnumeric())' delivery.sas

One positional argument is read as a SAS+ file. Two are read as a domain file and a problem file.

Both routes reach the same task. Each reports 49 finite-domain variables and 12 numeric variables, expands the same 73 states, and returns the same plan of cost 22.

Time and memory limits

--max-time and --max-memory are enforced by the planner itself. A run that exceeds one of them stops, prints a report and exits with a code that says which limit it hit. It is not killed from outside, so you get a result rather than a truncated log.

./target/release/planforge --max-time 30m --max-memory 8G \
    --search 'astar(lmcutnumeric())' domain.pddl problem.pddl

The command line gives the accepted size and duration syntax.

Where to go next