rustsim-core 0.0.1

Core ABM engine: agents, models, stores, schedulers, stepping, data collection
Documentation
//! Stress test: 1,000,000 agents with the StandardModel + Fastest scheduler.
//!
//! Validates that the ABM engine can:
//! 1. Populate 1M agents without excessive allocation time
//! 2. Step all 1M agents per tick within a reasonable time budget
//! 3. Maintain deterministic results across runs
//! 4. Handle deferred removals at scale

use rand::rngs::StdRng;
use rand::SeedableRng;
use rustsim_core::{prelude::*, step_context::StepContext};
mod support;
use std::time::Instant;
use support::NothingSpace;

const AGENT_COUNT: u64 = 1_000_000;
const STEP_COUNT: usize = 10;

// --- Minimal agent: 24 bytes ---
#[derive(Debug, Clone)]
struct Particle {
    id: AgentId,
    x: f32,
    vx: f32,
}

impl Agent for Particle {
    fn id(&self) -> AgentId {
        self.id
    }
}

type ParticleModel =
    StandardModel<NothingSpace, Particle, HashMapStore<Particle>, (), StdRng, Fastest>;

// Trivial step: advance position by velocity
fn particle_step(
    agent: &mut Particle,
    _ctx: &mut StepContext<'_, NothingSpace, Particle, (), StdRng, Fastest>,
) {
    agent.x += agent.vx;
}

#[test]
fn one_million_agents_hashmap_store() {
    // --- Phase 1: Populate ---
    let t0 = Instant::now();

    let mut store = HashMapStore::new();
    for i in 1..=AGENT_COUNT {
        store.insert(Particle {
            id: i,
            x: 0.0,
            vx: 0.001,
        });
    }
    assert_eq!(store.len(), AGENT_COUNT as usize);

    let populate_ms = t0.elapsed().as_millis();
    eprintln!(
        "[HashMapStore] Populated {} agents in {} ms",
        AGENT_COUNT, populate_ms
    );

    let mut model = ParticleModel::new(
        store,
        NothingSpace,
        Fastest::new(),
        (),
        StdRng::seed_from_u64(42),
        Some(Box::new(particle_step)),
        None,
        true,
    );

    // --- Phase 2: Step ---
    let t1 = Instant::now();
    model.step_n(STEP_COUNT);
    let step_ms = t1.elapsed().as_millis();
    let per_step_ms = step_ms as f64 / STEP_COUNT as f64;

    eprintln!(
        "[HashMapStore] {} steps x {} agents in {} ms ({:.1} ms/step)",
        STEP_COUNT, AGENT_COUNT, step_ms, per_step_ms
    );

    // --- Phase 3: Verify correctness ---
    assert_eq!(
        model.time(),
        rustsim_core::types::Time::Discrete(STEP_COUNT as u64)
    );

    // Spot-check: agent 1 should have moved exactly STEP_COUNT * 0.001
    let a1 = model.agent(1).unwrap();
    let expected = STEP_COUNT as f32 * 0.001;
    assert!(
        (a1.x - expected).abs() < 1e-5,
        "agent 1: expected x={}, got x={}",
        expected,
        a1.x
    );
    drop(a1);

    // All agents still present
    assert_eq!(model.agents_len(), AGENT_COUNT as usize);

    // --- Phase 4: Determinism ---
    let checksum: f64 = model.agents().map(|a| a.x as f64).sum();
    drop(model);

    // Run again with same seed
    let mut store2 = HashMapStore::new();
    for i in 1..=AGENT_COUNT {
        store2.insert(Particle {
            id: i,
            x: 0.0,
            vx: 0.001,
        });
    }
    let mut model2 = ParticleModel::new(
        store2,
        NothingSpace,
        Fastest::new(),
        (),
        StdRng::seed_from_u64(42),
        Some(Box::new(particle_step)),
        None,
        true,
    );
    model2.step_n(STEP_COUNT);
    let checksum2: f64 = model2.agents().map(|a| a.x as f64).sum();

    assert!(
        (checksum - checksum2).abs() < 1e-6,
        "determinism broken: checksum {} vs {}",
        checksum,
        checksum2
    );

    // --- Timing assertions (generous but meaningful) ---
    assert!(
        populate_ms < 10_000,
        "populating 1M agents should take < 10s (took {} ms)",
        populate_ms
    );
    assert!(
        per_step_ms < 5_000.0,
        "stepping 1M agents should take < 5s/step (took {:.0} ms/step)",
        per_step_ms
    );

    eprintln!("[HashMapStore] All assertions passed.");
    eprintln!("  Populate:    {} ms", populate_ms);
    eprintln!("  Per-step:    {:.1} ms", per_step_ms);
    eprintln!("  Checksum:    {:.6}", checksum);
}

