Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

flagmeaning
-o, --domain <FILE>PDDL domain
-f, --problem <FILE>PDDL problem
--jsonemit 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
--satisficePDDL3: stop at the first plan instead of optimizing the metric
--decomposesplit 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)
--ipcIPC 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::Auto routes 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). See examples/decompose.rs.
  • Session::new(domain, problem, &Options) — ground once, then replan many times as the world changes each tick (classical domains). See examples/replan.rs.
  • plan::validate_plan(&domain, &problem, &plan) — independently replay a plan under ferroplan's own apply semantics. See examples/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 }time is set on temporal plans.
  • SolveErrorDomainParse / ProblemParse / EmptyType / Derived / Unsupported, via thiserror.

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:

  1. Parse (parser, lexer) — PDDL domain + problem to an AST.
  2. Ground (ground) — parallel per-action binding enumeration, DNF of preconditions, ADL expansion (forall/exists/when), negative-precondition compilation, relaxed-reachability pruning, CSR packing.
  3. 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.
  4. Modes — classic FF, SGPlan-style partition+resolve, and PDDL3 pddl3 (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 into total-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 forall collapses ~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, and validate_plan rejects constraint-violating plans.
  • Statically decidable instances are simplified away before grounding (quadratic forall constraints over static relations stay tractable); FF_PREF_NO_STATIC=1 restores 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:

  1. 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).
  2. The exact-closure metric optimizer (0.4.0, now the default) — real-state search with metric-bounded acceptance plus an exact collect/forgo phase 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:

domainsingle-predmulti-predbiggest group
blocks0%, 0 grp100%, 9 grpblock support {on, ontable, holding}
logistics0%, 0 grp93%, 9 grpobject location {at, in}
gripper7%, 1 grp71%, 7 grpgripper 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-action with at start, over all, and at end conditions 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) and and ranges; 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-START takes the at start condition (plus the over all invariant) and applies the at start effects plus a (RUNNING-A …) token;
  • A-END requires the at end condition, the invariant, and that token; it applies the at end effects 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

vardefaulteffect
FF_TDEMANDnumeric-onlyforce the Full demand tier (also seed demand from predicate-goal thresholds) first, for conjunctive/structural builds.
FF_NO_TDEMANDmaster switch to the pristine pre-v0.2 path: no demand guidance, no relevance pruning, no escalation.
FF_NORELdisable goal-relevance pruning alone (keep demand guidance).
FF_NO_ESCALATEladder ondisable the on-failure escalation ladder (retry Full tier, then decomposer). Only affects would-be failures.
FF_TDECOMPoffroute the temporal path through the partition-and-resolve decomposer first (the decompose API always does, regardless).
FF_TCONCoffrun the concurrent scheduling phase — repack a plan onto actor objects to minimize makespan.
FF_TDEMAND_W3weight 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.

vardefaulteffect
FF_PREF_EVAL_BUDGET2000000deterministic per-solve eval budget — the real quality dial. Higher = more optimization time on hard instances.
FF_PREF_NO_ESCALATEescalatedisable the budget-escalating retry (abandon a probe on its first capped iteration).
FF_PREF_GREEDYanytimerestore 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_RESTARTSladder ondisable 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_SEEDoffexperimental: 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_SEED3offexperimental: 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_SELECTselect ondisable 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_STATICsimplifydisable static preference simplification at compile (keep statically-satisfied instances).
FF_PREF_NO_BARRIERbarrier onexclude 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_COMPILEDclosureroute through the legacy compiled-goal B&B instead of the exact-closure optimizer.
FF_PREF_NUMLEGACYclosurefolded 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_WEIGHTdomain-dependentcost-aware open-list weight (SearchCfg::w_c). Experimental — a measured dead end on rovers; default 0 there.
FF_RES_WEIGHT / FF_RES_THRESHtuned / 0satisfaction-guidance resource penalty weight / threshold.
FF_DEADLINE_WEIGHT0extra penalty on deadline-pair triggers in satisfaction guidance.
FF_RES_DEBUGprint 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.

vardefaulteffect
FF_NO_ESPCespc onopt out — restore the closure-optimizer-only default path.
FF_ESPC_EVAL_BUDGET6000000deterministic eval pool for the loop (λ iterations + polish) — the primary budget contract, thread-count independent.
FF_ESPC_TIME_MSunsetoptional additional wall-clock cap for interactive use. Applies only when set; setting it trades determinism for latency.
FF_ESPC_MONOpartitionedreproduce the earlier monolithic (pre-0.4.0) loop.
FF_ESPCaccepted 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

vardefaulteffect
FF_CONSTRAINTS_REJECTenforcerestore 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.

A delivery problem as a typed graph: location nodes (circles) joined by road edges, with package/truck mobiles (squares) sitting on the node they're currently at. 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:

A logistics problem: location nodes joined by gray road edges and a blue rail edge, a package box, and truck/train mobiles. 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:

A job-shop problem: stage nodes joined by amber routing edges, machines as octagons, and jobs as boxes. 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.

Mid-animation: a plan is playing, showing "step 5/9: drive rig garage harbor", with a crate having moved to its new node. 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 block editor: typed OBJECTS, INIT facts (road/pkg-at blocks), and GOAL blocks in a left panel, with the graph updating on Apply. 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: a TYPES section (location/truck/package, each - object) and a PREDICATES section (road, truck-at, pkg-at, in) as blocks. 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: drive/load/unload actions, each with typed parameters and pre/eff literal lists (pos/neg). The action editor — parameters plus precondition and effect literals (positive / negative) per action.

The editor and the solver share the same parser and solve entry point as the ff CLI, 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.

instancebeforeafter
gripper p02658 µs247 µs (2.65×)
150-ball untyped, 1-step1.56 s~0
gripper-250 (partition mode)11.9 s3.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.

instancebeforeafter
gripper-250 --mode ff2.16M evals / 33 s32k 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. Its solve/ group covers small typed/numeric instances (gripper, blocks, rovers) and solve_large/ covers the scale-sensitive grounding- and search-dominated cases. Criterion is the only noise-robust timer on a loaded machine.
  • benchmarks/perf.py reports 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)

categoryferroplan solvedspeed (geomean vs Metric-FF)
STRIPS40/400.71× (~1.4× slower)
ADL23/240.77× (~1.3× slower)
numeric36/400.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:

problemferroplan EHCferroplan best-firstMetric-FF
strips/gripper/p0822317 446158
numeric/depots/p012040321
strips/blocks/p02166922

(--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.