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.
| flag | meaning |
|---|---|
-o, --domain <FILE> | PDDL domain |
-f, --problem <FILE> | PDDL problem |
--json | emit a structured JSON Solution instead of classic text |
--json-request <FILE> | self-contained {domain, problem, options} JSON job (- = stdin) |
--mode <auto|ff|partition|pddl3|temporal|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 |
--satisfice | PDDL3: stop at the first plan instead of optimizing the metric |
--decompose | split a too-big temporal goal into ordered contracts and stitch them |
--validate <PLAN> | replay a plan file under ferroplan's own apply semantics (exit 0/1) |
--threads <N> | worker threads (0 = auto) |
--ipc | IPC time-stamped plan format (text mode) |
auto routes by problem features: classic FF for classical/numeric, the PDDL3
metric optimizer when the problem has preferences, and the decision-epoch temporal
search when the domain has :durative-actions. partition selects the
SGPlan-style partition-and-resolve mode; 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::Autoroutes by features (temporal → decision-epoch, preferences → PDDL3 metric optimizer, else classical FF).parse(src)→ParseReport— syntax-check and summarize a domain or problem without grounding or solving (fast authoring feedback).decompose(domain, problem, &Options)→Result<Decomposition, SolveError>— split a too-big temporal goal into ordered, individually-solved contracts and stitch them into one validated plan (falls back to a monolithic solve when a goal can't be split). Seeexamples/decompose.rs.Session::new(domain, problem, &Options)— ground once, 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 alsoexamples/game_think.rsandexamples/bazaar_live.rs.plan::validate_plan(&domain, &problem, &plan)— independently replay a plan under ferroplan's own apply semantics. Seeexamples/validate_plan.rs.
Key types
Solution { solved, mode, plan: Option<Plan>, statistics, notes }Plan { steps: Vec<Step>, length, metric: Option<f64>, makespan: Option<f64> }Step { index, action, args, time, duration }—time/durationare set on temporal plans.SolveError—DomainParse/ProblemParse/EmptyType/Derived/Unsupported, viathiserror.
Everything serializes to JSON, so solve doubles as the core of a planning
service. See examples/json_api.rs.
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 byapply_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:
- Parse (
parser,lexer) — PDDL domain + problem to an AST. - Ground (
ground) — parallel per-action binding enumeration, DNF of preconditions, ADL expansion (forall/exists/when), negative-precondition compilation, relaxed-reachability pruning, CSR packing. - Search (
search,heuristic) — weighted best-first (1·g + 5·h) with a delete-relaxation relaxed-plan heuristic; deferred (lazy) heuristic evaluation; parallel batch evaluation with order-preserving determinism. - Modes — classic FF; SGPlan-style
partition+resolve; PDDL3pddl3(Keyder–Geffner soft-goal compilation + anytime branch-and-bound); the decision-epochtemporalsearch (snap-action compilation, pending-end agenda, symmetry-reduced since 0.13); a budget-aware sequentialportfolio; and the game-embeddingSession(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 intototal-cost; maximize / negative / scaled metrics fall back to a satisficing plan with a clear note.
The optimizer (0.4.0)
The default is an exact-closure metric optimizer: it searches real states with
metric-bounded acceptance and closes the compiled preference bookkeeping with a
provably-optimal collect/forgo phase tail. Three pieces make it scale:
- Static preference simplification at compile — statically-satisfied preference
instances are dropped before grounding (storage's 62k-instance quadratic
forallcollapses ~97%). - Barrier-free full-DNF guidance — the search sees a preference's forgone cost directly instead of behind a compilation barrier.
- A budget-escalating branch-and-bound — a tightening probe that hits its
per-iteration eval cap without improvement retries the same bound with the
remaining budget rather than giving up. The deterministic, thread-count-independent
budget is
FF_PREF_EVAL_BUDGET(default 2M evals) — a real quality dial. - Anytime sweeps + a diversified restart ladder — each sweep tightens its bound in place on every acceptance (a restart happens once per cap, not once per improvement), and a capped sweep that fails to improve rotates the open-list weights through a fixed profile ladder before the final full-budget escalation — a stuck h-ordering is a direction problem, not a budget problem. This is what broke the storage/tpp large-instance plateau (storage now beats SGPlan5 on p01–p07).
For resource-coupled domains 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-ENDstep — 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=1restores 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=1restores 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, andvalidate_planrejects constraint-violating plans. - Statically decidable instances are simplified away before grounding
(quadratic
forallconstraints over static relations stay tractable);FF_PREF_NO_STATIC=1restores the blind expansion.
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:
- Satisfaction-guided ordering (
search::SatGuidance) — a heap penalty counting the preferences forgone in the concrete state, giving the search a reason to deliver. It broke the floor (70 → 63 on openstacks p01) without ever regressing, since it only changes node ordering (branch-and-bound keeps the best plan found). - The exact-closure metric optimizer (0.4.0, now the default) — real-state
search with metric-bounded acceptance plus an exact
collect/forgophase tail, static preference simplification at compile, barrier-free full-DNF guidance, and a budget-escalating branch-and-bound. This pushed openstacks p01 further (63 → 42) and lifted whole domains (storage to full 8/8 coverage, trucks p08 133 → 10).
The last piece for openstacks was the scheduling of the shared stacks-avail
resource, invisible to the satisfaction term because it appears in no preference.
That is exactly what the 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:
| domain | single-pred | multi-pred | biggest group |
|---|---|---|---|
| blocks | 0%, 0 grp | 100%, 9 grp | block support {on, ontable, holding} |
| logistics | 0%, 0 grp | 93%, 9 grp | object location {at, in} |
| gripper | 7%, 1 grp | 71%, 7 grp | gripper hand {free, carry} |
These groups feed SGPlan-style partitioning: the initial partition is seeded from
a goal-interaction graph over the mutex variables, and on a conflict the resolver
merges the actual conflicting pair. The result shortens blocks plans ~25%
where goals share structure but aren't resource-coupled; on resource-coupled
domains naive decomposition still re-traverses the shared resource — which the
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-actionwithat start,over all, andat endconditions and effects.- Durations that are constants or parameter-dependent, e.g.
:duration (= ?duration (/ (distance ?a ?b) (speed ?v)))— evaluated per grounded action against the initial state, or per expansion against the current state when the duration reads a fluent some action modifies (state-dependent durations, since 0.12).?durationis 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)andandranges; the search commits to the shortest feasible duration. - Timed initial literals —
(at <time> <literal>)in:init; each becomes a synthetic exogenous applier fired from a pre-seeded agenda at its time (so a goal reachable only via a TIL is not pruned as a dead end). - Required concurrency — actions whose intervals must overlap (the classic "match / mend-fuse": the fuse can only be mended while the match is lit).
How it works
Each durative action is compiled into two instantaneous snap-actions so the existing grounder and relaxed-plan heuristic can be reused:
A-STARTtakes theat startcondition (plus theover allinvariant) and applies theat starteffects plus a(RUNNING-A …)token;A-ENDrequires theat endcondition, the invariant, and that token; it applies theat endeffects and drops the token.
The duration and the over all invariant live in a side table the temporal
search consumes: a decision-epoch search advances time over an agenda of pending
end-events, only letting A-END fire duration after its matching A-START,
and checking the invariant at both happenings 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.
| variable | default | effect |
|---|---|---|
FF_TIME_LIMIT | unset | the 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_SECS | 3 % of the wall (0.5–3 s) + a size term | how 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_GB | unset | the 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_WALL | wall on | disable 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_FRAC | 0.75 | move 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_FRAC | 0.25 | the fraction of the remaining wall EHC may spend before handing down the ladder. FF_NO_EHC_WALLCAP=1 removes the slice. |
FF_NO_RUNG_WALLCAP | checkpoints on | turn 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_PHASES | off | narrate every wall and memory decision / every grounding phase with its elapsed time on stderr. Probe eyes; never affect the search. |
Temporal
| var | default | effect |
|---|---|---|
FF_TDEMAND | numeric-only | force the Full demand tier (also seed demand from predicate-goal thresholds) first, for conjunctive/structural builds. |
FF_NO_TDEMAND | — | master switch to the pristine pre-v0.2 path: no demand guidance, no relevance pruning, no escalation. |
FF_NOREL | — | disable goal-relevance pruning alone (keep demand guidance). |
FF_NO_ESCALATE | ladder on | disable the on-failure escalation ladder (retry Full tier, then decomposer). Only affects would-be failures. |
FF_TDECOMP | off | route the temporal path through the partition-and-resolve decomposer first (the decompose API always does, regardless). |
FF_TCONC | off | run the concurrent scheduling phase — repack a plan onto actor objects to minimize makespan. |
FF_TDEMAND_W | 3 | weight of the temporal demand seed. |
FF_NO_TSYMM | symmetry on | disable 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_CAP | byte model | override the deterministic temporal stored-node cap directly (0 disables; default derives from the memory target and static task dims). |
FF_TEMPORAL_ABS_KEY | relative | restore ABSOLUTE agenda times in the visited key on TIL-free tasks (the pre-0.10 behavior; relative keying dedups retimed permutations). |
FF_TLAMA | off | experimental: the temporal LAMA rung (fact-landmark-dominant key). Measured negative — snap-task landmarks are RUNNING-token chains with no branching signal. |
FF_LAX_HELPFUL | off | experimental: re-arm empty temporal helpful sets with a lax fallback instead of a full scan. Measured zero new solves. |
FF_TAGENDA_W | 0 | experimental: complete-pass agenda-size ordering term (de-prioritize interval hoarding). Measured negative on parc-printer-t. |
FF_NO_FIXPOINT_GROUND | fixpoint (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_ORBIT | orbits on | disable 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_DEBUG | off | narrate orbit detection (candidate groups, bail reasons) and per-solve eval counts on stderr — the probe eyes, never affects the search. |
FF_ORBIT_GEN | off | experimental: 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_TLIFO | off | experimental: 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_G | off | experimental: don't charge g for time-advance successors. Measured WORSE on TMS (best_h 196) — rides time forward, wasting windows. |
FF_TAGENDA_W_PRUNE | 0 | experimental: 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_TCOMPRESS | rung on | disable 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_FRAC | 0.25 / 0.25 | the 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_TSUCC | generator on | restore 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_BUDGET | unlimited | cap CLI temporal search evaluations — the deterministic measuring stick for A/B probes (eval budgets, never wall clock). |
Classical search (experiments & restore hatches)
| var | default | effect |
|---|---|---|
FF_NOVELTY | auto | the 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_LIMIT | unset | the 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_DIRS | direction-aware | restore 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_HTRACE | off | print the classical best-first best_h trace (evaluation count and seconds at each improvement) on stderr. |
FF_NO_DNF_STATIC | resolve | disable 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_END | end-action | restore the exponential goal-DNF construction for hard trajectory monitors (pre-0.8). |
FF_CLM | off | experimental: classical landmark-count guidance term. Measured negative (transport unchanged, floor-tile worse). |
FF_LEN_ANYTIME | off | experimental: 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_EVALS | off | experimental: 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.
| var | default | effect |
|---|---|---|
FF_PREF_EVAL_BUDGET | 2000000 | deterministic per-solve eval budget — the real quality dial. Higher = more optimization time on hard instances. |
FF_PREF_NO_ESCALATE | escalate | disable the budget-escalating retry (abandon a probe on its first capped iteration). |
FF_PREF_GREEDY | anytime | restore first-improvement sweeps: return at the first plan under the bound and restart, instead of tightening the bound in place and draining the sweep. |
FF_PREF_NO_RESTARTS | ladder on | disable the diversified restart ladder (rotated open-list weight profiles on a capped no-improvement sweep) — the lever behind the storage p06/p07 and pathways p05 wins. |
FF_PREF_NO_SEED | seed on | disable 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_BOUND | floor only | also 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_FRAC | 1.0 / 0.6 | the share of the remaining wall the hard-goal search may use, and EHC's share of THAT. |
FF_PREF_SEED | off | experimental: forgo-aware second seed — price each preference's completion with a cost-aware relaxed plan and pre-forgo those priced over their weight. Measured neutral on rovers (the EHC seed already lands there). |
FF_PREF_SEED3 | off | experimental: partitioned closure seed — compose a per-preference-component incumbent (mutex-conflict-pruned, sibling-protected stages) before the tightening loop. Composes genuinely (tpp p05: 99 vs the 105 init-tail) but measured neutral on finals: the anytime+ladder loop reaches the same metric from either bound. |
FF_PREF_NO_SELECT | select on | disable the 0.6 selection layer (exact preference-subset selection solved combinatorially, then planned as a hard-goal target; docs/forensics-tpp.md). Selection is what ties SGPlan5 on tpp p06 and widened the rovers totals lead; its bounded seed runs outside the tightening budget. |
FF_PREF_NO_STATIC | simplify | disable static preference simplification at compile (keep statically-satisfied instances). |
FF_PREF_NO_BARRIER | barrier on | exclude init-satisfied preferences from the guidance (the 0.4–0.5.0 behavior). Keeping them (the 0.5.1 default) protects high-weight trap preferences — the storage 8/8 sweep — see docs/forensics-tpp.md. FF_PREF_BARRIER is accepted, now redundant. |
FF_PREF_COMPILED | closure | route through the legacy compiled-goal B&B instead of the exact-closure optimizer. |
FF_PREF_NUMLEGACY | closure | folded numeric metrics only (rovers-shaped): restore the pre-0.5 routing to the legacy B&B. The closure path now dominates it (rovers flipped to a domain lead). |
FF_PREF_COST_WEIGHT | domain-dependent | cost-aware open-list weight (SearchCfg::w_c). Experimental — a measured dead end on rovers; default 0 there. |
FF_RES_WEIGHT / FF_RES_THRESH | tuned / 0 | satisfaction-guidance resource penalty weight / threshold. |
FF_DEADLINE_WEIGHT | 0 | extra penalty on deadline-pair triggers in satisfaction guidance. |
FF_RES_DEBUG | — | print resource/preference simplification diagnostics. |
ESPC penalty loop (default-on where it bites)
The extended-saddle-point penalty loop for resource-coupled preference domains (openstacks-shaped). On by default since 0.5, with a deterministic evaluated-state budget; it engages only when the compiled task carries once-only conditional-achievement deadline pairs — on every other task it is a verified no-op.
| var | default | effect |
|---|---|---|
FF_NO_ESPC | espc on | opt out — restore the closure-optimizer-only default path. |
FF_ESPC_EVAL_BUDGET | 6000000 | deterministic eval pool for the loop (λ iterations + polish) — the primary budget contract, thread-count independent. |
FF_ESPC_TIME_MS | unset | optional additional wall-clock cap for interactive use. Applies only when set; setting it trades determinism for latency. |
FF_ESPC_MONO | partitioned | reproduce the earlier monolithic (pre-0.4.0) loop. |
FF_ESPC | — | accepted for compatibility (pre-0.5 opt-in); redundant now. |
Advanced ESPC schedule tuning (rarely needed): FF_ESPC_OUTER (outer iterations),
FF_ESPC_RATE (initial penalty rate, 20), FF_ESPC_K (consecutive-violation
rate bump, 2), FF_ESPC_LAMBDA0 (initial λ, 0), FF_ESPC_STALL (stall limit
before termination, 4).
PDDL3 trajectory constraints
| var | default | effect |
|---|---|---|
FF_CONSTRAINTS_REJECT | enforce | restore the 0.4.1–0.6 blanket rejection of every (:constraints ...) block, instead of compiling the untimed operators into enforced monitor automata (0.7) — hard constraints as goal conjuncts, soft (preference ...) constraints priced through the PDDL3 metric machinery. The hatch restores rejection, not ignoring — no setting makes ferroplan silently drop a constraint. |
Reproducing a specific benchmark
The IPC-5 scoreboard
records the exact env for each domain — e.g. openstacks's domain lead is
FF_ESPC=1 FF_ESPC_TIME_MS=90000.
GUI (ferroplan-bevy)
ferroplan-bevy is a Bevy app that turns a PDDL
domain + problem into something you can see: a force-directed graph of the
problem, an animated plan, and a Blockly-style block editor for both the problem
and the domain.
cargo run -p ferroplan-bevy # start empty, load via the editor
cargo run -p ferroplan-bevy domain.pddl problem.pddl
Visualize a domain + problem as a graph
Static objects become nodes, laid out force-directionally, with per-type
icons (a circle for location, a square for the mobile package/truck types);
static binary predicates (e.g. road) become edges. The current state is
drawn on the graph — a package sits on the node it's at. Right-drag pans,
scroll zooms, click inspects a node, and you can drag a node to reposition it.
The problem as a typed graph — locations are circles, mobiles are squares, road predicates are edges. The side panel shows the inspected object and the goal.
The icons and edge colors are inferred from the PDDL — there's no per-domain config. A logistics problem shows the package as a box and trucks/train as mobiles, with rail legs drawn in blue and roads in gray:
Logistics — rail (blue) vs road (gray) edges are distinguished automatically.
A job-shop schedule shows machines as octagons, jobs as boxes, and the stage
routing (s1→s2→s3) in amber:
Job-shop — machines (octagons), jobs (boxes), and stage routing (amber).
Animate the plan
Press S to solve (it calls the same ferroplan::solve the CLI uses) and
Space to play. The plan replays step by step — mobiles slide along edges as
each action fires — with the current step echoed in the side panel. Arrow keys
step manually; R resets to the initial state.
A plan animating mid-step — the side panel tracks step 5/9 while the mobiles move along the graph.
Block editor — problems
The editor is a Blockly-style, drag-and-snap surface: no PDDL syntax to get wrong. The problem editor edits objects (typed), the init facts, and the goal as nested blocks; Apply re-parses and re-renders the graph live, Export writes the PDDL back out.
The problem editor — objects, init, and goal as typed blocks; Apply re-renders the graph, Export writes PDDL.
Block editor — domains
The editor goes all the way down to the domain: types and predicates...
The domain editor — the type hierarchy and predicate signatures as editable blocks.
...and the actions — each action's parameters, precondition, and effect as positive/negative literal blocks, so you can author or tweak operators without touching a text file.
The action editor — parameters plus precondition and effect literals (positive / negative) per action.
The editor and the solver share the same parser and
solveentry point as theffCLI, so what you see is exactly what the planner sees.
Performance
ferroplan is data-oriented by design, but a fast layout only pays off if the hot paths don't do redundant work. This session landed three optimizations that turn instances which were previously un-finishable or un-scoreable into routine ones — each measured, each correctness-preserving.
Grounding — static-precondition parameter-domain restriction
Untyped domains used to enumerate the full cartesian product of every parameter
and string-match almost all of it away. gripper's pick(?obj ?room ?gripper)
was generating 154³ ≈ 3.6M bindings per action and discarding 99.98% of them.
The fix restricts each parameter's domain by its static unary preconditions
before enumerating — so ?gripper only ever ranges over the grippers, not
every object — collapsing the blowup at the source. The produced ground ops are
bit-identical; only the work to find them shrinks.
| instance | before | after |
|---|---|---|
| gripper p02 | 658 µs | 247 µs (2.65×) |
| 150-ball untyped, 1-step | 1.56 s | ~0 |
| gripper-250 (partition mode) | 11.9 s | 3.96 s (3×) |
(crates/ferroplan/src/ground.rs)
EHC work cap — scaled by operator count
Enforced hill-climbing carried a fixed work cap. Large-but-easy instances would exhaust it and bail into the unpruned best-first arm, doing millions of evaluations on a problem EHC's near-greedy descent would have walked straight through. Scaling the cap by operator count lets those instances finish in the cheap arm; the heuristic is untouched and the plan stays valid.
| instance | before | after |
|---|---|---|
gripper-250 --mode ff | 2.16M evals / 33 s | 32k evals / 0.86 s (38×) |
Small and genuinely-hard instances are unchanged — they never hit the old cap,
or they legitimately need the fallback (deep plateaus are still on the backlog;
see perf-notes).
(crates/ferroplan/src/search.rs)
Metric optimizer — monotone numeric-term folding
The metric optimizer drives an anytime branch-and-bound
over the :metric. Previously it could only see the preference-violation terms,
so on domains whose metric also charges a monotone numeric quantity — like
rovers' (sum-traverse-cost) — it scored a bogus 0: the numeric part was
invisible and the search had nothing to optimize.
It now folds monotone numeric metric terms into total-cost, so it optimizes the
full metric. rovers went from un-scoreable to a real metric of 935.3 —
which is what unlocked the sixth IPC-5 domain (see
Metric quality).
(crates/ferroplan/src/pddl3.rs)
How the wins are measured
Two harnesses, with a deliberate division of labor:
cargo bench -p ferroplan --bench planning(criterion) is the reference for wall-time deltas. Itssolve/group covers small typed/numeric instances (gripper, blocks, rovers) andsolve_large/covers the scale-sensitive grounding- and search-dominated cases. Criterion is the only noise-robust timer on a loaded machine.benchmarks/perf.pyreports deterministic evaluated-state counts. A constant-factor win leaves these bit-identical (proof the work, not the strategy, shrank); a search-strategy win changes them and must be re-baselined.
Raw wall-clock here is noise-dominated below ~15% — the same binary has ranged 11.5–14 s under background load — so treat any single timed run with suspicion and let criterion arbitrate.
The ranked backlog of remaining optimizations (generation-counter Scratch
reset, preferred-operator best-first, apply_into clone-on-survival) lives in
docs/perf-notes.md,
along with the methodology caveats learned the hard way (notably: atos
mis-attributes inlined hot code on optimized builds — trust the de-noised
profile, not the raw top symbols).
Benchmarks
ferroplan is differentially tested against reference planners over a curated subset of the IPC contest suites — classical (gripper, blocks, logistics, …), numeric (rovers, satellite, depots, …), ADL, and IPC-5 simple-preferences. Coverage, plan validity, and speed are checked against the C Metric-FF; IPC-5 preference quality is scored against SGPlan5, the contest winner (per-instance metrics from the official archive — see the scoreboard). A locally-built SGPlan6 is also used for solvability/validity agreement.
The harness lives in benchmarks/:
solvability agreement, plan validity, plan length, and (for preferences) metric
comparison, plus criterion micro-benchmarks for parse/ground/search.
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)
| category | ferroplan solved | speed (geomean vs Metric-FF) |
|---|---|---|
| STRIPS | 40/40 | 0.71× (~1.4× slower) |
| ADL | 23/24 | 0.77× (~1.3× slower) |
| numeric | 36/40 | 0.22× (~4.5× slower) |
On classical + ADL, ferroplan — a from-scratch, memory-safe Rust planner — is
within ~1.4× of the heavily-optimized C reference. EHC is the reason (below). On
numeric it still trails; of the 4 unsolved numeric tasks, 3 time out under
Metric-FF too (genuinely hard), and 1 (satellite/p06) Metric-FF solves but
ferroplan does not — its EHC lookahead stalls on some numeric domains and falls
back to (slower, complete) best-first. Closing the numeric EHC gap is future work.
Why EHC matters — states evaluated
EHC reaches the goal in dozens of evaluations where plain best-first needs thousands, matching Metric-FF's order of magnitude:
| problem | ferroplan EHC | ferroplan best-first | Metric-FF |
|---|---|---|---|
| strips/gripper/p08 | 223 | 17 446 | 158 |
| numeric/depots/p01 | 20 | 403 | 21 |
| strips/blocks/p02 | 16 | 69 | 22 |
(--search best-first selects the old behaviour; EHC is the default.)
IPC-5 simple-preferences (vs SGPlan5)
ferroplan compiles preferences away (Keyder & Geffner) and, as of 0.4.0, optimizes
them with an exact-closure metric optimizer (the default) plus a
budget-escalating branch-and-bound, and — opt-in via FF_ESPC — an ESPC-style
partitioned penalty loop. Metric-FF is PDDL2.1-only and errors on every preference
problem, so the benchmark is SGPlan5, the IPC-5 winner (per-instance metrics
from the official archive; lower is better).
The 0.4.0 headline: full 48/48 coverage (storage was 2/8) and ferroplan now leads SGPlan5 on two of the six domains —
- openstacks (with
FF_ESPC): wins p04–p08, totals 271 vs 326. - storage (default path): wins p01–p05.
…with a parity band on the rest — trucks (ahead on the total, wins p01/p07), pathways and tpp (tie on p01–p04), rovers (edges p07/p08) — where SGPlan5 keeps each domain's larger instances. Under the IPC-5 coverage-first rule this is a strong 2nd, with the remaining gap concentrated in the tpp/pathways/storage p05–p08 tails and rovers' numeric metric.
The full per-instance tables, the ESPC method, and the reproduction commands
live in the scoreboard:
benchmarks/ipc5-scoreboard.md.
Reproduce
Vendored micro-suite: cargo bench (criterion, ferroplan-internal). Cross-planner
comparison: compare.py with the oracles, per
COMPARING.md.
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— seebenchmarks/ATTRIBUTION.mdfor 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.mdholds 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)
| track | entered | coverage | quality | failure classes |
|---|---|---|---|---|
| propositional | yes | 386/450 | len vs best-of-field: 54W/44T/182L, mean quality 0.90 (280 scored) | 64 timeout |
| time | yes | 126/130 | makespan vs best-of-field: 31W/3T/92L, mean quality 0.65 (126 scored) | 4 timeout |
| metric-time | yes | 119/200 | makespan vs best-of-field: 75W/1T/32L, mean quality 0.93 (108 scored) | 11 mem-cap, 70 timeout |
| constraints | yes | 28/120 | coverage-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) | yes | 130/130 | IPC score vs SGPlan5: 97.3 / 115.1 over 130 cells (32W/21T/76L; 1 solved unpriced) | none |
| qualitative-preferences (full corpus) | yes | 100/100 | IPC 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) | yes | 81/108 | IPC 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-preferences | yes | see board | reference-scored — ipc5-scoreboard.md | — |
| qualitative-preferences | yes | see board | reference-scored — ipc5-qualitative-scoreboard.md (24W/4T/10L vs SGPlan5 — ahead of the winner; rovers/storage/tpp won outright) | — |
| complex-preferences | superseded — the full-corpus board above (entered 0.25) | — | — | — |
IPC-6 (2008)
| track | entered | coverage | quality | failure classes |
|---|---|---|---|---|
| seq-sat | yes | 298/300 | coverage + VAL (no official per-instance archive vendored) | 2 timeout |
| tempo-sat | yes | 338/390 | coverage + VAL (no official per-instance archive vendored) | 5 mem-cap, 47 timeout |
| net-benefit | yes | 270/270 | coverage + VAL (no official per-instance archive vendored) | none |
| seq-opt | yes (first entry, 0.19 — Mode::Optimal) | 157/270 | coverage = PROOF RATE (A* + admissible LM-cut, h^max sprint first; every plan certified + VAL) | 113 timeout |
| tempo-opt | out of scope by design (satisficing temporal path) | — | — | — |
IPC-7 (2011)
| track | entered | coverage | quality | failure classes |
|---|---|---|---|---|
| seq-sat | yes | 245/280 | coverage + VAL | 35 timeout |
| tempo-sat | yes | 174/240 | coverage + VAL | 8 mem-cap, 58 timeout |
| seq-mco t2 | yes (first entry, 0.16) | 249/280 | wall-clock per competition rule (--threads N, one instance at a time; 4P+6E box — t8 oversubscribed by construction) | 31 timeout |
| seq-mco t4 | yes (first entry, 0.16) | 253/280 | wall-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 t8 | yes (first entry, 0.16) | 255/280 | wall-clock per competition rule (--threads N, one instance at a time; 4P+6E box — t8 oversubscribed by construction) | 1 early-exit, 24 timeout |
| seq-opt | yes (first entry, 0.19 — Mode::Optimal) | 140/280 | coverage = 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)
| track | entered | coverage | quality | failure classes |
|---|---|---|---|---|
| 2014 seq-sat | yes (first entry, 0.17) | 180/280 | coverage + VAL | 4 mem-cap, 96 timeout |
| 2014 seq-agile | yes (first entry, 0.17) | 174/280 | coverage + VAL | 1 early-exit, 1 mem-cap, 104 timeout |
| 2014 tempo-sat | yes (first entry, 0.17) | 124/200 | coverage + VAL | 2 mem-cap, 74 timeout |
| 2014 seq-mco t2 | yes (FIRST ENTRY, 0.25 — the table grows) | 181/280 | wall-clock per competition rule (one instance at a time; 4P+6E box) | 6 mem-cap, 93 timeout |
| 2014 seq-mco t4 | yes (first entry, 0.17) | 184/280 | wall-clock per competition rule (--threads 4, one instance at a time; 4P+6E box) | 7 mem-cap, 89 timeout |
| 2014 seq-mco t8 | yes (FIRST ENTRY, 0.25 — the table grows) | 186/280 | wall-clock per competition rule (one instance at a time; 4P+6E box, t8 oversubscribed by construction) | 11 mem-cap, 83 timeout |
| 2014 seq-opt | yes (first entry, 0.19) | 82/256 | coverage = PROOF RATE (Mode::Optimal, A* + admissible LM-cut, h^max sprint first; every plan certified + VAL) | 174 timeout |
| 2018 seq-sat | yes (first entry, 0.17) | 99/240 | vs 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-opt | yes (FIRST ENTRY, 0.25 — the table grows) | 94/240 | coverage = 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 classical | yes (first entry, 0.17) | 52/140 | vs best-known bounds: 1W/21T/30L, mean quality 0.84 (52 scored) | 17 mem-cap, 71 timeout |
| 2023 seq-sat | yes (FIRST ENTRY, 0.25 — the table grows) | 52/140 | vs best-known bounds: 0W/22T/30L, mean quality 0.84 (52 scored) | 20 mem-cap, 68 timeout |
| 2023 seq-opt | yes (FIRST ENTRY, 0.25 — the table grows) | 33/140 | coverage = 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/140 | OFFICIAL 300 s budget — a competition-methodology ENTRY, not a baseline | 17 mem-cap, 56 timeout |
| 2023 numeric | yes (first entry, 0.17) | 289/400 | field CSVs vendored (ipc-2023n/results) — per-domain comparison in the audit record | 1 engine-reject/error, 52 mem-cap, 58 timeout, 47 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py) |
| 2023 numeric-opt | yes (FIRST ENTRY, 0.25 — the table grows) | 82/400 | coverage = PROOF RATE over the numeric corpus; the track's official field CSV (ipc-2023n/results/opt.csv) is the vs-field referee | 137 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/320 | coverage + VAL; the corpus ships -sat/-opt domain PAIRS, all swept satisficing-style on this first board | 2 early-exit, 94 timeout, 11 solved VAL-unavailable (engine-oracle only; see benchmarks/val-availability.py) |
| 2026 numeric-opt | yes (FIRST ENTRY, 0.21 — the -opt pairs, ⚖️) | 23/60 | coverage = 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 FULL | yes (FIRST ENTRY, 0.25 — the table grows) | 80/260 | coverage = 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
benchmarks/ipc5-scoreboard.md— IPC-5 simple preferences, ferroplan vs the official field.benchmarks/ipc5-qualitative-scoreboard.md— IPC-5 qualitative preferences, reference-grafted with the full W/T/L accounting.benchmarks/ipc67-results.md/benchmarks/ipc67-temporal.md/benchmarks/ipc67-netben.md— the standing IPC-6/7 scoreboards (seq-sat, tempo-sat, net-benefit), per-variant.benchmarks/ipc5-prop.mdand siblings (ipc5-time,ipc5-metric-time,ipc5-constraints) — the 2006 deterministic-track sweeps, first entered in 0.16.benchmarks/ipc7-mco-t{2,4,8}.md— the multi-core rows.
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.