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, parameter-dependent, or state-dependent durations, timed initial literals, and required concurrency (see Temporal planning);
  • a game-embedding Session — ground a world once, then run a whole population of minds in it: bounded deterministic thinks, free plan-validity replays, retargetable goals, cheap forks, scheduled events, in-flight intervals (see Game embedding);
  • an optional SGPlan-style partition-and-resolve mode, and a budget-aware sequential portfolio mode (--mode portfolio).

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.

Save it for offline. The header's Save offline button keeps a copy of the demo in your browser — the three pages, the example corpus and the WASM planner, a few megabytes — so it opens and plans with no network at all (airplane, lecture hall, the site being down). Nothing is stored until you ask. The copy refreshes itself in the background whenever you visit online, a new deploy replaces it whole, and the button reports what it holds (files, size, the build it came from); the ✕ beside it removes the copy. The page is also installable as an app from the browser's menu. Your browser has to allow service workers on the origin (it is a secure page, so the only place this is hidden is a plain file:// open).

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.

The living bazaar

A replay of a real multi-mind Session tick-loop run — two planners in one barter world, with and without contention claims (see Game embedding):

▶ Open the living-bazaar replay

For everything-else (the CLI, the library, install), see the rest of the docs.

Install & quick start

Both crates are on crates.io:

cargo install ferroplan-cli    # -> the `ff` binary

or build from source:

git clone https://github.com/hhh42/ferroplan
cd ferroplan
cargo build --release      # -> target/release/ff

Solve a problem:

ff -o domain.pddl -f problem.pddl

Or from code — cargo add ferroplan 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|portfolio>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; portfolio runs the budget-aware sequential strategy ladder on one shared eval pool. 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 think many times as the world changes each tick: classical and temporal domains, bounded thinks, free plan-validity replays, retargetable goals, cheap population forks, scheduled events, and in-flight intervals. The whole game-embedding surface has its own chapter; see also examples/game_think.rs and examples/bazaar_live.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, duration } — time/duration are set on temporal plans.
  • SolveError — DomainParse / 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.

Game embedding (the Session)

ferroplan::solve re-parses and re-grounds on every call. A Session is the embedding API for callers that re-solve the same world every tick — a game's villagers, a simulation loop, an agent runtime. It parses, compiles axioms, and grounds once, then holds the current world state; every "think" afterward pays only the search.

Everything below is deterministic and thread-count independent: the same session state, budget, and goal produce byte-identical plans at any threads setting.

#![allow(unused)]
fn main() {
use ferroplan::{Options, Session};
let (domain_src, problem_src) = (String::new(), String::new());
let mut s = Session::new(&domain_src, &problem_src, &Options::default())?;
let first = s.replan();                       // plan from the problem's :init
s.set_fact("(at vera field)", true)?;         // the world moved
s.set_fluent("(grain)", 3.0)?;
let next = s.replan();                        // replan from HERE
Ok::<(), String>(())
}

Classical, numeric, and ADL domains are supported — and temporal (durative-action) domains since 0.12: thinks return timed, genuinely-concurrent plans with per-step time/duration and a makespan.

Bounded thinks

replan_budgeted(max_evaluated, memory_mb) is the real-time surface: an eval budget (the deterministic unit — never wall clock) and a memory target. A think either returns a plan or an honest budget-exhausted solved: false the game can react to — think again later, escalate, or pick a fallback behavior.

Follow before you rethink

plan_still_valid(&plan, from_step) replays a plan's remaining suffix against the current state — no search, no budget. An agent whose world drifted irrelevantly keeps following its plan for free; only a broken suffix warrants a real rethink. When one does break, replan_following(&plan, from_step, budget, memory_mb) biases the rethink toward the broken plan: it replays the still-applicable prefix and searches only for a new tail, so plan churn stays confined to what drift actually broke (measured: a deep break re-plans in 3 evals with churn 1 where an unbiased rethink spends 2,899 evals churning 16 steps). If no tail exists, it falls back to an unbiased rethink — the bias can cost budget, never completeness.

One world, changing desires

