ferroplan 0.9.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
//! ESPC (`FF_ESPC`) penalty-resolution loop: independent metric verification,
//! strict improvement over the default optimizer, no-regression floor, and
//! thread-count determinism.
//!
//! One sequential test on purpose: `FF_ESPC` is a process-global env toggle, and a
//! separate test binary running a single test means it can never race the other
//! suites. The determinism assertion uses p01, which terminates by the (fully
//! deterministic) stall/saddle conditions well within the wall-clock backstop, so
//! the per-search thread-independence carries through to the whole loop.

use ferroplan::{solve, Options, Plan};

fn read(inst: &str) -> (String, String) {
    let base = concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../benchmarks/ipc/pref/openstacks"
    );
    (
        std::fs::read_to_string(format!("{base}/domain.pddl")).unwrap(),
        std::fs::read_to_string(format!("{base}/{inst}.pddl")).unwrap(),
    )
}

fn steps(plan: &Plan) -> Vec<(String, Vec<String>)> {
    plan.steps
        .iter()
        .map(|s| (s.action.clone(), s.args.clone()))
        .collect()
}

#[test]
#[ignore = "heavy IPC solve (~2-6 min): ESPC penalty-resolution loop on openstacks/p01. \
            Opt-in via `cargo test -- --include-ignored`; CI runs it in release."]
fn espc_openstacks_p01_improves_verifies_and_is_deterministic() {
    let (d, p) = read("p01");

    // Pre-ESPC path (0.5 graduated ESPC to default-on; FF_NO_ESPC restores the
    // closure-optimizer-only path): the independent verifier must agree with
    // the reported metric (guards against crediting a metric the plan did not
    // actually earn).
    std::env::set_var("FF_NO_ESPC", "1");
    let base = solve(&d, &p, &Options::default()).unwrap();
    std::env::remove_var("FF_NO_ESPC");
    let bplan = base.plan.expect("default plan");
    let bm = bplan.metric.expect("default metric");
    let bv = ferroplan::verify::verify(&d, &p, &steps(&bplan)).unwrap();
    assert!(bv.hard_goal_met, "default plan must meet the hard goal");
    assert!(
        (bv.metric - bm).abs() < 1e-6,
        "default reported {bm} != independently verified {}",
        bv.metric
    );

    // ESPC path — now the DEFAULT (no env needed) — at two thread counts. Its
    // outer budget is a deterministic eval pool (FF_ESPC_EVAL_BUDGET), so the
    // whole loop is thread-count independent by construction; no wall-clock
    // pin needed (FF_ESPC_TIME_MS applies only when set, and is off here).
    let o1 = Options {
        threads: 1,
        ..Options::default()
    };
    let o8 = Options {
        threads: 8,
        ..Options::default()
    };
    let s1 = solve(&d, &p, &o1).unwrap();
    let s8 = solve(&d, &p, &o8).unwrap();

    let p1 = s1.plan.expect("espc plan (t1)");
    let m1 = p1.metric.expect("espc metric");
    let v1 = ferroplan::verify::verify(&d, &p, &steps(&p1)).unwrap();
    assert!(v1.hard_goal_met, "espc plan must meet the hard goal");
    assert!(
        (v1.metric - m1).abs() < 1e-6,
        "espc reported {m1} != independently verified {}",
        v1.metric
    );
    assert!(
        m1 < bm,
        "espc metric {m1} must strictly beat the default {bm}"
    );
    assert!(
        m1 <= 19.0,
        "espc openstacks/p01 metric {m1} regressed above the locked 19 \
         (partitioned coupling, increment 2)"
    );

    // Thread count must change neither the plan nor the metric.
    let p8 = s8.plan.expect("espc plan (t8)");
    assert_eq!(
        steps(&p1),
        steps(&p8),
        "espc plan differs across thread counts"
    );
    assert_eq!(
        m1,
        p8.metric.expect("espc metric t8"),
        "espc metric differs across thread counts"
    );

    // FF_ESPC_MONO debug hatch: the pre-partition monolithic loop must still
    // run, verify, and hold the old quality floor (smoke, small eval budget).
    std::env::set_var("FF_ESPC_MONO", "1");
    std::env::set_var("FF_ESPC_EVAL_BUDGET", "600000");
    let sm = solve(&d, &p, &Options::default()).unwrap();
    std::env::remove_var("FF_ESPC_MONO");
    std::env::remove_var("FF_ESPC_EVAL_BUDGET");
    let pm = sm.plan.expect("espc mono plan");
    let mm = pm.metric.expect("espc mono metric");
    let vm = ferroplan::verify::verify(&d, &p, &steps(&pm)).unwrap();
    assert!(vm.hard_goal_met, "espc mono plan must meet the hard goal");
    assert!(
        (vm.metric - mm).abs() < 1e-6,
        "espc mono reported {mm} != independently verified {}",
        vm.metric
    );
    // Mono's floor is its own penalty-free compiled-goal B&B (historically 63),
    // NOT the default path — the default has since moved to the exact-closure
    // optimizer (49 on this box), which mono deliberately predates.
    assert!(
        mm <= 63.0,
        "espc mono metric {mm} regressed above its 63 compiled-goal floor"
    );
}