metalforge 0.3.0

forge: a deterministic metaheuristic optimization substrate in Rust. Unified Problem/MultiProblem/Anneal traits; DDS, SCE-UA, DE, L-SHADE, L-SRTDE, PSO, CMA-ES, NSGA-II/III, SMS-EMOA, simulated annealing, parallel tempering and GLUE uncertainty; 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::{Dtlz2, Zdt1};
use forge_core::{NsgaII, NsgaIII, SmsEmoa, 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]
        );
    }
}

/// Mean distance of the front to the DTLZ2 unit sphere: `|√(Σfᵢ²) − 1|`.
fn mean_distance_to_unit_sphere(front: &forge_core::ParetoFront) -> f64 {
    let mut total = 0.0;
    for o in front.objective_vectors() {
        let norm = o.iter().map(|v| v * v).sum::<f64>().sqrt();
        total += (norm - 1.0).abs();
    }
    total / front.len() as f64
}

#[test]
fn nsga3_converges_on_dtlz2_three_objectives() {
    // 3-objective DTLZ2: the Pareto front is the unit sphere in the positive
    // orthant. NSGA-III should drive the population onto it with good spread.
    let problem = Dtlz2::new(3, 12);
    let nsga = NsgaIII {
        pop_size: 92,
        divisions: 12,
        ..NsgaIII::default()
    };
    let front = nsga.optimize_within(&problem, &Termination::budget(30_000));

    assert!(front.len() >= 50, "front too small: {}", front.len());
    // Close to the sphere (g ≈ 0).
    let gd = mean_distance_to_unit_sphere(&front);
    assert!(gd < 0.03, "mean distance to DTLZ2 sphere too high: {gd}");
    // Spread: the front should reach toward each objective axis (some solution
    // with f_k ≈ 1 while the others ≈ 0) for every objective.
    for k in 0..3 {
        let reaches_axis = front
            .objective_vectors()
            .any(|o| o[k] > 0.9 && (0..3).filter(|&j| j != k).all(|j| o[j] < 0.2));
        assert!(reaches_axis, "front does not reach axis {k}");
    }
}

#[test]
fn nsga3_front_is_internally_non_dominated() {
    let problem = Dtlz2::new(3, 10);
    let front = NsgaIII::default().optimize_within(&problem, &Termination::budget(12_000));
    let objs: Vec<&[f64]> = front.objective_vectors().collect();
    for (i, a) in objs.iter().enumerate() {
        for (j, b) in objs.iter().enumerate() {
            if i == j {
                continue;
            }
            let dominates =
                a.iter().zip(*b).all(|(x, y)| x <= y) && a.iter().zip(*b).any(|(x, y)| x < y);
            assert!(
                !dominates,
                "solution {i} dominates {j} on the NSGA-III front"
            );
        }
    }
}

#[test]
fn nsga3_is_deterministic() {
    let problem = Dtlz2::new(3, 9);
    let t = Termination::budget(8000);
    let a = NsgaIII::default().optimize_within(&problem, &t);
    let b = NsgaIII::default().optimize_within(&problem, &t);
    assert_eq!(a.solutions, b.solutions);
    assert_eq!(a.evaluations, b.evaluations);
}

#[test]
fn sms_emoa_converges_to_zdt1_front() {
    // SMS-EMOA's hypervolume selection should drive a tight, well-spread
    // bi-objective front.
    let problem = Zdt1::new(10);
    let front = SmsEmoa::default().optimize(&problem, &Termination::budget(20_000));
    assert!(front.len() >= 50, "front too small: {}", front.len());
    let gd = mean_distance_to_zdt1_front(&front);
    assert!(gd < 0.02, "mean distance to ZDT1 front too high: {gd}");
    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 sms_emoa_is_deterministic() {
    let problem = Zdt1::new(6);
    let t = Termination::budget(4000);
    let a = SmsEmoa::default().optimize(&problem, &t);
    let b = SmsEmoa::default().optimize(&problem, &t);
    assert_eq!(a.solutions, b.solutions);
    assert_eq!(a.evaluations, b.evaluations);
}

#[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
    );
}

#[test]
fn moead_converges_to_zdt1_front() {
    use forge_core::Moead;
    let problem = Zdt1::new(10);
    let front = Moead::default().optimize(&problem, &Termination::budget(20_000));
    assert!(front.len() >= 50, "front too small: {}", front.len());
    let gd = mean_distance_to_zdt1_front(&front);
    assert!(gd < 0.02, "mean distance to ZDT1 front too high: {gd}");
    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 moead_is_deterministic() {
    use forge_core::Moead;
    let problem = Zdt1::new(6);
    let t = Termination::budget(5000);
    let a = Moead::default().optimize(&problem, &t);
    let b = Moead::default().optimize(&problem, &t);
    assert_eq!(a.solutions, b.solutions);
    assert_eq!(a.evaluations, b.evaluations);
}

#[test]
fn padds_converges_on_zdt1() {
    use forge_core::Padds;
    let problem = Zdt1::new(10);
    let front = Padds::default().optimize(&problem, &Termination::budget(20_000));
    assert!(front.len() >= 20, "archive too small: {}", front.len());
    let gd = mean_distance_to_zdt1_front(&front);
    assert!(gd < 0.05, "mean distance to ZDT1 front too high: {gd}");
    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.2 && max > 0.8, "poor spread: f1 in [{min}, {max}]");
}

#[test]
fn padds_front_is_internally_non_dominated_and_deterministic() {
    use forge_core::Padds;
    let problem = Zdt1::new(8);
    let t = Termination::budget(6000);
    let a = Padds::default().optimize(&problem, &t);
    let b = Padds::default().optimize(&problem, &t);
    assert_eq!(a.solutions, b.solutions);
    let objs: Vec<&[f64]> = a.objective_vectors().collect();
    for (i, x) in objs.iter().enumerate() {
        for (j, y) in objs.iter().enumerate() {
            if i == j {
                continue;
            }
            let dominates = x[0] <= y[0] && x[1] <= y[1] && (x[0] < y[0] || x[1] < y[1]);
            assert!(!dominates, "archive member {i} dominates {j}");
        }
    }
}