set_goal("(and (has a0 item5) …)") retargets a session with zero regrounding — any ground conjunction (atoms, negated atoms with grounded mirrors, numeric comparisons) over the already-interned fact space. Desires the world cannot express are an honest error, not a silent unsolvable. goal_met() is the pure state test ("is it done now") — distinct from a think, which answers "could I still plan."

One grounding, a population of minds

fork() clones a mind over the same grounded world: the grounded payload (operator tables, names, indexes) is shared behind Arc, so N minds cost one grounding plus kilobytes of private state each — measured on the vendored bazaar, 12 NPCs fork in ~0 ms with +0 MB RSS. Forks start from the parent's current state and goal, then diverge freely; no fork's writes or tie-breaks ever touch a sibling.

restrict_ops(|display| …) scopes a mind to its own actions — the many-minds correctness primitive. A rival's moves reach it as set_fact drift, never as plan steps, and loop-side policies (like masking exchanges a rival's plan has claimed) compose on top of it. The bazaar_live example drives the whole tick loop — and the browser demo replays a real run.

The scheduled world

set_timed_fact(dt, "(power)", false) plants a clock-relative event: in dt time units, the fact flips. Pending events ride into every temporal think — plans beat closing windows or fail honestly, and can wait through an outage whose repair is scheduled — and into plan_still_valid replays. elapse(dt) advances the schedule as the game's clock moves, firing due events. (Absolute-clock TILs stay rejected at construction: session time is always relative to now.)

The domain contract: a scheduled fact must be dynamic — touched by some action in the domain. A truly static fact is compiled into the grounded operators, so flipping it at runtime could not soundly change behavior; model exogenous-changeable facts (market-open, power) with an action that touches them.

In-flight intervals

apply_start("(fire urn)") begins a durative action now: its start effects apply, and its end joins the session's in-flight set. Thinks happen mid-interval — plans cover what remains, never restart the running action, and are valid through every pending end (a think can even be pure waiting: zero steps, makespan = the pending end's moment). elapse(dt) fires due interval ends with their own at-end effects; an end whose preconditions drift broke is reported, its effects dropped — the game decides what a ruined firing means.

Belief and observation (fog)

A mind's session is a belief state: a world copy that drifts from the authoritative one. observe(&[(fact, value)]) is the seeing surface — sighted facts snap to the observed truth, everything else stays believed, and the call returns exactly the facts whose value differed from belief: the surprises. Feed the news to plan_still_valid / goal_met and rethink only when observation breaks something — surprise-driven rethinks, not wall-clock paranoia. The fogged bazaar loop (bazaar_live, the claims + fog rows in benchmarks/bazaar-thinks.md) shows the measured shape: a mind sees its own stall each turn and its trading partner's stall on arrival; a theft at a third-party stall is discovered on contact, one tick late, and information asymmetry visibly reshuffles who wins — deterministically, at any thread count.

Fences (why some writes are errors)

