swarmkit 0.1.0

Composable particle swarm optimization with nested searches
Documentation
#![cfg(test)]

use super::fixtures::{Bounds, RandomInit, RectBoundary, Vector2f};
use crate::{
    Best, Contextful, FitCalc, GBestMover, IntoGBestSearcher as _, PSOCoeffs, ParticleInit as _,
    ParticleMover as _, Searcher,
};
use rand::rngs::ThreadRng;

struct SphereFitCalc;

impl Contextful for SphereFitCalc {
    type TContext = ();
}

impl FitCalc for SphereFitCalc {
    type T = Vector2f;
    fn calculate_fit(&self, v: Vector2f) -> f64 {
        -(v.x * v.x + v.y * v.y)
    }
}

#[test]
fn gbest_searcher_monotonically_improves_on_sphere() {
    let bounds = Bounds {
        min: Vector2f::new(-10.0, -10.0),
        max: Vector2f::new(10.0, 10.0),
    };
    let boundary = RectBoundary { bounds };
    let fit_calc = SphereFitCalc;
    let init = RandomInit {
        particle_count: 30,
        bounds,
    };
    let config = PSOCoeffs {
        inertia: 0.4,
        cognitive_coeff: 1.5,
        social_coeff: 1.5,
    };

    let mut group = init.init(&mut ThreadRng::default());
    let mut searcher = GBestMover::<Vector2f>::new(config)
        .bounded_by(boundary)
        .into_gbest_searcher(fit_calc);

    let history: Vec<Best<Vector2f>> = searcher.iter(50, &mut group, None).collect();

    assert!(
        !history.is_empty(),
        "searcher should yield at least one iteration"
    );

    // gbest is the best-ever-seen position, so it must be monotone.
    for window in history.windows(2) {
        assert!(
            window[1].best_fit >= window[0].best_fit,
            "gbest regressed: {} -> {}",
            window[0].best_fit,
            window[1].best_fit,
        );
    }

    // 30 particles × 50 iterations on a smooth bowl reliably converges
    // inside r² < 0.1, i.e. fitness > -0.1.
    let final_fit = history.last().unwrap().best_fit;
    assert!(
        final_fit > -0.1,
        "expected convergence near origin (best_fit > -0.1), got {final_fit}",
    );
}

// NestedMover write-back test: synthetic stubs (no real GBestSearcher) so
// the assertion targets the NestedMover plumbing in isolation.

use crate::{
    Group, NestedMover, Particle, ParticleInitDependent, ParticleRefFrom, ParticleRefMut, SetTo,
};
use rand::{Rng, SeedableRng as _};

#[derive(Copy, Clone, Default, Debug, PartialEq)]
struct Compound {
    outer: Vector2f,
    inner: Vector2f,
}

/// `#[repr(transparent)]` makes the pointer cast in `divide_from` sound.
#[derive(Copy, Clone, Default, Debug, PartialEq)]
#[repr(transparent)]
struct InnerView(Vector2f);

#[expect(
    unsafe_code,
    clippy::multiple_unsafe_ops_per_block,
    reason = "deliberate transparent-repr pointer cast for ParticleRefFrom test fixture; the three derefs share one SAFETY argument"
)]
impl ParticleRefFrom for InnerView {
    type TSource = Compound;
    fn divide_from<'a>(source: &'a mut ParticleRefMut<'_, Compound>) -> ParticleRefMut<'a, Self>
    where
        Self: Copy,
    {
        // SAFETY: `InnerView` is `#[repr(transparent)]` over `Vector2f`,
        // so the pointer reinterpretations have identical layout and alignment.
        unsafe {
            ParticleRefMut {
                pos: &mut *std::ptr::from_mut::<Vector2f>(&mut source.pos.inner).cast::<Self>(),
                vel: &mut *std::ptr::from_mut::<Vector2f>(&mut source.vel.inner).cast::<Self>(),
                fit: source.fit,
                best_pos: &mut *std::ptr::from_mut::<Vector2f>(&mut source.best_pos.inner)
                    .cast::<Self>(),
                best_fit: source.best_fit,
            }
        }
    }
}

impl SetTo for InnerView {
    type TTarget = Compound;
    fn set_to_ref_mut(&self, target: &mut ParticleRefMut<'_, Compound>) {
        target.pos.inner = self.0;
    }
}

struct StubInit;
impl ParticleInitDependent for StubInit {
    type TIn = Compound;
    type TOut = InnerView;

    fn init_pos<R: Rng>(&self, _: &mut R, _: Compound) -> Vec<InnerView> {
        vec![InnerView::default()]
    }
    fn init_vel<R: Rng>(&self, _: &mut R, _: Compound) -> Vec<InnerView> {
        vec![InnerView::default()]
    }
    fn init_dep<R: Rng>(&self, _: &mut R, _: Compound) -> Group<InnerView> {
        Group::from(vec![Particle {
            pos: InnerView::default(),
            vel: InnerView::default(),
            fit: 0.0,
            best_pos: InnerView::default(),
            best_fit: f64::MIN,
        }])
    }
}

/// Returns the configured best on every `next` call; whatever we put in
/// `best.best_pos` is what `NestedMover` will write back.
#[derive(Clone)]
struct StubSearcher {
    best: Best<InnerView>,
}
impl Contextful for StubSearcher {
    type TContext = Compound;
}
impl Searcher for StubSearcher {
    type TUnit = InnerView;
    fn init(&mut self, _: &mut Group<InnerView>) {}
    fn next(&mut self, _: &mut Group<InnerView>) -> &Best<InnerView> {
        &self.best
    }
    fn reseed<R: Rng>(&mut self, _: &mut R) {}
}

#[test]
fn nested_mover_writes_inner_best_and_leaves_outer_untouched() {
    let outer_initial = Vector2f::new(1.0, 2.0);
    let inner_initial = Vector2f::new(3.0, 4.0);
    let inner_target = Vector2f::new(99.0, -99.0);

    let stub_searcher = StubSearcher {
        best: Best {
            best_fit: 0.0,
            best_pos: InnerView(inner_target),
        },
    };
    let nested = NestedMover::new(1, stub_searcher, StubInit);

    let mut outer_group = Group::<Compound>::from(vec![Particle {
        pos: Compound {
            outer: outer_initial,
            inner: inner_initial,
        },
        vel: Compound::default(),
        fit: 0.0,
        best_pos: Compound::default(),
        best_fit: f64::MIN,
    }]);

    let mut rng = rand::rngs::SmallRng::from_seed([0u8; 32]);
    let common = Best::<Compound>::new();
    nested.update(&common, &mut rng, 0, &mut outer_group[0].as_ref_mut());

    let after_pos = outer_group[0].pos;
    assert_eq!(
        after_pos.outer, outer_initial,
        "outer field must not be modified by the inner search",
    );
    assert_eq!(
        after_pos.inner, inner_target,
        "inner field must be overwritten with the inner search's best",
    );
}