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
--searchgrammar. 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
--searchoptions, 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
cplexfeature. planforge-sgd- The gradient plan-synthesis engine and its transcription of a task.
Optional. See the
sgdfeature. 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.
| Job | What it enforces |
|---|---|
| rustfmt | cargo fmt --all --check. Formatting is not a review topic. |
| clippy | The whole workspace, all targets, no warnings. The backlog was cleared to zero without adding suppressions, so this job means "no new lints". |
| test | The suite above, in a debug build. |
| release tests | The cases that only make sense optimised. These are the instances whose search takes minutes unoptimised and seconds optimised. |
| optional features | Compiles 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 backends | Inspects 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.