Honesty over silent wrongness, everywhere:

  • Static facts are rejected by set_fact/set_timed_fact: grounding enumerates operators against statics, so flipping one could require operators that were never enumerated.
  • RUNNING-* tokens (the temporal compiler's interval bookkeeping) are managed by apply_start/elapse, never by hand.
  • ADL goal connectives in set_goal (or goals over never-interned atoms) error with the reason — recreate the session for an ADL goal.
  • PDDL3 constraints/preferences and absolute-clock TILs are rejected at construction with pointers to the per-solve API.

world_bytes() / mind_bytes() report the shared-vs-private memory split (flat-byte floors) for embedders budgeting a population.

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; PDDL3 pddl3 (Keyder–Geffner soft-goal compilation + anytime branch-and-bound); the decision-epoch temporal search (snap-action compilation, pending-end agenda, symmetry-reduced since 0.13); a budget-aware sequential portfolio; and the game-embedding Session (ground once, think forever — with a fixpoint grounding path whose transient memory is up to ~117× smaller on sparse-reachable worlds).

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 a default-on ESPC penalty loop (opt out with FF_NO_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.

Incumbent zero (0.28). Before the optimizer starts, the classical ladder plans the HARD goals alone (soft trajectory constraints stripped — they cannot invalidate a plan) and that plan is lifted into the compiled task as a floor: the optimizer's own search is unchanged, and the seed is what comes back, with a note, only when nothing cheaper was found inside the budget. Until 0.28 a run that ended before the optimizer's first plan reported solved: false with a valid plan milliseconds away. Two consequences worth knowing: on a problem with no hard goal at all the floor is the empty plan (valid, every preference violated), and when the compiled preference task does not even ground inside the wall or the memory budget the hard-goal plan is returned with metric: null and a NOT priced note. FF_PREF_NO_SEED=1 restores 0.27.

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 gate the goal through a forced-terminal END action (since 0.8): acceptance is latched by conditional effects on one synthetic TRAJ-END step — stripped from reported plans — so the compiled goal stays literal-only and grounding cost is LINEAR in the monitor count (the 0.7 goal-conjunct compilation was exponential in the worst case; FF_NO_TRAJ_END=1 restores it). A plan that violates a hard constraint 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 monitor transition block is ground ONCE and shared across all ground actions (since 0.8, FF_NO_COND_SHARE=1 restores per-op copies) — the storage-scale instances that previously exhausted 15 GB during grounding now ground in well under a second at ~100–200 MB.
  • 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.

Since 0.23, hard untimed constraints are enforced on durative-action (temporal) domains too: the same monitor compile rides the snap-compiled task, the emitted (ε-separated) schedule is re-audited against the monitors before a constrained plan is returned, and the temporal validator folds the original constraint semantics over its replay. The timed operators (within, always-within, hold-during, hold-after) and soft (preference ...) constraints on durative 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 ESPC penalty loop (default-on since 0.5; FF_NO_ESPC opts out) 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 default-on ESPC penalty loop (FF_NO_ESPC opts out) 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, or per expansion against the current state when the duration reads a fluent some action modifies (state-dependent durations, since 0.12). ?duration is also accepted inside numeric effect expressions (duration-dependent effects like (increase (energy ?x) (* ?duration (recharge-rate ?x))), since 0.10).
  • 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 and on every happening in between (0.14): a happening whose effects would delete a running interval's invariant fact is refused, so a delete + re-add sneaking through the endpoint checks — a bake spanning the gap between two kiln firings — can't happen, and nodes whose agenda head can never legally fire are pruned at birth. Since 0.13 the pending-interval agenda is symmetry-reduced (canonical ordering + redundant identical-interval elimination, FF_NO_TSYMM=1 reverts) — same-epoch starts of interchangeable intervals no longer multiply the visited space. 0.14 adds object-symmetry orbits on top (FF_NO_ORBIT=1 reverts): when a problem's objects or goal pairs are interchangeable — identical init profiles, symmetric goals, a grounded task closed under relabeling — the visited key is canonicalized under member permutation, collapsing machine-shop-style "which identical piece is which" state blowups.

The compression rung (0.28)

Before any of that, a task that needs no concurrency is tried the cheap way: every durative action is read as ONE instantaneous action (condition = start ∧ over all ∧ end, effect = start then end), the classical ladder plans that, and the plan is put back on the clock by a left-shift over each step's read/write sets — independent work overlaps, interfering work does not. The result is validated against the ORIGINAL durative task before it is returned, so the rung can be wrong about a task without ever being wrong about a plan. It banks: the decision-epoch search then runs as a bounded quality chase and the smaller makespan wins, so a task that solved before returns the plan it returned before. It declines — and says so under FF_WALL_DEBUG=1 — on required concurrency, timed initial literals, trajectory constraints and unconstrained durations, and it stands aside when FF_TCONC=1 asks for the actor scheduler. FF_NO_TCOMPRESS=1 restores the 0.27 route.

One consequence to know about: the left-shift overlaps whatever the PDDL allows. A domain that is lockless by design — the cabin crew, where "one job per worker" is the scheduler's convention and not a precondition — now gets every independent job at once by default (crew-solo: makespan 109 → 47, one worker on four jobs). That is a valid PDDL2.1 plan. If a resource can only do one thing at a time, say so in the domain with a busy token, or run the actor scheduler (FF_TCONC=1), under which the results are exactly 0.27's.

A plan in hand comes back inside the budget (0.28)

Everything done to a plan that already solves the task — the preference quality chase, the makespan chase, the scorer — is optional work, and it stops a reserve short of FF_TIME_LIMIT (3 % of the wall, 0.5–3 s, plus a size term) and at 75 % of FF_MEM_BUDGET_GB. A runner that kills at the wall or at the memory cap no longer takes a valid plan down with the process. A banked plan whose preference scorer cannot be built in what is left is returned with a NOT scored note rather than not returned.

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 the full IPC-2008/2011 tempo-sat corpus (630 instances, 30 s each), ferroplan solves 399 — and every one of the 399 plans is 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; since 0.10 the pass totally ε-orders execution, so same-instant mutexes — conditional-effect ones included — are impossible by construction.) Coverage on the unsolved remainder is search-limited: the recorded walls are guidance problems, not semantics. See benchmarks/ipc67-temporal.md.

Not yet supported

Continuous (#t) effects are not handled (discrete duration-dependent effects via ?duration are — see above). 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 guidance on the recorded wall domains (machine-shop, storage, model-train) 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.

Budgets — the wall and the memory

A budget is declared, never imposed: ferroplan does not kill itself. It reads what the caller (or the caller's runner) will enforce and arranges to be finished first. Per call, in code, the same two ideas are Options::wall_ms and Options::should_continue.

variabledefaulteffect
FF_TIME_LIMITunsetthe process's REAL wall budget in seconds — see Classical search for the ladder gate it arms. Since 0.28 it also bounds everything done to a plan ALREADY in hand.
FF_REPORT_RESERVE_SECS3 % of the wall (0.5–3 s) + a size termhow far short of the wall optional work on a found plan stops — the quality chases, the preference scorer, the PDDL3 optimizer over its seed — so the plan is reported before a runner that kills AT the wall fires (0.28).
FF_MEM_BUDGET_GBunsetthe retained-memory budget in GiB (fractional ok) the caller will enforce. It sizes the modelled node caps, and since 0.28 it is also MEASURED: the engine reads its own resident size (/proc/self/status; task_info on macOS).
FF_NO_MEM_WALLwall ondisable the 0.28 measured memory wall. With it, bounded work — a chase over a banked plan, the grounding that would price or score a plan already found, a rung's bet — stops at 75 % of FF_MEM_BUDGET_GB and returns what was banked; a trip is sticky for that scope. A first search, with nothing to fall back on, is never cut by it.
FF_MEM_TRIP_FRAC0.75move that line — for measuring it (0.85 was measured: nothing solves or scores better, and one board instance peaks at 5.97 GB of 6).
FF_EHC_WALL_FRAC0.25the fraction of the remaining wall EHC may spend before handing down the ladder. FF_NO_EHC_WALLCAP=1 removes the slice.
FF_NO_RUNG_WALLCAPcheckpoints onturn OFF the clock checkpoints inside the bounded rungs, best-first batches and grounding (the 0.21 shapes — a run can then overrun its wall; kept as the permanent RED record).
FF_WALL_DEBUG / FF_GROUND_PHASESoffnarrate every wall and memory decision / every grounding phase with its elapsed time on stderr. Probe eyes; never affect the search.

Temporal

vardefaulteffect
FF_TDEMANDnumeric-onlyforce 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_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.
FF_NO_TSYMMsymmetry ondisable the 0.13 pending-interval symmetry reduction (canonical agenda order + redundant identical-interval elimination). The restore hatch for its one named corpus casualty (parking-2011 #16).
FF_TEMPORAL_NODE_CAPbyte modeloverride the deterministic temporal stored-node cap directly (0 disables; default derives from the memory target and static task dims).
FF_TEMPORAL_ABS_KEYrelativerestore ABSOLUTE agenda times in the visited key on TIL-free tasks (the pre-0.10 behavior; relative keying dedups retimed permutations).
FF_TLAMAoffexperimental: the temporal LAMA rung (fact-landmark-dominant key). Measured negative — snap-task landmarks are RUNNING-token chains with no branching signal.
FF_LAX_HELPFULoffexperimental: re-arm empty temporal helpful sets with a lax fallback instead of a full scan. Measured zero new solves.
FF_TAGENDA_W0experimental: complete-pass agenda-size ordering term (de-prioritize interval hoarding). Measured negative on parc-printer-t.
FF_NO_FIXPOINT_GROUNDfixpoint (Session)make the Session's temporal entry use stratified grounding instead of reached-restricted fixpoint grounding (the ~117× transient-memory win on sparse-reachable worlds). Corpus/CLI paths always use stratified — their tie-breaks pin the scoreboard baselines.
FF_NO_ORBITorbits ondisable object-symmetry orbit detection (0.14): goal-respecting member permutations canonicalize the temporal visited key, collapsing states that differ only by relabeling interchangeable objects/goal pairs (machine-shop's wall; rescues turn-and-open under the sound invariant semantics).
FF_ORBIT_DEBUGoffnarrate orbit detection (candidate groups, bail reasons) and per-solve eval counts on stderr — the probe eyes, never affects the search.
FF_ORBIT_GENoffexperimental: generation-side stabilizer skipping (0.15): with orbits detected, an op whose state-fixing member swap maps it to an already-generated sibling is never generated (TMS: 2.4× real evals at equal budget; duplicates 81% → 53% — but zero new solves, since TMS's wall is the start-credit plateau, not throughput). The 0.15 sweep referee flipped it opt-in: the per-expansion stabilizer scan cost match-cellar 9 solved instances. The canonical-key pre-dedup stays default-on.
FF_TLIFOoffexperimental: LIFO tie-breaking at equal temporal keys. Measured WORSE on the TMS plateau (best_h 150 vs 110) — dives a high-h corridor.
FF_TB_FREE_Goffexperimental: don't charge g for time-advance successors. Measured WORSE on TMS (best_h 196) — rides time forward, wasting windows.
FF_TAGENDA_W_PRUNE0experimental: the agenda-size term on the PRUNED pass (start-credit counter-account). Measured worse on TMS (best_h 173) though it does push the search deep enough for window blocking to engage.
FF_NO_TCOMPRESSrung ondisable the 0.28 compression rung (plan a concurrency-free temporal task as a classical one, left-shift it onto the clock, validate against the original). Restores the 0.27 route.
FF_TCOMPRESS_WALL_FRAC / FF_TCOMPRESS_CHASE_FRAC0.25 / 0.25the share of the remaining wall the compression rung may bet, and the share the decision-epoch makespan chase gets after it banks. Unwalled, the bet is capped at 2,000 evaluations per classical rung instead.
FF_NO_TSUCCgenerator onrestore the full operator scan in temporal expansion. 0.28 wired the anchored successor generator (0.27's classical lever) into the temporal rung; it is byte-identical by construction and, measured, removes a 0.01–7.7 % term — the relaxed planning graph is the other 92–99 %. Kept for exactly that kind of measurement.
FF_TEVAL_BUDGETunlimitedcap CLI temporal search evaluations — the deterministic measuring stick for A/B probes (eval budgets, never wall clock).

Classical search (experiments & restore hatches)

vardefaulteffect
FF_NOVELTYautothe width-1 BFWS-style novelty rung (0.17) — a third bounded classical rung after EHC and LAMA fail, open list ordered by state novelty then h. 0.19: DEFAULT-ON when FF_TIME_LIMIT is declared and >40% of the budget remains (the 0.18 gated referee measured +4/−0, reversing 0.17's ungated +7/−51 tax); FF_NO_NOVELTY=1 opts out, FF_NOVELTY=1 forces it without a budget. No budget declared ⇒ rung off, byte-identical ladder. FF_NOVELTY_ONLY=1 probes the rung alone.
FF_TIME_LIMITunsetthe process's REAL wall budget in seconds (0.18 budget-aware ladder). The clock arms at solve entry (grounding counts as spent budget); a bounded rung (LAMA, novelty) is entered only while more than 40% of the budget remains, so late-ladder rungs stop starving the complete fallback near the budget edge — the mechanism behind the novelty referee's −51. Unset = all-rungs behavior, byte-identical to 0.17. benchmarks/ipc67.py passes its per-instance --timeout automatically. Informational, never kills the process. FF_WALL_DEBUG=1 narrates the gate's verdict on stderr (the probe eyes, never affects the search).
FF_NO_NEED_DIRSdirection-awarerestore the 0.27 relaxed-graph fixpoint test, which counted ANY numeric bound movement as progress — a consumable's un-read lower bound drifting down sent every dead-end evaluation to the 2,000-layer cap. Heuristic values are identical either way; only the cost of proving a dead end changes (~78× evaluations per second on rovers-metric-time shapes).
FF_HTRACEoffprint the classical best-first best_h trace (evaluation count and seconds at each improvement) on stderr.
FF_NO_DNF_STATICresolvedisable static resolution inside precondition DNF expansion (restore the 2^k imply blowup the 0.10 fix removed — openstacks-ADL 6/30 → 30/30).
FF_NO_TRAJ_ENDend-actionrestore the exponential goal-DNF construction for hard trajectory monitors (pre-0.8).
FF_CLMoffexperimental: classical landmark-count guidance term. Measured negative (transport unchanged, floor-tile worse).
FF_LEN_ANYTIMEoffexperimental: keep draining the open list after the first plan, returning the shortest found. Measured −9 coverage at 60 s; opt-in only.
FF_LEN_SWEEP_EVALSoffexperimental: iterated-weight anytime re-sweeps for unit-cost plan length (~1.8% shorter at ~28× evals).

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_NO_SEEDseed ondisable 0.28's incumbent zero — the hard-goal plan handed to the optimizer as a floor, and returned (with a note) when nothing cheaper was found. Restores 0.27, where such a run reported solved: false.
FF_PREF_SEED_BOUNDfloor onlyalso OPEN the branch-and-bound with the seed's metric as its first bound (off by default: the seed is a floor, not a bound, so the optimizer's own search is byte-identical to 0.27's).
FF_PREF_SEED_WALL_FRAC / FF_PREF_SEED_EHC_FRAC1.0 / 0.6the share of the remaining wall the hard-goal search may use, and EHC's share of THAT.
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_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.

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_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

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.

Full-corpus scoreboards over IPC-2008/2011 (all VAL-validated) live alongside: seq-sat, tempo-sat, and net-benefit — regenerated per release by benchmarks/ipc67.py. The game track has its own scoreboard: bazaar-thinks.md (think latency/quality curves and the live multi-mind loop, generated by the bazaar_thinks / bazaar_live examples).

Curated-subset results tables are committed and published here as-is (they are machine-dependent; regenerate locally per benchmarks/COMPARING.md).

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.

Competition standings

ferroplan measures itself on 32 IPC boards — every deterministic track it can enter from IPC-5 (2006) through IPC-2026: classical satisficing, temporal, PDDL3 preferences and constraints, net-benefit, numeric, the multi-core track under its wall-clock rule, and seven optimal tracks where coverage is proof rate. Budgets are 60 s per instance (300 s for the one official-budget agile entry), one thread, two instances at a time on the sweep box, every reported plan externally validated by VAL. The tables below are GENERATED from the raw per-instance sweep logs (python3 benchmarks/standings.py, and the crucible's own renderer must agree with it byte for byte) and are refreshed against the final binary at every release cut — scoreboards defend themselves.

Three kinds of honesty markers appear throughout:

  • Reference-scored means scored against the official competition field. The IPC-5 preference boards are scored from the vendored official results archive (benchmarks/IPC5-results.tgz — see benchmarks/ATTRIBUTION.md for provenance) by the IPC quality score against SGPlan5's own ; MetricValue, the currency those tracks were ranked on: level with the winner on rows, behind on points, and the table says so. The IPC-5 propositional, time and metric-time tracks are quality-scored by plan length and makespan against the archive field; the 2018 and 2023 classical boards against the best-known bounds.
  • Coverage-only means no aligned reference exists: no official per-instance archive is vendored for IPC-6/7 and the 2014 corpus, and the constraints board records no quality currency.
  • vs field (in STANDINGS.md) is a coverage-RATE placement against the competition's actual entrants at ~1/30th of the official budget — a floor, not a result; docs/ipc-rankings.md holds the provenance.

Failure classes are counted per unsolved instance: timeout (budget exhausted), mem-cap (the runner's memory cap hit — tracked separately from engine verdicts), early-exit (the engine returned without a plan before its budget), engine-reject/error (a named rejection — hold-during / hold-after, or a parse error), spawn-fail and VAL-RED (the harness's own failures, never silently folded in).

The optimal tracks (⚖️) are entered since 0.19 under Mode::Optimal — A* with an admissible LM-cut heuristic — and their coverage IS proof rate. The sequential multi-core tracks are entered under their competition rule (wall-clock with all cores; per-thread-count determinism still holds) on the sweep box's 4P+6E cores, with the t8 row marked as oversubscribed.

IPC standings — the one honest table per competition

Generated by python3 benchmarks/standings.py (do not hand-edit; regenerate after any sweep). Raw inputs are the per-instance JSONLs and the vendored official IPC-5 archive — see the module docstring for scoring semantics and the failure-class definitions.

IPC-5 (2006)

trackenteredcoveragequalityfailure classes
propositionalyes386/450len vs best-of-field: 54W/44T/182L, mean quality 0.90 (280 scored)64 timeout
timeyes126/130makespan vs best-of-field: 31W/3T/92L, mean quality 0.65 (126 scored)4 timeout
metric-timeyes119/200makespan vs best-of-field: 75W/1T/32L, mean quality 0.93 (108 scored)11 mem-cap, 70 timeout
constraintsyes28/120coverage-only (no quality currency recorded)15 early-exit, 8 mem-cap, 69 timeout, 7 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
simple-preferences (full corpus)yes130/130IPC score vs SGPlan5: 97.3 / 115.1 over 130 cells (32W/21T/76L; 1 solved unpriced)none
qualitative-preferences (full corpus)yes100/100IPC score vs SGPlan5: 64.6 / 91.4 over 100 cells (28W/4T/68L; 0 solved unpriced)12 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
complex-preferences (full corpus)yes81/108IPC score vs SGPlan5: 54.5 / 100.0 over 105 cells (12W/5T/61L; 1 solved unpriced)14 mem-cap, 13 timeout, 4 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
simple-preferencesyessee boardreference-scored — ipc5-scoreboard.md—
qualitative-preferencesyessee boardreference-scored — ipc5-qualitative-scoreboard.md (24W/4T/10L vs SGPlan5 — ahead of the winner; rovers/storage/tpp won outright)—
complex-preferencessuperseded — the full-corpus board above (entered 0.25)———

IPC-6 (2008)

trackenteredcoveragequalityfailure classes
seq-satyes298/300coverage + VAL (no official per-instance archive vendored)2 timeout
tempo-satyes338/390coverage + VAL (no official per-instance archive vendored)5 mem-cap, 47 timeout
net-benefityes270/270coverage + VAL (no official per-instance archive vendored)none
seq-optyes (first entry, 0.19 — Mode::Optimal)157/270coverage = PROOF RATE (A* + admissible LM-cut, h^max sprint first; every plan certified + VAL)113 timeout
tempo-optout of scope by design (satisficing temporal path)———

IPC-7 (2011)

trackenteredcoveragequalityfailure classes
seq-satyes245/280coverage + VAL35 timeout
tempo-satyes174/240coverage + VAL8 mem-cap, 58 timeout
seq-mco t2yes (first entry, 0.16)249/280wall-clock per competition rule (--threads N, one instance at a time; 4P+6E box — t8 oversubscribed by construction)31 timeout
seq-mco t4yes (first entry, 0.16)253/280wall-clock per competition rule (--threads N, one instance at a time; 4P+6E box — t8 oversubscribed by construction)1 mem-cap, 26 timeout
seq-mco t8yes (first entry, 0.16)255/280wall-clock per competition rule (--threads N, one instance at a time; 4P+6E box — t8 oversubscribed by construction)1 early-exit, 24 timeout
seq-optyes (first entry, 0.19 — Mode::Optimal)140/280coverage = PROOF RATE (A* + admissible LM-cut, h^max sprint first; every plan certified + VAL)140 timeout

The modern corpora (IPC 2014 / 2018 / 2023 — first entered 0.17)

trackenteredcoveragequalityfailure classes
2014 seq-satyes (first entry, 0.17)180/280coverage + VAL4 mem-cap, 96 timeout
2014 seq-agileyes (first entry, 0.17)174/280coverage + VAL1 early-exit, 1 mem-cap, 104 timeout
2014 tempo-satyes (first entry, 0.17)124/200coverage + VAL2 mem-cap, 74 timeout
2014 seq-mco t2yes (FIRST ENTRY, 0.25 — the table grows)181/280wall-clock per competition rule (one instance at a time; 4P+6E box)6 mem-cap, 93 timeout
2014 seq-mco t4yes (first entry, 0.17)184/280wall-clock per competition rule (--threads 4, one instance at a time; 4P+6E box)7 mem-cap, 89 timeout
2014 seq-mco t8yes (FIRST ENTRY, 0.25 — the table grows)186/280wall-clock per competition rule (one instance at a time; 4P+6E box, t8 oversubscribed by construction)11 mem-cap, 83 timeout
2014 seq-optyes (first entry, 0.19)82/256coverage = PROOF RATE (Mode::Optimal, A* + admissible LM-cut, h^max sprint first; every plan certified + VAL)174 timeout
2018 seq-satyes (first entry, 0.17)99/240vs best-known bounds: 0W/2T/30L, mean quality 0.79 (32 scored)24 mem-cap, 117 timeout, 10 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
2018 seq-optyes (FIRST ENTRY, 0.25 — the table grows)94/240coverage = PROOF RATE (Mode::Optimal, A* + admissible LM-cut, h^max sprint first; every plan certified + VAL)21 mem-cap, 125 timeout, 10 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
2023 classicalyes (first entry, 0.17)52/140vs best-known bounds: 1W/21T/30L, mean quality 0.84 (52 scored)17 mem-cap, 71 timeout
2023 seq-satyes (FIRST ENTRY, 0.25 — the table grows)52/140vs best-known bounds: 0W/22T/30L, mean quality 0.84 (52 scored)20 mem-cap, 68 timeout
2023 seq-optyes (FIRST ENTRY, 0.25 — the table grows)33/140coverage = PROOF RATE (Mode::Optimal, A* + admissible LM-cut, h^max sprint first; every plan certified + VAL)8 mem-cap, 99 timeout
2023 agile ENTRY (300s)yes (OFFICIAL-BUDGET entry, 0.19)67/140OFFICIAL 300 s budget — a competition-methodology ENTRY, not a baseline17 mem-cap, 56 timeout
2023 numericyes (first entry, 0.17)289/400field CSVs vendored (ipc-2023n/results) — per-domain comparison in the audit record1 engine-reject/error, 52 mem-cap, 58 timeout, 47 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
2023 numeric-optyes (FIRST ENTRY, 0.25 — the table grows)82/400coverage = PROOF RATE over the numeric corpus; the track's official field CSV (ipc-2023n/results/opt.csv) is the vs-field referee137 early-exit, 1 engine-reject/error, 86 mem-cap, 94 timeout, 3 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
2026 numeric (first board)yes (FIRST ENTRY, 0.20 — new corpus)224/320coverage + VAL; the corpus ships -sat/-opt domain PAIRS, all swept satisficing-style on this first board2 early-exit, 94 timeout, 11 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py)
2026 numeric-optyes (FIRST ENTRY, 0.21 — the -opt pairs, ⚖️)23/60coverage = PROOF RATE (Mode::Optimal over the three -opt pairs; LENGTH optima — the vendored corpus carries no active :metric; every certificate VAL-checked)21 early-exit, 16 timeout
2026 numeric-opt FULLyes (FIRST ENTRY, 0.25 — the table grows)80/260coverage = PROOF RATE over the official 13-domain/260 Overall Optimal constituency (the 3-pair board above is the like-for-like slice)112 early-exit, 24 mem-cap, 44 timeout

The 2023 classical corpus is swept on its agile instances at the standard 60 s satisficing budget (the competition's agile budget is 300 s — these rows are BASELINES, marked as such, not competition entries).

Reading the boards

Per-cycle history (what moved and why, cut by cut) lives in the docs/roadmap-0.*.md records; this chapter is always the CURRENT standing.