metalforge 0.1.0

forge: a deterministic metaheuristic optimization substrate in Rust. Unified Problem/MultiProblem/Anneal traits; DDS, SCE-UA, DE, PSO, NSGA-II and simulated annealing; reproducible by seed; optional Rayon parallelism.
Documentation
//! NSGA-II multi-objective tests: convergence to a known Pareto front,
//! determinism, and well-formed output.

use forge_core::problem::multi_func;
use forge_core::testfn::Zdt1;
use forge_core::{NsgaII, Termination};

/// Mean distance from the returned front to the analytical ZDT1 front
/// (`f2 = 1 − √f1`): a simple generational-distance proxy.
fn mean_distance_to_zdt1_front(front: &forge_core::ParetoFront) -> f64 {
    let mut total = 0.0;
    for o in front.objective_vectors() {
        let (f1, f2) = (o[0], o[1]);
        total += (f2 - Zdt1::front_f2(f1)).abs();
    }
    total / front.len() as f64
}

#[test]
fn nsga2_converges_to_zdt1_front() {
    let problem = Zdt1::new(10);
    let nsga = NsgaII {
        pop_size: 100,
        ..NsgaII::default()
    };
    let front = nsga.optimize(&problem, &Termination::budget(20_000));

    // A healthy front: many non-dominated points...
    assert!(front.len() >= 50, "front too small: {}", front.len());
    // ...close to the true front...
    let gd = mean_distance_to_zdt1_front(&front);
    assert!(gd < 0.02, "mean distance to ZDT1 front too high: {gd}");
    // ...and spread across the f1 range (good diversity).
    let f1s: Vec<f64> = front.objective_vectors().map(|o| o[0]).collect();
    let (min, max) = f1s
        .iter()
        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
            (lo.min(v), hi.max(v))
        });
    assert!(min < 0.1 && max > 0.9, "poor spread: f1 in [{min}, {max}]");
}

#[test]
fn nsga2_front_is_internally_non_dominated() {
    let problem = Zdt1::new(8);
    let front = NsgaII::default().optimize(&problem, &Termination::budget(8000));
    let objs: Vec<&[f64]> = front.objective_vectors().collect();
    // No solution on the returned front may dominate another.
    for (i, a) in objs.iter().enumerate() {
        for (j, b) in objs.iter().enumerate() {
            if i == j {
                continue;
            }
            let dominates = a[0] <= b[0] && a[1] <= b[1] && (a[0] < b[0] || a[1] < b[1]);
            assert!(!dominates, "solution {i} dominates {j} on the front");
        }
    }
}

#[test]
fn nsga2_is_deterministic() {
    let problem = Zdt1::new(6);
    let t = Termination::budget(5000);
    let a = NsgaII::default().optimize(&problem, &t);
    let b = NsgaII::default().optimize(&problem, &t);
    assert_eq!(a.solutions, b.solutions);
    assert_eq!(a.evaluations, b.evaluations);
}

#[test]
fn nsga2_solves_schaffer_n1() {
    // Schaffer N.1: minimize x² and (x−2)²; Pareto-optimal x ∈ [0, 2].
    let sch = multi_func(vec![(-5.0, 5.0)], 2, |x| {
        vec![x[0] * x[0], (x[0] - 2.0).powi(2)]
    });
    let front = NsgaII {
        pop_size: 60,
        ..NsgaII::default()
    }
    .optimize(&sch, &Termination::budget(6000));

    // Every Pareto-optimal decision should fall within [0, 2] (small slack).
    for s in &front.solutions {
        assert!(
            (-0.05..=2.05).contains(&s.x[0]),
            "x outside Pareto set: {}",
            s.x[0]
        );
    }
}

#[test]
fn nsga2_respects_budget_roughly() {
    let front = NsgaII::default().optimize(&Zdt1::new(5), &Termination::budget(3000));
    // May overshoot by less than one generation (pop_size) but not more.
    assert!(
        front.evaluations < 3000 + 100,
        "spent {}",
        front.evaluations
    );
}