ferroplan 0.8.0

A fast, data-parallel PDDL planner in Rust — FF heuristic with ADL, numeric, derived axioms, PDDL3 preferences, PDDL2.1 temporal, and a goal decomposer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
<p align="center">
  <img src="https://raw.githubusercontent.com/hhh42/ferroplan/main/assets/logo.svg" alt="ferroplan" width="360">
</p>

# ferroplan

[![CI](https://github.com/hhh42/ferroplan/actions/workflows/ci.yml/badge.svg)](https://github.com/hhh42/ferroplan/actions/workflows/ci.yml)
[![docs](https://img.shields.io/badge/docs-mdbook-blue)](https://hhh42.github.io/ferroplan)
[![live demo](https://img.shields.io/badge/live_demo-try_in_browser-6c5ce7)](https://hhh42.github.io/ferroplan/demo/index.html)
[![license](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue)](#license)

A fast, data-parallel **PDDL planner** in Rust — a deterministic planning core for
the age of AI.

**The bet:** an LLM should be the *author and supervisor* of a planner, not its
runtime. The same reason 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 — applies one
level up: don't ask an LLM to *be* the planner for a whole village of agents. Have it
author a PDDL domain that then plans deterministically, cheaply, and inspectably at
scale, and let it only *nudge* that domain at runtime. PDDL is the auditable interface
between your intent, the model's authoring, and a fast solver — and `ferroplan` is
that solver.

**Why PDDL, not prompt-spaghetti:**

- **Cost** — a solved domain plans essentially for free; an LLM call per decision per agent does not.
- **Determinism** — same problem, same plan; you can regression-test it.
- **Inspectability** — you can read a domain and an axiom; you cannot read a model's weights.
- **Scale** — a village of agents each replanning is tractable for a fast solver, not as a wall of LLM calls.

> **[▶ Try it live in your browser]https://hhh42.github.io/ferroplan/demo/index.html**> pick a built-in example or paste your own PDDL; it plans entirely client-side via
> WebAssembly, no install. There's also a
> [browser visualizer + block editor]https://hhh42.github.io/ferroplan/gui/index.html.

`ferroplan` is a from-scratch reimplementation of the FF family of planners with a
data-oriented core (bitset states, structure-of-arrays / CSR operator tables),
**enforced hill-climbing** (EHC) with a best-first fallback, parallel grounding
and parallel heuristic evaluation, plus an SGPlan-style partition-and-resolve mode,
PDDL3 preference/metric optimization, and **PDDL2.1 temporal** planning (durative
actions). It ships both a **library** (with a structured, JSON-serializable API)
and the **`ff`** command-line binary — a drop-in for Metric-FF's
`ff -o domain -f problem`.

On classical and ADL benchmarks it runs within ~1.4× of the heavily-optimized C
Metric-FF (EHC reaches goals in dozens of evaluations, not thousands); numeric
trails and IPC-5 preference quality is competitive-not-winning — see
[Benchmarks](#benchmarks).

> Status: **v0.8.0**`ferroplan` + `ferroplan-cli` are on [crates.io]https://crates.io/crates/ferroplan. APIs may shift before 1.0.

> **What's new in 0.8.0 — Pay the Costs.** 0.7 enforced trajectory
> constraints and wrote down the bill; 0.8 pays it. Hard-monitor
> acceptance now rides one forced-terminal END action instead of an
> exponential goal-DNF product (the recorded storage hard fixture drops
> **59,969 grounded ops → 921**), and the monitor transition block —
> byte-identical across every ground action — grounds **once** instead of
> per op: both recorded 15 GB grounding OOMs are gone (storage
> qualitative p07 grounds in **313 ms at 109 MB**; p08 in 676 ms at
> 174 MB), and those two instances produce their **first-ever metrics
> (200 / 261), reported == verified exact**. ESPC now engages on real
> once-only achievement structure instead of monitor artifacts, so the
> storage tail runs on **pure defaults** — qualitative coverage rises
> from 36/40 to **38/40**, every remaining gap still named on the
> [scoreboard]benchmarks/ipc5-qualitative-scoreboard.md. A
> deterministic search memory backstop (byte-model node cap, t1 ≡ t8 by
> construction) guards the wide-state passes. Every change keeps a
> restore hatch; constraint-free inputs are byte-identical
> ([0.8 roadmap]docs/roadmap-0.8.md).

> **What's new in 0.7.0 — Trajectories: enforce the constraint, price the
> preference.** The oldest fence is retired: PDDL3 `(:constraints ...)`
> blocks — rejected-by-design since 0.4.1 — are now ENFORCED on the
> classical path. The six untimed modal operators compile into monitor
> automata riding every action; hard constraints become goal conjuncts,
> soft `(preference name ...)` constraints are priced through the existing
> metric stack with **zero optimizer changes**, and the independent
> verifier folds the ORIGINAL constraint semantics over every replay (now
> grounding quantified bodies — exact on 5 of 6 simple-preferences domains
> too). Measured proof: the IPC-5 *qualitative-preferences* track is
> vendored (5 domains × 8 instances) — **36/40 instances produce a plan
> and a metric** (reported == verified exact on all 11 oracle-checked
> instances), every gap has a named reason, and quadratic `forall`-constraints ground tractably via
> constraint-side static simplification
> ([qualitative scoreboard]benchmarks/ipc5-qualitative-scoreboard.md,
> [0.7 roadmap]docs/roadmap-0.7.md). Timed operators and the temporal
> path still reject by name; `FF_CONSTRAINTS_REJECT=1` restores the
> blanket rejection.

> **What's new in 0.6.0 — Selection: solve the choice, then plan to it.**
> Plan forensics ([the write-up]docs/forensics-tpp.md) proved the remaining
> quality gap on preference domains was a *selection* problem, not a search
> problem — so ferroplan now solves the preference-subset choice **exactly**
> (`selection.rs`: mutex-variable end states, branch-and-bound, an admissible
> bound that can *prove* optimality) and plans to it as a target, and keeps
> init-satisfied "trap" preferences visible to the guidance. Results on pure
> defaults vs SGPlan5, the IPC-5 winner: **storage becomes an 8/8 domain
> sweep** (totals 234 vs 547), **tpp p06 an exact tie**, rovers' totals lead
> widens to 4862 vs 5632, and the suite tally reaches **19W/16T/13L**
> ([scoreboard]benchmarks/ipc5-scoreboard.md) — three domains led under
> both quality conventions, deterministic at any thread count, every default
> change with a restore hatch, every dead end recorded
> ([0.6 roadmap]docs/roadmap-0.6.md).

> **What's new in 0.5.0 — closing on first.** On the vendored IPC-5
> simple-preferences suite, **pure defaults** (one configuration, no env vars,
> deterministic at any thread count) now **lead SGPlan5 — the IPC-5 winner —
> under BOTH quality conventions on three of the six domains**: openstacks
> (wins p04–p08), storage (wins p01–p07), and rovers (wins p04/p06/p07/p08,
> exact ties p01/p05) — with trucks ahead on the domain total and a suite-wide
> instance tally of **19W/14T/15L** ([scoreboard]benchmarks/ipc5-scoreboard.md).
> Under the hood: the ESPC penalty loop **graduated to a deterministically
> budgeted default**, both B&B loops gained **anytime in-sweep tightening + a
> diversified restart ladder** (which broke the storage/tpp plateaus), and
> folded numeric metrics **route through the exact-closure optimizer** (the
> rovers flip). Every default change keeps a restore hatch
> ([tuning reference]https://hhh42.github.io/ferroplan/tuning.html);
> negative results are recorded, not hidden (two seeding levers measured
> neutral, shipped opt-in). See the [CHANGELOG]CHANGELOG.md and the executed
> [0.5 roadmap]docs/roadmap-0.5.md.

> **What's new in 0.4.0** — the PDDL3 preference-metric release, measured
> against the official IPC-5 winner on the vendored simple-preferences suite
> ([scoreboard]benchmarks/ipc5-scoreboard.md): ferroplan now **leads SGPlan5
> on two of the six domains** — openstacks (opt-in `FF_ESPC=1` partitioned
> penalty loop: 19/23/17/**16/21/22/66/87** vs 13/16/12/26/36/33/67/123) and
> storage (plain defaults: **3/5/6/9/46**/145/200/263 vs 5/8/14/17/87/…, up
> from 2/8 coverage) — is **ahead on the trucks total**, and **ties SGPlan5 on
> every tpp and pathways p01–p04 instance**. Under the hood: an
> **exact-closure metric optimizer** (search real states, close the compiled
> preference bookkeeping with a provably-optimal phase tail), **static
> preference simplification** (storage's 62k-instance quadratic forall
> collapses ~97% at compile), barrier-free full-DNF guidance, and a
> **budget-escalating B&B** whose deterministic eval budget
> (`FF_PREF_EVAL_BUDGET`) is a real quality dial. Every change has a restore
> hatch. See the [CHANGELOG]CHANGELOG.md for the full breakdown, including
> 0.3.0's temporal solver depth (65 → 73/75 corpus, default goal-relevance
> pruning, escalation ladder, `Session` API), the animator's transport bar and
> Gantt view, and the move to **Bevy 0.19**.

## Features

- **EHC + best-first** — enforced hill-climbing with helpful actions (the FF
  speed default), falling back to weighted best-first when it stalls. Selectable
  per solve (`--search auto|ehc|best-first|…`).
- **FF heuristic** — delete-relaxation relaxed-plan heuristic over a
  data-oriented task, deferred evaluation, tunable `g`/`h` weights.
- **Data parallelism** — parallel grounding and parallel batch heuristic
  evaluation (`std::thread`); the plan found is identical for any thread count.
- **PDDL coverage** — STRIPS, typing, negative/disjunctive preconditions,
  numeric fluents (Metric-FF style), **ADL** (conditional effects,
  `forall`/`exists`, equality), and **derived predicates / axioms** (`:derived`,
  static/stratified — closed into the initial state via a datalog fixpoint).
- **PDDL3 preferences** — soft goal preferences (incl. `forall`-quantified and
  precondition preferences) compiled away, with anytime branch-and-bound metric
  optimization. *(Exact-optimal on small/medium instances; best-found, flagged,
  on the largest — see [Limitations]#limitations.)*
- **PDDL2.1 temporal**`:durative-action`s with `at start`/`over all`/`at end`
  conditions & effects, **constant or parameter-dependent durations**, and
  required concurrency, via a decision-epoch forward search; output in the IPC
  temporal plan format (`t: (action) [dur]`) with a makespan.
- **SGPlan-style partitioning** — an optional partition-and-resolve mode.
- **Robust** — a published library shouldn't crash: malformed/pathological PDDL
  (incl. deeply-nested forms) returns a typed error, never a panic.
- **Structured output** — the library returns typed, `serde`-serializable
  results; the CLI emits classic FF text **or** JSON.

## GUI

[`ferroplan-bevy`](crates/ferroplan-bevy) is a Bevy app that visualizes a
domain+problem as a typed graph, animates the plan, and edits both problems and
domains in a Blockly-style block editor (`cargo run -p ferroplan-bevy`).

![ferroplan-bevy visualizing a delivery problem as a typed graph](https://raw.githubusercontent.com/hhh42/ferroplan/main/book/src/images/graph.png)

## Install / build

```sh
# install the `ff` CLI from crates.io
cargo install ferroplan-cli    # puts `ff` on your PATH

# …or build from a clone
cargo build --release          # produces target/release/ff
cargo run --release --bin ff -- -o domain.pddl -f problem.pddl
```

As a library dependency: `cargo add ferroplan` (see [Library](#library) below).

## CLI (`ff`)

```sh
# drop-in: classic Metric-FF text output
ff -o domain.pddl -f problem.pddl

# structured JSON solution
ff -o domain.pddl -f problem.pddl --json

# pick a mode / search strategy
ff -o domain.pddl -f problem.pddl --mode partition
ff -o domain.pddl -f problem.pddl --search best-first --weight-h 3

# temporal (durative actions) — auto-detected; prints the IPC temporal plan
ff -o temporal-domain.pddl -f problem.pddl --mode temporal

# decompose a too-big temporal goal into ordered, individually-solved contracts
# (the "LLM authors, planner decomposes" bet, made inspectable — text or --json)
ff -o temporal-domain.pddl -f problem.pddl --mode temporal --decompose

# self-contained JSON job: {"domain": "...", "problem": "...", "options": {...}}
ff --json-request job.json
```

Run `ff --help` for all flags (`--search`, `--weight-g/--weight-h`,
`--max-evaluated`, `--satisfice`, `--threads`, …).

## Library

```rust
use ferroplan::{solve, Options};

let domain  = std::fs::read_to_string("domain.pddl")?;
let problem = std::fs::read_to_string("problem.pddl")?;

// Syntax-check before solving (no grounding/solving) — fast authoring feedback.
let report = ferroplan::parse(&domain);
assert!(report.ok, "{:?}", report.error);

let solution = ferroplan::solve(&domain, &problem, &Options::default())?;
if let Some(plan) = solution.plan {
    for step in &plan.steps {
        println!("{} {}", step.action, step.args.join(" "));
    }
    println!("metric: {:?}", plan.metric);
}
# Ok::<(), ferroplan::SolveError>(())
```

The public, `serde`-serializable surface: **`solve`** (plan a domain+problem),
**`decompose`** (split a too-big temporal goal into validated contracts),
**`parse`** (syntax-check + summarize PDDL without solving),
**`Session`** (ground once, replan many — for a live loop that re-solves the same
world every tick), and **`plan::validate_plan`** (independently check a plan). See
[`examples/`](crates/ferroplan/examples) for `solve`, `parse`, `json_api`, and
`replan` (`Session` vs. re-solving from scratch, with timings).

## Configuration

Every solver knob lives on one `Options` struct (library-first, `serde`-
serializable). The CLI flags and JSON job options map to the same fields; omitted
JSON fields fall back to the defaults shown.

```rust
ferroplan::solve(&domain, &problem, &ferroplan::Options {
    mode:            Mode::Auto,        // auto | ff | partition | pddl3 | temporal
    search:          Search::Auto,      // auto | ehc | best-first | ehc-then-best-first
    helpful_actions: true,              // helpful-action pruning (EHC)
    weight_g:        1.0,               // best-first path-length weight
    weight_h:        5.0,               // best-first heuristic weight  (1·g + 5·h)
    threads:         0,                 // 0 = auto
    max_evaluated:   None,              // search node cap
    optimize:        true,              // PDDL3: optimize metric vs. satisfice
    ..Default::default()                // every field is optional
})?;
```

CLI equivalents: `--mode`, `--search`, `--no-helpful`, `--weight-g/--weight-h`,
`--max-evaluated`, `--satisfice`, `--threads`. Via JSON:
`{"domain": "...", "problem": "...", "options": {"search": "best-first"}}`.

## Workspace layout

| crate | what |
|---|---|
| [`ferroplan`]crates/ferroplan | the library: engine + modes + `solve` / `decompose` / `Session` API |
| [`ferroplan-cli`]crates/ferroplan-cli | the `ff` binary (clap + JSON) |
| [`ferroplan-mcp`]crates/ferroplan-mcp | an MCP server exposing `solve` / `validate` / `decompose` over stdio — so an LLM agent can author PDDL and drive the planner |
| [`ferroplan-bevy`]crates/ferroplan-bevy | Bevy app: visualize, inspect & animate a domain+problem (`cargo run -p ferroplan-bevy [domain.pddl problem.pddl]`) |
| [`ferroplan-wasm`]crates/ferroplan-wasm | WebAssembly binding behind the client-side [browser demo]https://hhh42.github.io/ferroplan/demo/index.html`solve` a domain+problem entirely in-page |
| [`ferroplan-py`]crates/ferroplan-py | Python binding (`pip`-installable extension module) exposing `solve` for embedding in Python tools |

## Examples

[`examples/`](examples) collects worked domains that exercise the full feature set
— see the [examples index](examples/README.md) for a feature-by-feature map and a
suggested reading order. Highlights:

- [`rpg`]examples/rpg — the clean intro: durative actions with renewable
  (workers) and consumable resources, gather → craft → build.
- [`rpg-world`]examples/rpg-world — a ~120-action crafting/economy domain
  (durative actions, numeric resources, renewable capacities, a reachability
  axiom) with a corpus of validated contracts, a flavor-×-scale [`suite/`]examples/rpg-world/suite,
  an adversarial [`hard/`]examples/rpg-world/hard batch, and an
  [industrial-city]examples/rpg-world/industrial-city showcase that runs a whole
  metal/stone/wood industry as a pipeline of contracts.
- [`cabin`]examples/cabin — deep numeric build plus a durative "crew" twin
  (makespan vs. crew size, skill-gated scheduling).
- [`reachability`]examples/reachability — the worked **derived-axiom**
  (`:derived`) example: static transitive-closure reachability.
- [`village`]examples/village — a full-ADL stress test (`when`, `forall`+`when`,
  `or`, negation) over durative + numeric state.
- [`villagers`]examples/villagers — a data-driven recipe planner with numeric
  **PDDL3 metric** optimization; the "embed in a game" model.
- [`logistics`]examples/logistics — transshipment: per-location goods, trucks
  with capacity, a train line.
- [`jobshop`]examples/jobshop — scheduling with machine-exclusion (scales to 100
  concurrent jobs).
- [`BORDERS.md`]examples/BORDERS.md — a measured map of where one-shot planning
  solves vs. where a goal must be decomposed into contracts. The **`decompose` API /
  `ff --decompose`** acts on that border: it splits a too-big temporal goal into
  ordered, individually-solved contracts and stitches them into one validated plan
  (e.g. `hard/order-8` → 8 named contracts), falling back to a monolithic solve when
  a goal can't be split.

## Benchmarks

Classical and ADL coverage/speed are measured against the C **Metric-FF**; the
IPC-5 preference quality is measured against **SGPlan5** (the IPC-5 winner), over
a subset of the IPC contest suites. Headline (native Metric-FF, EHC default):

| category | ferroplan solved | speed vs Metric-FF |
|---|---:|---|
| STRIPS | 40/40 | 0.71× (~1.4× slower) |
| ADL | 23/24 | 0.77× (~1.3× slower) |
| numeric | 36/40 | 0.22× |

The IPC-5 preference scoreboard (vs SGPlan5):
[`benchmarks/ipc5-scoreboard.md`](benchmarks/ipc5-scoreboard.md); classical/numeric
detail: [`benchmarks/results.md`](benchmarks/results.md) (and the
[project site](https://hhh42.github.io/ferroplan)). The oracles are not bundled
(GPL / non-commercial licences) — reproduce per
[`benchmarks/COMPARING.md`](benchmarks/COMPARING.md).

**Profiling & perf tracking:** [`PROFILING.md`](PROFILING.md) — a deterministic
metrics harness (`benchmarks/perf.py run`/`compare` against a committed baseline,
so improvement/regression is measurable across machines) plus the samply /
flamegraph / criterion-baseline workflow for finding and tracking hotspots.

## Limitations

- **Numeric** trails Metric-FF: EHC's helpful-action lookahead stalls on some
  numeric domains and falls back to (complete, slower) best-first.
- **IPC-5 preferences**: compiled away, then optimized by an **exact-closure
  metric optimizer** with anytime sweeps, a diversified restart ladder, and the
  deterministically-budgeted ESPC penalty loop — all defaults. Coverage is
  **full (48/48)** on the vendored simple-preferences suite and ferroplan
  **leads SGPlan5 under both quality conventions on three of the six domains**
  (openstacks, storage, rovers), with trucks ahead on totals — see the
  [scoreboard]benchmarks/ipc5-scoreboard.md. The tpp/pathways p05–p08 tails
  still trail (best-found, flagged *not proven optimal*, measured
  direction-bound); the design record for the remaining work is in
  [`docs/espc-preferences-spec.md`]docs/espc-preferences-spec.md and
  [`docs/roadmap-0.5.md`]docs/roadmap-0.5.md.
- **PDDL3 trajectory constraints** (`(:constraints ...)`): the six untimed
  modal operators (`always`, `sometime`, `at-most-once`, `sometime-after`,
  `sometime-before`, `at end`) are **enforced on the classical path** — compiled
  into monitor automata and cross-checked by the independent verifier. Hard
  constraints latch a forced-terminal END action (linear in monitors — the
  0.8 construction; goal-side compilation via `FF_NO_TRAJ_END=1`); soft
  `(preference name ...)` constraints are **priced through the PDDL3 metric
  machinery** like native goal preferences (the IPC-5
  *qualitative-preferences* suite is vendored and scored at **38/40** — see
  [`benchmarks/ipc5-qualitative-scoreboard.md`]benchmarks/ipc5-qualitative-scoreboard.md).
  The timed operators (`within`, `hold-during`, `hold-after`,
  `always-within`) and the temporal path are still **rejected by name**
  rather than silently dropped (`FF_CONSTRAINTS_REJECT=1` restores the
  pre-0.7 blanket rejection).
- **Temporal**: durative actions with constant or parameter-dependent durations
  and required concurrency are supported, and **plans are VAL-validated** on real
  IPC temporal domains (44/45 produced plans valid — see
  [`benchmarks/temporal-results.md`]benchmarks/temporal-results.md). Coverage is
  currently search-limited (the decision-epoch search times out on large
  instances). Duration *inequalities* (`(>= ?duration L)` / `(<= ?duration U)` /
  `and` ranges) are supported — the search commits to the shortest feasible
  duration — as are **timed initial literals** (`(at <time> <literal>)` in `:init`).
  Continuous (`#t`) effects are not yet supported.
- **Derived predicates** (`:derived`): static/stratified axioms are supported
  (closed into the initial state); *dynamic* derived predicates (bodies over
  changing facts) are not yet.

## Acknowledgments

This project is built in deep respect for the planners that came before it.

**SGPlan** (SGPlan5 / SGPlan6), by Chih-Wei Hsu and Benjamin W. Wah at the
University of Illinois, has been the standard to beat in this corner of automated
planning for the better part of two decades — the IPC-winning system whose
constraint-partitioning and extended-saddle-point penalty-coordination ideas still
define the state of the art for satisficing planning with preferences and with
temporal/resource constraints. I've followed that line of research for many years,
and to build something that even comes *close* to it on a slice of the benchmarks
is, genuinely, an honor. Enormous credit to that team for the depth, rigor, and
sheer durability of the work — ferroplan is in no small part an attempt to learn
from it, in Rust.

Equal thanks to Jörg Hoffmann's **FF / Metric-FF**, whose relaxed-plan heuristic
and enforced hill-climbing are the backbone of this engine; to the IPC organizers
and domain authors whose benchmarks make progress measurable; and to Derek Long and
Maria Fox's **VAL**, used here to independently validate the temporal plans.

## License

Dual-licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE),
at your option.