// --- VecStore variant ---

type ParticleModelVec =
    StandardModel<NothingSpace, Particle, VecStore<Particle>, (), StdRng, Fastest>;

#[test]
fn one_million_agents_vec_store() {
    let t0 = Instant::now();

    let mut store = VecStore::new();
    for i in 1..=AGENT_COUNT {
        store.insert(Particle {
            id: i,
            x: 0.0,
            vx: 0.001,
        });
    }
    assert_eq!(store.len(), AGENT_COUNT as usize);

    let populate_ms = t0.elapsed().as_millis();
    eprintln!(
        "[VecStore] Populated {} agents in {} ms",
        AGENT_COUNT, populate_ms
    );

    let mut model = ParticleModelVec::new(
        store,
        NothingSpace,
        Fastest::new(),
        (),
        StdRng::seed_from_u64(42),
        Some(Box::new(particle_step)),
        None,
        true,
    );

    let t1 = Instant::now();
    model.step_n(STEP_COUNT);
    let step_ms = t1.elapsed().as_millis();
    let per_step_ms = step_ms as f64 / STEP_COUNT as f64;

    eprintln!(
        "[VecStore] {} steps x {} agents in {} ms ({:.1} ms/step)",
        STEP_COUNT, AGENT_COUNT, step_ms, per_step_ms
    );

    assert_eq!(
        model.time(),
        rustsim_core::types::Time::Discrete(STEP_COUNT as u64)
    );
    assert_eq!(model.agents_len(), AGENT_COUNT as usize);

    let a1 = model.agent(1).unwrap();
    let expected = STEP_COUNT as f32 * 0.001;
    assert!(
        (a1.x - expected).abs() < 1e-5,
        "agent 1: expected x={}, got x={}",
        expected,
        a1.x
    );

    assert!(
        populate_ms < 10_000,
        "populating 1M agents should take < 10s (took {} ms)",
        populate_ms
    );
    assert!(
        per_step_ms < 5_000.0,
        "stepping 1M agents should take < 5s/step (took {:.0} ms/step)",
        per_step_ms
    );

    eprintln!("[VecStore] All assertions passed.");
    eprintln!("  Populate:    {} ms", populate_ms);
    eprintln!("  Per-step:    {:.1} ms", per_step_ms);
}

// --- Deferred removal at scale ---

#[test]
fn million_agents_with_deferred_removal() {
    let mut store = HashMapStore::new();
    for i in 1..=AGENT_COUNT {
        store.insert(Particle {
            id: i,
            x: 0.0,
            vx: 0.001,
        });
    }

    fn remove_evens(
        agent: &mut Particle,
        ctx: &mut StepContext<'_, NothingSpace, Particle, (), StdRng, Fastest>,
    ) {
        if agent.id.is_multiple_of(2) {
            ctx.defer_remove_agent(agent.id());
        } else {
            agent.x += agent.vx;
        }
    }

    let mut model = ParticleModel::new(
        store,
        NothingSpace,
        Fastest::new(),
        (),
        StdRng::seed_from_u64(42),
        Some(Box::new(remove_evens)),
        None,
        true,
    );

    let t0 = Instant::now();
    model.step();
    let ms = t0.elapsed().as_millis();

    // Half removed
    assert_eq!(
        model.agents_len(),
        (AGENT_COUNT / 2) as usize,
        "500K agents should remain after removing evens"
    );

    // Remaining agents (odds) should have stepped
    let a1 = model.agent(1).unwrap();
    assert!((a1.x - 0.001).abs() < 1e-6);
    drop(a1);

    assert!(model.agent(2).is_none(), "agent 2 (even) should be removed");

    eprintln!(
        "[Deferred removal] Removed 500K / stepped 500K in {} ms",
        ms
    );
    assert!(
        ms < 10_000,
        "1 step with 500K removals should take < 10s (took {} ms)",
        ms
    );
}