Introduction
ferroplan is a fast, data-parallel PDDL planner in Rust — a from-scratch reimplementation of the FF planner family, and a deterministic planning core for the age of AI.
The bet behind it: an LLM should be the author and supervisor of a planner, not its runtime. You don't ask a model to add a column of numbers — you have it emit code that does the arithmetic deterministically and for free; the same applies one level up. Rather than running a whole village of agents' decisions through an LLM every tick — expensive, non-reproducible, unbounded — the model authors a PDDL domain that plans deterministically, cheaply, inspectably, and at scale, and only nudges it at runtime. PDDL is the auditable interface between intent, the model's authoring, and a fast solver. You can read a domain and an axiom; you can't read a model's weights.
It combines:
- a delete-relaxation FF heuristic over a data-oriented task representation (bitset states, structure-of-arrays / CSR operator tables);
- data parallelism — parallel grounding and parallel batch heuristic evaluation, with bit-for-bit identical plans regardless of thread count;
- ADL (conditional effects,
forall/exists, equality) and numeric fluents; - derived predicates / axioms (
:derived, static/stratified); - PDDL3 preferences with anytime branch-and-bound metric optimization,
and PDDL3 trajectory constraints (
(:constraints ...)) — the six untimed modal operators enforced via monitor-automaton compilation, hard and soft alike; - PDDL2.1 temporal planning — durative actions with constant or parameter-dependent durations and required concurrency (see Temporal planning);
- an optional SGPlan-style partition-and-resolve mode.
It is offered as a Rust library (with a structured, JSON-serializable API)
and the ff command-line binary, a drop-in for Metric-FF.
Acknowledgments
ferroplan owes an enormous debt to the planners it learns from. Above all SGPlan (Chih-Wei Hsu and Benjamin W. Wah, University of Illinois), which has set the standard in satisficing planning with preferences and temporal/resource constraints for nearly two decades — coming even close to it on a slice of the benchmarks is genuinely an honor, and a tribute to the depth and durability of that team's research. And to Jörg Hoffmann's FF / Metric-FF, whose relaxed-plan heuristic and enforced hill-climbing are this engine's backbone, and to VAL (Derek Long & Maria Fox) for independent temporal-plan validation.
Try it in your browser
ferroplan compiles to WebAssembly and runs entirely client-side — no server, no install, nothing leaves your machine.
▶ Open the live planner demo
Pick a built-in example from the dropdown — gripper, numeric resources, ADL,
derived axioms, PDDL3 preferences, temporal/durative, logistics, a job shop, or one
of the RPG-world scenarios (including a border example that shows where a
monolithic goal stops solving in one shot and must be decomposed) — or paste your
own PDDL domain + problem.
Choose a mode (auto routes by problem features), hit Plan, and the plan is
computed in your browser by the same Rust planner core compiled to WASM. It runs the
gripper example on load so you see a plan immediately.
The visual GUI, in your browser
The full Bevy GUI — graph visualizer, plan animation, and the color-coded block editor — also runs in the browser (it's a larger download; give it a moment).
▶ Open the visualizer & block editor
Keys: E toggles the editor, Tab switches problem/domain, S solves, Space plays the plan; drag nodes, scroll to zoom, click to inspect.
For everything-else (the CLI, the library, install), see the rest of the docs.
Install & quick start
git clone https://github.com/hhh42/ferroplan
cd ferroplan
cargo build --release # -> target/release/ff
Solve a problem:
./target/release/ff -o domain.pddl -f problem.pddl
Or from code — add ferroplan as a path/git dependency and:
#![allow(unused)] fn main() { let domain = std::fs::read_to_string("domain.pddl").unwrap(); let problem = std::fs::read_to_string("problem.pddl").unwrap(); let sol = ferroplan::solve(&domain, &problem, &ferroplan::Options::default()).unwrap(); println!("{:?}", sol.plan.map(|p| p.length)); }
Command line (ff)
The ff binary is a drop-in for Metric-FF's ff -o domain -f problem.
| flag | meaning |
|---|---|
-o, --domain <FILE> | PDDL domain |
-f, --problem <FILE> | PDDL problem |
--json | emit a structured JSON Solution instead of classic text |
--json-request <FILE> | self-contained {domain, problem, options} JSON job (- = stdin) |
--mode <auto|ff|partition|pddl3|temporal> | planning strategy (default auto) |
--search <auto|ehc|best-first|ehc-then-best-first> | search strategy |
--weight-g <W> / --weight-h <W> | best-first path-length / heuristic weights |
--max-evaluated <N> | cap on nodes evaluated before giving up |
--satisfice | PDDL3: stop at the first plan instead of optimizing the metric |
--decompose | split a too-big temporal goal into ordered contracts and stitch them |
--validate <PLAN> | replay a plan file under ferroplan's own apply semantics (exit 0/1) |
--threads <N> | worker threads (0 = auto) |
--ipc | IPC time-stamped plan format (text mode) |
auto routes by problem features: classic FF for classical/numeric, the PDDL3
metric optimizer when the problem has preferences, and the decision-epoch temporal
search when the domain has :durative-actions. partition selects the
SGPlan-style partition-and-resolve mode. Run ff --help for the full list.
ff -o domain.pddl -f problem.pddl --json
ff -o temporal-domain.pddl -f problem.pddl --mode temporal --decompose
ff -o domain.pddl -f problem.pddl --validate plan.txt
echo '{"domain":"...","problem":"...","options":{"mode":"ff"}}' | ff --json-request -
Library API
The library returns typed, serde-serializable structures. Every knob lives
on one Options struct; every field is optional via Default.
#![allow(unused)] fn main() { use ferroplan::{solve, Mode, Options}; let domain = std::fs::read_to_string("domain.pddl").unwrap(); let problem = std::fs::read_to_string("problem.pddl").unwrap(); let opts = Options { mode: Mode::Auto, ..Default::default() }; let sol = solve(&domain, &problem, &opts).unwrap(); if let Some(plan) = sol.plan { for step in &plan.steps { println!("{} {}", step.action, step.args.join(" ")); } println!("metric: {:?}, makespan: {:?}", plan.metric, plan.makespan); } }
The public surface
solve(domain, problem, &Options)→Result<Solution, SolveError>— plan a domain+problem.Mode::Autoroutes by features (temporal → decision-epoch, preferences → PDDL3 metric optimizer, else classical FF).parse(src)→ParseReport— syntax-check and summarize a domain or problem without grounding or solving (fast authoring feedback).decompose(domain, problem, &Options)→Result<Decomposition, SolveError>— split a too-big temporal goal into ordered, individually-solved contracts and stitch them into one validated plan (falls back to a monolithic solve when a goal can't be split). Seeexamples/decompose.rs.Session::new(domain, problem, &Options)— ground once, thenreplanmany times as the world changes each tick (classical domains). Seeexamples/replan.rs.plan::validate_plan(&domain, &problem, &plan)— independently replay a plan under ferroplan's own apply semantics. Seeexamples/validate_plan.rs.
Key types
Solution { solved, mode, plan: Option<Plan>, statistics, notes }Plan { steps: Vec<Step>, length, metric: Option<f64>, makespan: Option<f64> }Step { index, action, args, time }—timeis set on temporal plans.SolveError—DomainParse/ProblemParse/EmptyType/Derived/Unsupported, viathiserror.
Everything serializes to JSON, so solve doubles as the core of a planning
service. See examples/json_api.rs.
Architecture
ferroplan is data-oriented: states are bitsets of fact ids plus dense fluent vectors; operators are stored column-wise (CSR) so the hot loops stream contiguous memory and parallelize over immutable shared task data.
Pipeline:
- Parse (
parser,lexer) — PDDL domain + problem to an AST. - Ground (
ground) — parallel per-action binding enumeration, DNF of preconditions, ADL expansion (forall/exists/when), negative-precondition compilation, relaxed-reachability pruning, CSR packing. - Search (
search,heuristic) — weighted best-first (1·g + 5·h) with a delete-relaxation relaxed-plan heuristic; deferred (lazy) heuristic evaluation; parallel batch evaluation with order-preserving determinism. - Modes — classic FF, SGPlan-style
partition+resolve, and PDDL3pddl3(Keyder–Geffner soft-goal compilation + anytime branch-and-bound).
Performance notes: an in-tree FxHash hasher, a compact relevant-only visited key, and size-gated parallelism (serial for small frontiers, capped threads) keep both small and large problems fast.
PDDL3 preferences
ferroplan compiles soft-goal preferences away (Keyder & Geffner, JAIR 2009) and
minimizes the :metric.
- Goal preferences, including
(forall (?x) (preference p phi)), are expanded into one instance per binding;(is-violated p)counts violated instances. - Precondition preferences become satisfied/violated action variants, so a violation is charged exactly once per application.
- The metric must be linear in
(is-violated …)and(total-cost)(the IPC-5 simple-preferences shape) — plus any monotone numeric term (e.g. rovers'sum-traverse-cost), which is folded intototal-cost; maximize / negative / scaled metrics fall back to a satisficing plan with a clear note.
The optimizer (0.4.0)
The default is an exact-closure metric optimizer: it searches real states with
metric-bounded acceptance and closes the compiled preference bookkeeping with a
provably-optimal collect/forgo phase tail. Three pieces make it scale:
- Static preference simplification at compile — statically-satisfied preference
instances are dropped before grounding (storage's 62k-instance quadratic
forallcollapses ~97%). - Barrier-free full-DNF guidance — the search sees a preference's forgone cost directly instead of behind a compilation barrier.
- A budget-escalating branch-and-bound — a tightening probe that hits its
per-iteration eval cap without improvement retries the same bound with the
remaining budget rather than giving up. The deterministic, thread-count-independent
budget is
FF_PREF_EVAL_BUDGET(default 2M evals) — a real quality dial. - Anytime sweeps + a diversified restart ladder — each sweep tightens its bound in place on every acceptance (a restart happens once per cap, not once per improvement), and a capped sweep that fails to improve rotates the open-list weights through a fixed profile ladder before the final full-budget escalation — a stuck h-ordering is a direction problem, not a budget problem. This is what broke the storage/tpp large-instance plateau (storage now beats SGPlan5 on p01–p07).
For resource-coupled domains an opt-in ESPC penalty loop (FF_ESPC, after
Hsu–Wah's extended-saddle-point method) prices a shared resource as a global
constraint across a partitioned search — the lever that puts openstacks ahead of
SGPlan5 on its larger instances. Every knob has a restore hatch (FF_PREF_COMPILED,
FF_PREF_NO_STATIC, FF_PREF_BARRIER, FF_PREF_NO_ESCALATE, FF_ESPC_MONO); see
the tuning reference.
On the largest instances exact optimization may return a best-found plan (flagged
not proven optimal) within the budget. Full per-instance results vs SGPlan5:
benchmarks/ipc5-scoreboard.md.
Trajectory constraints ((:constraints ...)) — enforced since 0.7
The six untimed modal operators — always, sometime, at-most-once,
sometime-after, sometime-before, at end — are enforced on the
classical path by compiling each ground constraint instance into a small
monitor automaton: fresh 0-ary monitor facts transitioned by conditional
effects on every action, with acceptance checked at the goal. forall
outside a (preference ...) multiplies instances (so (is-violated name)
counts violated instances); and/forall inside a preference body stay
ONE instance, violated at most once — the PDDL3 instance boundary.
- Hard constraints become goal conjuncts: a plan that violates one is simply not a plan.
- Soft
(preference name ...)constraints lower to ordinary goal preferences priced by the metric machinery above — the whole optimizer stack applies unchanged, and(is-violated name)works across goal and constraint preferences in one namespace. - The independent verifier (
ferroplan::verify) replays the ORIGINAL constraint semantics over the trajectory — never the compiled monitors — so reported metrics are cross-checked by construction, andvalidate_planrejects constraint-violating plans. - Statically decidable instances are simplified away before grounding
(quadratic
forallconstraints over static relations stay tractable);FF_PREF_NO_STATIC=1restores the blind expansion.
The timed operators (within, always-within, hold-during,
hold-after) and constraints on durative-action domains are rejected by
name — never silently dropped. FF_CONSTRAINTS_REJECT=1 restores the
pre-0.7 blanket rejection. Measured results on the IPC-5
qualitative-preferences track:
benchmarks/ipc5-qualitative-scoreboard.md.
Metric quality & invariants
Two pieces of the IPC-5 / SGPlan-class work: a satisfaction-guided metric optimizer for the preference (soft-goal) track, and the Helmert-style mutex-group synthesis that feeds the partition-and-resolve mode.
IPC-5 / metric quality
ferroplan handles all six IPC-5 (2006) simple-preferences soft-goal domains —
openstacks, tpp, storage, trucks, rovers, pathways — where the :metric charges
for violated preferences (and, in rovers, a numeric traverse cost) and lower is
better. Preferences are compiled away (Keyder & Geffner) and the metric is driven
by an exact-closure optimizer with budget-escalating branch-and-bound (see
PDDL3 preferences).
rovers was the last to fall in: its metric also charges a monotone numeric
quantity (sum-traverse-cost), which the optimizer used to ignore — scoring a
bogus 0. Folding monotone numeric terms into total-cost lets it optimize the
full metric (a real 935.3); see Performance.
The hard part is that delete-relaxation hides the cost of forgoing a soft goal:
the free Keyder–Geffner forgo makes every preference look reachable, so on
openstacks-soft the metric search had no gradient toward actually delivering —
it sat on the all-forgo floor (metric 70 on p01). Two engine steps closed most
of that gap:
- Satisfaction-guided ordering (
search::SatGuidance) — a heap penalty counting the preferences forgone in the concrete state, giving the search a reason to deliver. It broke the floor (70 → 63 on openstacks p01) without ever regressing, since it only changes node ordering (branch-and-bound keeps the best plan found). - The exact-closure metric optimizer (0.4.0, now the default) — real-state
search with metric-bounded acceptance plus an exact
collect/forgophase tail, static preference simplification at compile, barrier-free full-DNF guidance, and a budget-escalating branch-and-bound. This pushed openstacks p01 further (63 → 42) and lifted whole domains (storage to full 8/8 coverage, trucks p08 133 → 10).
The last piece for openstacks was the scheduling of the shared stacks-avail
resource, invisible to the satisfaction term because it appears in no preference.
That is exactly what the opt-in ESPC penalty loop (FF_ESPC) now does: its λ
schedule drives a partitioned composition that prices stacks-avail as a global
constraint — taking openstacks ahead of SGPlan5 on p04–p08.
Standing (vs SGPlan5, the IPC-5 winner): full 48/48 coverage and a domain-level
lead on two of six (openstacks with FF_ESPC; storage on defaults), parity on the
small instances nearly everywhere else — a strong 2nd under the coverage-first
rule. The full per-instance tables, the ESPC method, and reproduction commands:
benchmarks/ipc5-scoreboard.md.
Mutex groups & SAS+
For the SGPlan-style partition mode, ferroplan synthesizes mutex-group "guidance variables" — Helmert-style monotonicity invariants that recover the SAS+ multi-valued variables of a task (the "where is X / what is held" facts that are mutually exclusive).
A cheap single-predicate pass only finds variables whose values are all the same
predicate — the clean position variables (at-robby, lift-at, pointing) —
and yields nothing on blocks or logistics. The real work is the
multi-predicate refinement: when an action unbalances a candidate (it adds
into the variable via one predicate but deletes via another), the candidate is
extended with the deleted-and-required fact and re-verified to a fixpoint
(crates/ferroplan/src/invariants.rs). That recovers exactly the variables the
partitioner needs:
| domain | single-pred | multi-pred | biggest group |
|---|---|---|---|
| blocks | 0%, 0 grp | 100%, 9 grp | block support {on, ontable, holding} |
| logistics | 0%, 0 grp | 93%, 9 grp | object location {at, in} |
| gripper | 7%, 1 grp | 71%, 7 grp | gripper hand {free, carry} |
These groups feed SGPlan-style partitioning: the initial partition is seeded from
a goal-interaction graph over the mutex variables, and on a conflict the resolver
merges the actual conflicting pair. The result shortens blocks plans ~25%
where goals share structure but aren't resource-coupled; on resource-coupled
domains naive decomposition still re-traverses the shared resource — which the
opt-in ESPC penalty loop (FF_ESPC) now prices as a global constraint. Method,
coverage numbers, and findings:
docs/invariants-measurement.md.
Temporal planning
ferroplan supports PDDL2.1 durative actions. Temporal problems are
auto-detected (any :durative-action in the domain) and routed to a
decision-epoch forward search; the CLI prints the IPC temporal plan format.
What's supported
:durative-actionwithat start,over all, andat endconditions and effects.- Durations that are constants or parameter-dependent, e.g.
:duration (= ?duration (/ (distance ?a ?b) (speed ?v)))— evaluated per grounded action against the initial state (the static fluents temporal durations usually read). - Duration inequalities —
(>= ?duration L)/(<= ?duration U)andandranges; the search commits to the shortest feasible duration. - Timed initial literals —
(at <time> <literal>)in:init; each becomes a synthetic exogenous applier fired from a pre-seeded agenda at its time (so a goal reachable only via a TIL is not pruned as a dead end). - Required concurrency — actions whose intervals must overlap (the classic "match / mend-fuse": the fuse can only be mended while the match is lit).
How it works
Each durative action is compiled into two instantaneous snap-actions so the existing grounder and relaxed-plan heuristic can be reused:
A-STARTtakes theat startcondition (plus theover allinvariant) and applies theat starteffects plus a(RUNNING-A …)token;A-ENDrequires theat endcondition, the invariant, and that token; it applies theat endeffects and drops the token.
The duration and the over all invariant live in a side table the temporal
search consumes: a decision-epoch search advances time over an agenda of pending
end-events, only letting A-END fire duration after its matching A-START,
and checking the invariant at both happenings.
Output
Plans are rendered in the IPC temporal format, start: (action args) [duration],
with the overall makespan:
0.000: (fly plane1 city-a city-b) [3.000]
3.000: (fly plane1 city-b city-c) [4.000]
From the library, temporal solutions carry time on each Step and a
makespan on the Plan.
Usage
ff -o temporal-domain.pddl -f problem.pddl # auto-detected
ff -o temporal-domain.pddl -f problem.pddl --mode temporal --json
Resource scheduling (renewable + consumable)
Durative actions over numeric fluents give you resource allocation over time for free — the case that matters for scheduling crews, machines, tools, power, or mana. Model a renewable resource as a pool that is taken at start and returned at end, guarded by an at-start check:
(:functions (workers))
(:durative-action chop-tree
:duration (= ?duration 3)
:condition (at start (>= (workers) 1))
:effect (and (at start (decrease (workers) 1)) ; held over the interval…
(at end (increase (workers) 1)) ; …released at the end
(at end (increase (wood) 1))))
Because the decrement persists until the matching end fires, the decision-epoch
search holds the resource across the whole [start, end] interval: a pool of 1
forces tasks to serialize, a larger pool lets them overlap. Consumable
resources (materials) are the same idea without the release — produced and
consumed by a crafting chain (wood → planks, planks + stone → house).
See examples/rpg/
for a full gather → craft → build example; the same problem with (= (workers) 1)
vs 3 plans serially (makespan ~19) vs in parallel (~13). Plans are satisficing,
not makespan-optimal — a good plan fast, suited to an agent that plans, acts, and
replans as the world changes.
Validation against VAL
Plans are validated with VAL, the IPC plan
validator, on real IPC temporal domains (2002–2014). 44 of 45 produced plans
are VAL-valid under PDDL2.1 continuous-time semantics — confirming the
snap-action compilation, over all invariants, required concurrency, and
ε-separation are correct. (Testing against VAL is what surfaced the ε-separation
requirement in the first place.) Coverage is currently search-limited: at a
short budget many instances time out or the decision-epoch search exhausts. See
benchmarks/temporal-results.md.
Not yet supported
Continuous (#t) / duration-dependent numeric effects and ε-separation of
conditional-effect mutexes are not handled yet. PDDL3 trajectory constraints
((:constraints …)) are enforced on the classical path (untimed operators,
since 0.7) but not on the temporal path — a durative-action domain that
declares them is rejected rather than silently ignored. Temporal search
performance (coverage on large instances) is the main open work item.
Tuning & environment knobs
ferroplan's defaults are chosen so that a plain ff -o domain -f problem is the
measured-best path — you should rarely need any of these. When you do, every knob is
an environment variable, read at solve time. Reads are panic-free on
wasm32; the boolean features additionally have in-process overrides in
ferroplan::features (set_overrides,
set_escalate_override, set_espc_override) for WASM/embedded callers, where
std::env::set_var panics.
Almost every knob is a restore hatch: it exists to reproduce an earlier behavior or to run an experiment, and the default is the recommended setting. All results are deterministic and thread-count independent.
Temporal
| var | default | effect |
|---|---|---|
FF_TDEMAND | numeric-only | force the Full demand tier (also seed demand from predicate-goal thresholds) first, for conjunctive/structural builds. |
FF_NO_TDEMAND | — | master switch to the pristine pre-v0.2 path: no demand guidance, no relevance pruning, no escalation. |
FF_NOREL | — | disable goal-relevance pruning alone (keep demand guidance). |
FF_NO_ESCALATE | ladder on | disable the on-failure escalation ladder (retry Full tier, then decomposer). Only affects would-be failures. |
FF_TDECOMP | off | route the temporal path through the partition-and-resolve decomposer first (the decompose API always does, regardless). |
FF_TCONC | off | run the concurrent scheduling phase — repack a plan onto actor objects to minimize makespan. |
FF_TDEMAND_W | 3 | weight of the temporal demand seed. |
PDDL3 preference optimizer
The default path is the exact-closure metric optimizer; these restore its predecessor pieces or tune its budget.
| var | default | effect |
|---|---|---|
FF_PREF_EVAL_BUDGET | 2000000 | deterministic per-solve eval budget — the real quality dial. Higher = more optimization time on hard instances. |
FF_PREF_NO_ESCALATE | escalate | disable the budget-escalating retry (abandon a probe on its first capped iteration). |
FF_PREF_GREEDY | anytime | restore first-improvement sweeps: return at the first plan under the bound and restart, instead of tightening the bound in place and draining the sweep. |
FF_PREF_NO_RESTARTS | ladder on | disable the diversified restart ladder (rotated open-list weight profiles on a capped no-improvement sweep) — the lever behind the storage p06/p07 and pathways p05 wins. |
FF_PREF_SEED | off | experimental: forgo-aware second seed — price each preference's completion with a cost-aware relaxed plan and pre-forgo those priced over their weight. Measured neutral on rovers (the EHC seed already lands there). |
FF_PREF_SEED3 | off | experimental: partitioned closure seed — compose a per-preference-component incumbent (mutex-conflict-pruned, sibling-protected stages) before the tightening loop. Composes genuinely (tpp p05: 99 vs the 105 init-tail) but measured neutral on finals: the anytime+ladder loop reaches the same metric from either bound. |
FF_PREF_NO_SELECT | select on | disable the 0.6 selection layer (exact preference-subset selection solved combinatorially, then planned as a hard-goal target; docs/forensics-tpp.md). Selection is what ties SGPlan5 on tpp p06 and widened the rovers totals lead; its bounded seed runs outside the tightening budget. |
FF_PREF_NO_STATIC | simplify | disable static preference simplification at compile (keep statically-satisfied instances). |
FF_PREF_NO_BARRIER | barrier on | exclude init-satisfied preferences from the guidance (the 0.4–0.5.0 behavior). Keeping them (the 0.5.1 default) protects high-weight trap preferences — the storage 8/8 sweep — see docs/forensics-tpp.md. FF_PREF_BARRIER is accepted, now redundant. |
FF_PREF_COMPILED | closure | route through the legacy compiled-goal B&B instead of the exact-closure optimizer. |
FF_PREF_NUMLEGACY | closure | folded numeric metrics only (rovers-shaped): restore the pre-0.5 routing to the legacy B&B. The closure path now dominates it (rovers flipped to a domain lead). |
FF_PREF_COST_WEIGHT | domain-dependent | cost-aware open-list weight (SearchCfg::w_c). Experimental — a measured dead end on rovers; default 0 there. |
FF_RES_WEIGHT / FF_RES_THRESH | tuned / 0 | satisfaction-guidance resource penalty weight / threshold. |
FF_DEADLINE_WEIGHT | 0 | extra penalty on deadline-pair triggers in satisfaction guidance. |
FF_RES_DEBUG | — | print resource/preference simplification diagnostics. |
ESPC penalty loop (default-on where it bites)
The extended-saddle-point penalty loop for resource-coupled preference domains (openstacks-shaped). On by default since 0.5, with a deterministic evaluated-state budget; it engages only when the compiled task carries once-only conditional-achievement deadline pairs — on every other task it is a verified no-op.
| var | default | effect |
|---|---|---|
FF_NO_ESPC | espc on | opt out — restore the closure-optimizer-only default path. |
FF_ESPC_EVAL_BUDGET | 6000000 | deterministic eval pool for the loop (λ iterations + polish) — the primary budget contract, thread-count independent. |
FF_ESPC_TIME_MS | unset | optional additional wall-clock cap for interactive use. Applies only when set; setting it trades determinism for latency. |
FF_ESPC_MONO | partitioned | reproduce the earlier monolithic (pre-0.4.0) loop. |
FF_ESPC | — | accepted for compatibility (pre-0.5 opt-in); redundant now. |
Advanced ESPC schedule tuning (rarely needed): FF_ESPC_OUTER (outer iterations),
FF_ESPC_RATE (initial penalty rate, 20), FF_ESPC_K (consecutive-violation
rate bump, 2), FF_ESPC_LAMBDA0 (initial λ, 0), FF_ESPC_STALL (stall limit
before termination, 4).
PDDL3 trajectory constraints
| var | default | effect |
|---|---|---|
FF_CONSTRAINTS_REJECT | enforce | restore the 0.4.1–0.6 blanket rejection of every (:constraints ...) block, instead of compiling the untimed operators into enforced monitor automata (0.7) — hard constraints as goal conjuncts, soft (preference ...) constraints priced through the PDDL3 metric machinery. The hatch restores rejection, not ignoring — no setting makes ferroplan silently drop a constraint. |
Reproducing a specific benchmark
The IPC-5 scoreboard
records the exact env for each domain — e.g. openstacks's domain lead is
FF_ESPC=1 FF_ESPC_TIME_MS=90000.
GUI (ferroplan-bevy)
ferroplan-bevy is a Bevy app that turns a PDDL
domain + problem into something you can see: a force-directed graph of the
problem, an animated plan, and a Blockly-style block editor for both the problem
and the domain.
cargo run -p ferroplan-bevy # start empty, load via the editor
cargo run -p ferroplan-bevy domain.pddl problem.pddl
Visualize a domain + problem as a graph
Static objects become nodes, laid out force-directionally, with per-type
icons (a circle for location, a square for the mobile package/truck types);
static binary predicates (e.g. road) become edges. The current state is
drawn on the graph — a package sits on the node it's at. Right-drag pans,
scroll zooms, click inspects a node, and you can drag a node to reposition it.
The problem as a typed graph — locations are circles, mobiles are squares, road predicates are edges. The side panel shows the inspected object and the goal.
The icons and edge colors are inferred from the PDDL — there's no per-domain config. A logistics problem shows the package as a box and trucks/train as mobiles, with rail legs drawn in blue and roads in gray:
Logistics — rail (blue) vs road (gray) edges are distinguished automatically.
A job-shop schedule shows machines as octagons, jobs as boxes, and the stage
routing (s1→s2→s3) in amber:
Job-shop — machines (octagons), jobs (boxes), and stage routing (amber).
Animate the plan
Press S to solve (it calls the same ferroplan::solve the CLI uses) and
Space to play. The plan replays step by step — mobiles slide along edges as
each action fires — with the current step echoed in the side panel. Arrow keys
step manually; R resets to the initial state.
A plan animating mid-step — the side panel tracks step 5/9 while the mobiles move along the graph.
Block editor — problems
The editor is a Blockly-style, drag-and-snap surface: no PDDL syntax to get wrong. The problem editor edits objects (typed), the init facts, and the goal as nested blocks; Apply re-parses and re-renders the graph live, Export writes the PDDL back out.
The problem editor — objects, init, and goal as typed blocks; Apply re-renders the graph, Export writes PDDL.
Block editor — domains
The editor goes all the way down to the domain: types and predicates...
The domain editor — the type hierarchy and predicate signatures as editable blocks.
...and the actions — each action's parameters, precondition, and effect as positive/negative literal blocks, so you can author or tweak operators without touching a text file.
The action editor — parameters plus precondition and effect literals (positive / negative) per action.
The editor and the solver share the same parser and
solveentry point as theffCLI, so what you see is exactly what the planner sees.
Performance
ferroplan is data-oriented by design, but a fast layout only pays off if the hot paths don't do redundant work. This session landed three optimizations that turn instances which were previously un-finishable or un-scoreable into routine ones — each measured, each correctness-preserving.
Grounding — static-precondition parameter-domain restriction
Untyped domains used to enumerate the full cartesian product of every parameter
and string-match almost all of it away. gripper's pick(?obj ?room ?gripper)
was generating 154³ ≈ 3.6M bindings per action and discarding 99.98% of them.
The fix restricts each parameter's domain by its static unary preconditions
before enumerating — so ?gripper only ever ranges over the grippers, not
every object — collapsing the blowup at the source. The produced ground ops are
bit-identical; only the work to find them shrinks.
| instance | before | after |
|---|---|---|
| gripper p02 | 658 µs | 247 µs (2.65×) |
| 150-ball untyped, 1-step | 1.56 s | ~0 |
| gripper-250 (partition mode) | 11.9 s | 3.96 s (3×) |
(crates/ferroplan/src/ground.rs)
EHC work cap — scaled by operator count
Enforced hill-climbing carried a fixed work cap. Large-but-easy instances would exhaust it and bail into the unpruned best-first arm, doing millions of evaluations on a problem EHC's near-greedy descent would have walked straight through. Scaling the cap by operator count lets those instances finish in the cheap arm; the heuristic is untouched and the plan stays valid.
| instance | before | after |
|---|---|---|
gripper-250 --mode ff | 2.16M evals / 33 s | 32k evals / 0.86 s (38×) |
Small and genuinely-hard instances are unchanged — they never hit the old cap,
or they legitimately need the fallback (deep plateaus are still on the backlog;
see perf-notes).
(crates/ferroplan/src/search.rs)
Metric optimizer — monotone numeric-term folding
The metric optimizer drives an anytime branch-and-bound
over the :metric. Previously it could only see the preference-violation terms,
so on domains whose metric also charges a monotone numeric quantity — like
rovers' (sum-traverse-cost) — it scored a bogus 0: the numeric part was
invisible and the search had nothing to optimize.
It now folds monotone numeric metric terms into total-cost, so it optimizes the
full metric. rovers went from un-scoreable to a real metric of 935.3 —
which is what unlocked the sixth IPC-5 domain (see
Metric quality).
(crates/ferroplan/src/pddl3.rs)
How the wins are measured
Two harnesses, with a deliberate division of labor:
cargo bench -p ferroplan --bench planning(criterion) is the reference for wall-time deltas. Itssolve/group covers small typed/numeric instances (gripper, blocks, rovers) andsolve_large/covers the scale-sensitive grounding- and search-dominated cases. Criterion is the only noise-robust timer on a loaded machine.benchmarks/perf.pyreports deterministic evaluated-state counts. A constant-factor win leaves these bit-identical (proof the work, not the strategy, shrank); a search-strategy win changes them and must be re-baselined.
Raw wall-clock here is noise-dominated below ~15% — the same binary has ranged 11.5–14 s under background load — so treat any single timed run with suspicion and let criterion arbitrate.
The ranked backlog of remaining optimizations (generation-counter Scratch
reset, preferred-operator best-first, apply_into clone-on-survival) lives in
docs/perf-notes.md,
along with the methodology caveats learned the hard way (notably: atos
mis-attributes inlined hot code on optimized builds — trust the de-noised
profile, not the raw top symbols).
Benchmarks
ferroplan is differentially tested against reference planners over a curated subset of the IPC contest suites — classical (gripper, blocks, logistics, …), numeric (rovers, satellite, depots, …), ADL, and IPC-5 simple-preferences. Coverage, plan validity, and speed are checked against the C Metric-FF; IPC-5 preference quality is scored against SGPlan5, the contest winner (per-instance metrics from the official archive — see the scoreboard). A locally-built SGPlan6 is also used for solvability/validity agreement.
The harness lives in benchmarks/:
solvability agreement, plan validity, plan length, and (for preferences) metric
comparison, plus criterion micro-benchmarks for parse/ground/search.
Results tables are regenerated in CI and published here.
Benchmark results
ferroplan vs the reference planners, over a subset of the IPC contest suites:
classical STRIPS / numeric / ADL are measured against a native arm64 Metric-FF;
the IPC-5 simple-preferences quality is measured against SGPlan5 (the IPC-5
winner), read from the official IPC5-results.tgz archive. The oracles are not
bundled (GPL / non-commercial licences); see COMPARING.md to reproduce.
Absolute times are machine- and load-dependent; only ratios within a single run are meaningful. Default ferroplan search is enforced hill-climbing (EHC) with a weighted-best-first fallback — the FF/Metric-FF default.
Speed & coverage vs Metric-FF (native arm64)
| category | ferroplan solved | speed (geomean vs Metric-FF) |
|---|---|---|
| STRIPS | 40/40 | 0.71× (~1.4× slower) |
| ADL | 23/24 | 0.77× (~1.3× slower) |
| numeric | 36/40 | 0.22× (~4.5× slower) |
On classical + ADL, ferroplan — a from-scratch, memory-safe Rust planner — is
within ~1.4× of the heavily-optimized C reference. EHC is the reason (below). On
numeric it still trails; of the 4 unsolved numeric tasks, 3 time out under
Metric-FF too (genuinely hard), and 1 (satellite/p06) Metric-FF solves but
ferroplan does not — its EHC lookahead stalls on some numeric domains and falls
back to (slower, complete) best-first. Closing the numeric EHC gap is future work.
Why EHC matters — states evaluated
EHC reaches the goal in dozens of evaluations where plain best-first needs thousands, matching Metric-FF's order of magnitude:
| problem | ferroplan EHC | ferroplan best-first | Metric-FF |
|---|---|---|---|
| strips/gripper/p08 | 223 | 17 446 | 158 |
| numeric/depots/p01 | 20 | 403 | 21 |
| strips/blocks/p02 | 16 | 69 | 22 |
(--search best-first selects the old behaviour; EHC is the default.)
IPC-5 simple-preferences (vs SGPlan5)
ferroplan compiles preferences away (Keyder & Geffner) and, as of 0.4.0, optimizes
them with an exact-closure metric optimizer (the default) plus a
budget-escalating branch-and-bound, and — opt-in via FF_ESPC — an ESPC-style
partitioned penalty loop. Metric-FF is PDDL2.1-only and errors on every preference
problem, so the benchmark is SGPlan5, the IPC-5 winner (per-instance metrics
from the official archive; lower is better).
The 0.4.0 headline: full 48/48 coverage (storage was 2/8) and ferroplan now leads SGPlan5 on two of the six domains —
- openstacks (with
FF_ESPC): wins p04–p08, totals 271 vs 326. - storage (default path): wins p01–p05.
…with a parity band on the rest — trucks (ahead on the total, wins p01/p07), pathways and tpp (tie on p01–p04), rovers (edges p07/p08) — where SGPlan5 keeps each domain's larger instances. Under the IPC-5 coverage-first rule this is a strong 2nd, with the remaining gap concentrated in the tpp/pathways/storage p05–p08 tails and rovers' numeric metric.
The full per-instance tables, the ESPC method, and the reproduction commands
live in the scoreboard:
benchmarks/ipc5-scoreboard.md.
Reproduce
Vendored micro-suite: cargo bench (criterion, ferroplan-internal). Cross-planner
comparison: compare.py with the oracles, per
COMPARING.md.