mrlab-ai/PlanForge

Development

PlanForge is a Cargo workspace of small crates on stable Rust. There is no build system beyond Cargo, no code generation step and no vendored C++.

Everything on this page runs from a fresh clone.

Building

cargo build --release                    # both binaries
cargo build --release --bin planforge    # just the planner

The release profile turns on link-time optimisation and uses a single codegen unit. A full optimised build is therefore about a minute of compiling rather than a few seconds.

There is a second profile called experiment. It keeps the optimiser but drops link-time optimisation, which matters when you are relinking after every one-line change.

cargo build --profile experiment

Workspace layout

planforge
Both binaries, plus the command line, the reporting allocator, the resource limits, the portfolio and the plan output. This is the only crate that knows about processes and files.
planforge-searcher
The --search grammar. Which algorithm, with which heuristics. Only the shape of a configuration lives here, which is why adding a heuristic never touches this crate.
planforge-search
The search algorithms, the heuristics and the abstractions. This is where almost all of the planner is.
planforge-translate
The PDDL reader, the grounder, the invariant analysis and the SAS+ writer.
planforge-sas
The task representation and the state registry that everything else is written against.
planforge-config-derive
The derive macro that turns a configuration struct into an option parser. It is a separate crate because a procedural macro has to be its own compilation unit. It serves over a hundred --search options, and it takes each option's positional order from the order of the struct fields.
planforge-cplex
A checked wrapper over the native CPLEX interface and its sparse linear program. Optional. See the cplex feature.
planforge-sgd
The gradient plan-synthesis engine and its transcription of a task. Optional. See the sgd feature.
planforge-py
Python bindings.
tests
The integration test suite, described below.
tutorials/rust-goal-count
A worked example of writing a heuristic against the crates above.

Adding a heuristic is a change to one crate. You add a module under planforge-search/src/evaluation/ and one arm to build_heuristic_from_spec in planforge-search/src/heuristic_factory.rs. The --search parser does not need to learn the name, and neither does the command line.

Tests

cargo test --workspace --exclude planforge-cplex

planforge-cplex is excluded because it links against a proprietary CPLEX installation, which is not present on a normal machine or in continuous integration.

The correctness gate is not a set of smoke tests. The tests crate pins the cheapest plan cost and the plan length of every fixture under tests/assets/.

The tables of expected values are compared against the fixtures actually on disk, as sets. That has two consequences. A planner regression that changes a plan cost fails the suite. Adding a fixture without pinning its cost also fails the suite, so the corpus cannot be widened without saying what the right answer is.

Beyond plan costs, the suite covers translator output, preprocessor invariants, deduplication in the state registry, and heuristic admissibility.

Continuous integration

One workflow, .github/workflows/ci.yml, runs on every push to main and dev and on every pull request. Warnings are promoted to errors.

The six jobs.
JobWhat it enforces
rustfmtcargo fmt --all --check. Formatting is not a review topic.
clippyThe whole workspace, all targets, no warnings. The backlog was cleared to zero without adding suppressions, so this job means "no new lints".
testThe suite above, in a debug build.
release testsThe cases that only make sense optimised. These are the instances whose search takes minutes unoptimised and seconds optimised.
optional featuresCompiles and tests the sgd feature. Nothing else in continuous integration would build it, so without this job the optional code would rot unnoticed.
default build excludes optional backendsInspects the build graph to prove that a default build does not compile the automatic differentiation backend. The manifest is not accepted as evidence, because the dependency appears in Cargo.lock either way. Only the build graph settles it.

The local check script

tools/gate.sh is the counterpart to run before you commit. It has tiers, because the checks answer different questions and the expensive ones are wasted on a change that cannot alter behaviour.

tools/gate.sh fast [crate...]   # does the crate I touched still work?
tools/gate.sh semantic          # did plan costs or expansion counts move?
tools/gate.sh full              # the same, across the whole benchmark set
tools/gate.sh timing "astar(lmcutnumeric())"
tools/gate.sh all

Two things about it are worth knowing. Both were learned the hard way.

Costs and expansions are the real gate

semantic and full compare plan costs and expansion counts against a saved baseline binary, and they ignore the clock on purpose.

A cost and an expansion count are semantics. No optimisation level can change them. So identical counts are real evidence, where a timing comparison on the same data would be noise.

The timing tier needs a configuration that reaches your change

timing defaults to the default search configuration, which is astar(blind()). That configuration touches no heuristic code at all.

So if your change is in a heuristic, an abstraction, LM-cut or the numeric bounds, the default tier exercises none of it, and any difference it reports is machine drift. Pass a configuration that reaches your change.

The script prints a warning when the configuration contains blind. It also interleaves the two binaries in ABBA order, so that drift cancels out instead of being reported as a speed-up.

Contributing

Bug reports and patches are welcome through the issue tracker and as pull requests on GitHub. For a question that does not fit an issue, the credits page says who to write to.

One convention is worth knowing before you start. An impossible state fails fast. The codebase prefers an assertion, a panic or a typed error over a conservative fallback.

Validate external input and return a clear error for it. For a violated internal invariant, stop. Substituting a default for a missing value, or skipping data that does not parse, is the pattern most likely to be sent back in review.