Skip to main content

rustsim_crowd/
integration.rs

1//! `rustsim-core` integration for the crowd models.
2//!
3//! The pedestrian models in this crate operate on bare
4//! [`Pedestrian`](crate::common::Pedestrian) structs with no identity
5//! or lifecycle. This module adapts them to the core ABM engine so
6//! that crowd agents can participate in [`StandardModel`] simulations,
7//! be logged through the telemetry pipeline, and be extracted into
8//! SoA `f64` buffers suitable for the GPU batch / persistent-store
9//! paths in the `rustsim` umbrella crate.
10//!
11//! # Shape
12//!
13//! - [`CrowdAgent`] pairs a [`Pedestrian`](crate::common::Pedestrian)
14//!   with an [`AgentId`]. It implements [`Agent`] and
15//!   [`SoaExtractableF64`] with an **8-column** layout:
16//!   `pos.x, pos.y, vel.x, vel.y, radius, desired_speed, dest.x, dest.y`.
17//! - [`step_scratch_store`] drives one tick of any 2-D pedestrian
18//!   model over a [`VecStore<CrowdAgent>`], using a caller-owned
19//!   [`Scratch`](crate::broadphase::Scratch). The store's interior
20//!   mutability is respected — each agent is borrowed immutably for
21//!   the read phase and mutably for the write-back phase.
22//!
23//! The adapter is intentionally a thin sync layer rather than a
24//! re-implementation of the physics: the source of truth for crowd
25//! dynamics stays in the model modules, and this file only shuffles
26//! data between `AgentStore` and `&mut [Pedestrian]`.
27//!
28//! # Example
29//!
30//! ```ignore
31//! use rustsim_core::prelude::*;
32//! use rustsim_crowd::prelude::*;
33//! use rustsim_crowd::integration::{step_scratch_store, CrowdAgent, SocialForceModel};
34//!
35//! let mut store: VecStore<CrowdAgent> = VecStore::new();
36//! store.insert(CrowdAgent {
37//!     id: 0,
38//!     ped: Pedestrian {
39//!         pos: [0.0, 0.0],
40//!         vel: [0.0, 0.0],
41//!         radius: 0.25,
42//!         desired_speed: 1.34,
43//!         destination: [10.0, 0.0],
44//!     },
45//! });
46//! let params = social_force::Params::default();
47//! let mut scratch = Scratch::with_capacity(1, social_force::neighbor_cutoff(&params));
48//! let mut peds = Vec::with_capacity(1);
49//! for _ in 0..100 {
50//!     step_scratch_store(&SocialForceModel, &mut store, &[], &params, 0.05, &mut scratch, &mut peds);
51//! }
52//! ```
53
54use rustsim_core::prelude::{Agent, AgentId, AgentStore, SoaExtractableF64};
55
56use crate::broadphase::Scratch;
57use crate::common::{Pedestrian, WallSegment};
58
59/// Identified pedestrian record for use with `rustsim-core` agent stores.
60///
61/// Wraps a [`Pedestrian`] with an [`AgentId`] so the crowd physics can
62/// be driven through the same `StandardModel` / `VecStore` / telemetry
63/// machinery used by the rest of the workspace.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct CrowdAgent {
66    /// Stable agent identifier assigned by the owning model.
67    pub id: AgentId,
68    /// Underlying pedestrian state.
69    pub ped: Pedestrian,
70}
71
72impl Agent for CrowdAgent {
73    #[inline]
74    fn id(&self) -> AgentId {
75        self.id
76    }
77}
78
79/// SoA column layout — 8 columns: `pos.x, pos.y, vel.x, vel.y, radius,
80/// desired_speed, dest.x, dest.y`. The 8-wide layout matches the
81/// `DeviceSoaStore` expectations for `f64`-kernel launches.
82impl SoaExtractableF64 for CrowdAgent {
83    fn num_columns() -> usize {
84        8
85    }
86
87    fn column_names() -> Vec<&'static str> {
88        vec![
89            "pos_x",
90            "pos_y",
91            "vel_x",
92            "vel_y",
93            "radius",
94            "desired_speed",
95            "dest_x",
96            "dest_y",
97        ]
98    }
99
100    fn extract_row(&self, columns: &mut [Vec<f64>]) {
101        columns[0].push(self.ped.pos[0]);
102        columns[1].push(self.ped.pos[1]);
103        columns[2].push(self.ped.vel[0]);
104        columns[3].push(self.ped.vel[1]);
105        columns[4].push(self.ped.radius);
106        columns[5].push(self.ped.desired_speed);
107        columns[6].push(self.ped.destination[0]);
108        columns[7].push(self.ped.destination[1]);
109    }
110
111    fn write_back_row(&mut self, columns: &[&[f64]], row: usize) {
112        self.ped.pos = [columns[0][row], columns[1][row]];
113        self.ped.vel = [columns[2][row], columns[3][row]];
114        self.ped.radius = columns[4][row];
115        self.ped.desired_speed = columns[5][row];
116        self.ped.destination = [columns[6][row], columns[7][row]];
117    }
118}
119
120/// Number of SoA columns in the [`CrowdAgent`] layout.
121pub const NUM_COLUMNS: usize = 8;
122
123/// Column index of `pos.x` in the [`CrowdAgent`] SoA layout.
124pub const COL_POS_X: usize = 0;
125/// Column index of `pos.y`.
126pub const COL_POS_Y: usize = 1;
127/// Column index of `vel.x`.
128pub const COL_VEL_X: usize = 2;
129/// Column index of `vel.y`.
130pub const COL_VEL_Y: usize = 3;
131/// Column index of `radius`.
132pub const COL_RADIUS: usize = 4;
133/// Column index of `desired_speed`.
134pub const COL_DESIRED_SPEED: usize = 5;
135/// Column index of `dest.x`.
136pub const COL_DEST_X: usize = 6;
137/// Column index of `dest.y`.
138pub const COL_DEST_Y: usize = 7;
139
140/// Unpack the [`CrowdAgent`] SoA layout into a `Pedestrian` buffer.
141///
142/// `peds_buf` is resized to `n` and filled row-by-row. This is the
143/// column → AoS bridge used by [`step_columns_f64`]; it is also the
144/// shape a future CUDA kernel will read from its column pointers.
145///
146/// # Panics
147///
148/// Panics if `columns.len() < NUM_COLUMNS` or if any column has fewer
149/// than `n` entries.
150pub fn unpack_columns_into(columns: &[Vec<f64>], n: usize, peds_buf: &mut Vec<Pedestrian>) {
151    assert!(
152        columns.len() >= NUM_COLUMNS,
153        "CrowdAgent SoA needs {NUM_COLUMNS} columns, got {}",
154        columns.len()
155    );
156    peds_buf.clear();
157    peds_buf.reserve(n);
158    let (pos_x, pos_y) = (&columns[COL_POS_X], &columns[COL_POS_Y]);
159    let (vel_x, vel_y) = (&columns[COL_VEL_X], &columns[COL_VEL_Y]);
160    let radius = &columns[COL_RADIUS];
161    let desired_speed = &columns[COL_DESIRED_SPEED];
162    let (dest_x, dest_y) = (&columns[COL_DEST_X], &columns[COL_DEST_Y]);
163    for row in 0..n {
164        peds_buf.push(Pedestrian {
165            pos: [pos_x[row], pos_y[row]],
166            vel: [vel_x[row], vel_y[row]],
167            radius: radius[row],
168            desired_speed: desired_speed[row],
169            destination: [dest_x[row], dest_y[row]],
170        });
171    }
172}
173/// Write a `Pedestrian` buffer back into the [`CrowdAgent`] SoA columns.
174///
175/// # Panics
176///
177/// Panics if `columns.len() < NUM_COLUMNS` or any column is shorter
178/// than `peds.len()`.
179pub fn pack_columns_from(peds: &[Pedestrian], columns: &mut [Vec<f64>]) {
180    assert!(
181        columns.len() >= NUM_COLUMNS,
182        "CrowdAgent SoA needs {NUM_COLUMNS} columns, got {}",
183        columns.len()
184    );
185    for (row, p) in peds.iter().enumerate() {
186        columns[COL_POS_X][row] = p.pos[0];
187        columns[COL_POS_Y][row] = p.pos[1];
188        columns[COL_VEL_X][row] = p.vel[0];
189        columns[COL_VEL_Y][row] = p.vel[1];
190        columns[COL_RADIUS][row] = p.radius;
191        columns[COL_DESIRED_SPEED][row] = p.desired_speed;
192        columns[COL_DEST_X][row] = p.destination[0];
193        columns[COL_DEST_Y][row] = p.destination[1];
194    }
195}
196
197/// Zero-alloc SoA step over `CrowdAgent` columns.
198///
199/// Consumes parallel `Vec<f64>` columns in the 8-column [`CrowdAgent`]
200/// layout, runs one zero-alloc tick of `model`, and writes the columns
201/// back in place. `peds_buf` and `scratch` are caller-owned and reused
202/// across ticks.
203///
204/// This is the shape that matches `rustsim::cpu_batch_step_f64`'s
205/// closure signature and is the direct transliteration target for a
206/// future `.cu` kernel: the SoA column contract here (names, order,
207/// precision) is identical to what a CUDA launch would receive as its
208/// column pointers. On CPU today it is a thin glue layer on top of
209/// [`CrowdStep::step`] — the physics live in the model modules and are
210/// bit-exactly the same as the AoS hot path.
211///
212/// # Example
213///
214/// ```ignore
215/// use rustsim::cpu_batch_step_f64;
216/// use rustsim_crowd::prelude::*;
217/// use rustsim_crowd::integration::{step_columns_f64, SocialForceModel};
218/// use rustsim_crowd::social_force;
219///
220/// let params = social_force::Params::default();
221/// let mut scratch = Scratch::with_capacity(n, social_force::neighbor_cutoff(&params));
222/// let mut peds_buf: Vec<Pedestrian> = Vec::with_capacity(n);
223/// cpu_batch_step_f64::<CrowdAgent, _, _>(&store, |cols, n| {
224///     step_columns_f64(
225///         &SocialForceModel,
226///         cols,
227///         n,
228///         &[],
229///         &params,
230///         0.05,
231///         &mut scratch,
232///         &mut peds_buf,
233///     );
234/// });
235/// ```
236#[allow(clippy::too_many_arguments)]
237pub fn step_columns_f64<M>(
238    model: &M,
239    columns: &mut [Vec<f64>],
240    n: usize,
241    walls: &[WallSegment],
242    params: &M::Params,
243    dt: f64,
244    scratch: &mut Scratch,
245    peds_buf: &mut Vec<Pedestrian>,
246) where
247    M: CrowdStep,
248{
249    unpack_columns_into(columns, n, peds_buf);
250    model.step(peds_buf, walls, params, dt, scratch);
251    pack_columns_from(peds_buf, columns);
252}
253
254/// Abstract 2-D crowd model with a zero-alloc step entry point.
255///
256/// Implemented by the unit marker types in this module so
257/// [`step_scratch_store`] can dispatch over any of the five 2-D models
258/// through a single generic bound.
259pub trait CrowdStep {
260    /// Model-specific parameter bundle.
261    type Params;
262
263    /// Run one zero-alloc tick over `peds` using `scratch`.
264    fn step(
265        &self,
266        peds: &mut [Pedestrian],
267        walls: &[WallSegment],
268        params: &Self::Params,
269        dt: f64,
270        scratch: &mut Scratch,
271    );
272}
273
274macro_rules! impl_crowd_step {
275    ($model:ident, $module:ident) => {
276        /// `CrowdStep` adapter for the
277        #[doc = concat!("[`", stringify!($module), "`](crate::", stringify!($module), ") model.")]
278        #[derive(Debug, Clone, Copy, Default)]
279        pub struct $model;
280
281        impl CrowdStep for $model {
282            type Params = crate::$module::Params;
283
284            #[inline]
285            fn step(
286                &self,
287                peds: &mut [Pedestrian],
288                walls: &[WallSegment],
289                params: &Self::Params,
290                dt: f64,
291                scratch: &mut Scratch,
292            ) {
293                crate::$module::step_scratch(peds, walls, params, dt, scratch);
294            }
295        }
296    };
297}
298
299impl_crowd_step!(SocialForceModel, social_force);
300impl_crowd_step!(CollisionFreeSpeedModel, collision_free_speed);
301impl_crowd_step!(
302    GeneralizedCentrifugalForceModel,
303    generalized_centrifugal_force
304);
305impl_crowd_step!(AnticipationVelocityModel, anticipation_velocity);
306impl_crowd_step!(OptimalStepsModel, optimal_steps);
307
308/// Rayon-parallel companion to [`CrowdStep`].
309///
310/// Implemented on every model marker behind the optional `rayon`
311/// feature. The contract mirrors the per-model `step_scratch_par`
312/// functions: the call must be **bit-exact** with the serial
313/// [`CrowdStep::step`] on the same inputs (no cross-thread float
314/// reduction; per-agent scratch slots only). This invariant is what
315/// lets [`step_scratch_store_par`] and [`step_scratch_store_observed_par`]
316/// transparently substitute for their serial counterparts in
317/// production deployments above ~5 000 agents — telemetry, ID
318/// ordering, write-back semantics, and observed state are all
319/// identical to the serial path. Below ~5 000 agents the rayon
320/// dispatch cost dominates and the serial entry points are faster.
321#[cfg(feature = "rayon")]
322pub trait CrowdStepPar: CrowdStep {
323    /// Run one zero-alloc, rayon-parallel tick over `peds` using
324    /// `scratch`. Bit-exact with [`CrowdStep::step`].
325    fn step_par(
326        &self,
327        peds: &mut [Pedestrian],
328        walls: &[WallSegment],
329        params: &Self::Params,
330        dt: f64,
331        scratch: &mut Scratch,
332    );
333}
334
335#[cfg(feature = "rayon")]
336macro_rules! impl_crowd_step_par {
337    ($model:ident, $module:ident) => {
338        impl CrowdStepPar for $model {
339            #[inline]
340            fn step_par(
341                &self,
342                peds: &mut [Pedestrian],
343                walls: &[WallSegment],
344                params: &Self::Params,
345                dt: f64,
346                scratch: &mut Scratch,
347            ) {
348                crate::$module::step_scratch_par(peds, walls, params, dt, scratch);
349            }
350        }
351    };
352}
353
354#[cfg(feature = "rayon")]
355impl_crowd_step_par!(SocialForceModel, social_force);
356#[cfg(feature = "rayon")]
357impl_crowd_step_par!(CollisionFreeSpeedModel, collision_free_speed);
358#[cfg(feature = "rayon")]
359impl_crowd_step_par!(
360    GeneralizedCentrifugalForceModel,
361    generalized_centrifugal_force
362);
363#[cfg(feature = "rayon")]
364impl_crowd_step_par!(AnticipationVelocityModel, anticipation_velocity);
365#[cfg(feature = "rayon")]
366impl_crowd_step_par!(OptimalStepsModel, optimal_steps);
367
368/// Per-agent post-step observation hook.
369///
370/// Invoked by [`step_scratch_store_observed`] **after** the model has
371/// finished the tick and the updated state has been written back to
372/// the store, so `ped` reflects the post-tick position, velocity, and
373/// destination. Closes the P1 "telemetry hooks" item from
374/// `docs/rustsim-crowd.md`: downstream code can forward each row into
375/// the workspace's `TelemetryPipeline::push_row`, write it to a CSV
376/// logger, update a live heatmap, or compute rolling flow-density
377/// statistics — without `rustsim-crowd` itself taking a dependency on
378/// any sink implementation.
379///
380/// The trait is blanket-implemented for every `FnMut(AgentId,
381/// &Pedestrian)`, so in practice callers pass a closure:
382///
383/// ```ignore
384/// step_scratch_store_observed(
385///     &SocialForceModel, &mut store, &walls, &params, dt,
386///     &mut scratch, &mut peds_buf,
387///     |id, ped| pipeline.push_row(&[tick.into(), id.into(),
388///         ped.pos[0].into(), ped.pos[1].into(),
389///         ped.vel[0].into(), ped.vel[1].into()])?,
390/// );
391/// ```
392///
393/// Implementations must not panic on valid inputs; the observer is
394/// called inside the tick loop and a panic aborts the whole tick.
395pub trait CrowdObserver {
396    /// Observe the post-tick state of one pedestrian.
397    fn observe(&mut self, agent_id: AgentId, ped: &Pedestrian);
398}
399
400impl<F> CrowdObserver for F
401where
402    F: FnMut(AgentId, &Pedestrian),
403{
404    #[inline]
405    fn observe(&mut self, agent_id: AgentId, ped: &Pedestrian) {
406        self(agent_id, ped);
407    }
408}
409
410/// No-op observer used by [`step_scratch_store`] to share code with
411/// [`step_scratch_store_observed`]. Monomorphization erases the
412/// observer entirely in the non-observing path.
413#[derive(Debug, Clone, Copy, Default)]
414struct NoopObserver;
415
416impl CrowdObserver for NoopObserver {
417    #[inline]
418    fn observe(&mut self, _agent_id: AgentId, _ped: &Pedestrian) {}
419}
420
421/// Zero-allocation `AgentStore` adapter for any 2-D crowd model.
422///
423/// Unpacks every agent's [`Pedestrian`] into `peds_buf` (which the
424/// caller owns and reuses across ticks), runs one model tick via
425/// [`CrowdStep::step`], and writes the updated state back into the
426/// store. `peds_buf` is cleared at the start of every call; its
427/// capacity is retained, so after the first tick this function does
428/// not allocate.
429///
430/// The `AgentId → pedestrian` ordering is stable across the extract
431/// and write-back phases because both iterate `store.iter_ids()`.
432pub fn step_scratch_store<M, S>(
433    model: &M,
434    store: &mut S,
435    walls: &[WallSegment],
436    params: &M::Params,
437    dt: f64,
438    scratch: &mut Scratch,
439    peds_buf: &mut Vec<Pedestrian>,
440) where
441    M: CrowdStep,
442    S: AgentStore<CrowdAgent>,
443{
444    step_scratch_store_observed(
445        model,
446        store,
447        walls,
448        params,
449        dt,
450        scratch,
451        peds_buf,
452        &mut NoopObserver,
453    );
454}
455
456/// Observed variant of [`step_scratch_store`]: identical semantics,
457/// plus a post-writeback callback for every agent.
458///
459/// `observer.observe(id, &ped)` is invoked once per agent, in the
460/// same order as `store.iter_ids()`, with `ped` holding the
461/// post-tick state that was just written back. The observer sees
462/// the same state the next [`AgentStore::get`] call would return.
463///
464/// This is the production telemetry entry point: a closure that
465/// forwards into `rustsim::TelemetryPipeline::push_row` (or any
466/// other sink — CSV, Parquet, in-memory buffer, Prometheus counter)
467/// gets per-agent per-tick coverage with zero allocation on the hot
468/// path beyond what the sink itself may do.
469///
470/// Panic safety: if `observer.observe` panics the writeback loop
471/// unwinds and the store is left in a partially-updated state (rows
472/// before the panicking index are already committed; rows after it
473/// have not yet been observed but have been written back). Callers
474/// that cannot tolerate that should make `observer.observe` infallible
475/// or forward its errors via captured state.
476#[allow(clippy::too_many_arguments)]
477pub fn step_scratch_store_observed<M, S, O>(
478    model: &M,
479    store: &mut S,
480    walls: &[WallSegment],
481    params: &M::Params,
482    dt: f64,
483    scratch: &mut Scratch,
484    peds_buf: &mut Vec<Pedestrian>,
485    observer: &mut O,
486) where
487    M: CrowdStep,
488    S: AgentStore<CrowdAgent>,
489    O: CrowdObserver + ?Sized,
490{
491    let ids = store.iter_ids();
492    peds_buf.clear();
493    peds_buf.reserve(ids.len());
494    for &id in &ids {
495        if let Some(agent) = store.get(id) {
496            peds_buf.push(agent.ped);
497        }
498    }
499
500    model.step(peds_buf, walls, params, dt, scratch);
501
502    // Write-back. The store's `get_mut` returns a `RefMut`, so we
503    // hold one mutable borrow at a time — safe across ticks.
504    for (row, &id) in ids.iter().enumerate() {
505        if let Some(mut agent) = store.get_mut(id) {
506            agent.ped = peds_buf[row];
507        }
508    }
509
510    // Notify the observer after every row has been written back, so
511    // it sees the authoritative post-tick state.
512    for (row, &id) in ids.iter().enumerate() {
513        observer.observe(id, &peds_buf[row]);
514    }
515}
516
517/// Rayon-parallel drop-in replacement for [`step_scratch_store`].
518///
519/// Identical contract — same `peds_buf` reuse, same `iter_ids()`
520/// extract / write-back ordering, same returned store state — but
521/// the per-agent kernel runs on a rayon thread pool via
522/// [`CrowdStepPar::step_par`]. Only the `M::step_par` call differs;
523/// the surrounding extract / write-back loops are still serial.
524///
525/// Use this in production deployments where the rayon-parallel
526/// kernel is faster than the serial one (typically N > ~5 000
527/// agents on multi-core CPUs). Bit-exact with the serial path —
528/// the regression tests in `tests/rayon_bit_exact.rs` and
529/// `social_force::tests::step_scratch_par_matches_step_scratch_bit_exact`
530/// pin the kernel-level invariant; this function only changes
531/// which kernel is invoked.
532#[cfg(feature = "rayon")]
533#[allow(clippy::too_many_arguments)]
534pub fn step_scratch_store_par<M, S>(
535    model: &M,
536    store: &mut S,
537    walls: &[WallSegment],
538    params: &M::Params,
539    dt: f64,
540    scratch: &mut Scratch,
541    peds_buf: &mut Vec<Pedestrian>,
542) where
543    M: CrowdStepPar,
544    S: AgentStore<CrowdAgent>,
545{
546    step_scratch_store_observed_par(
547        model,
548        store,
549        walls,
550        params,
551        dt,
552        scratch,
553        peds_buf,
554        &mut NoopObserver,
555    );
556}
557
558/// Observed, rayon-parallel variant of [`step_scratch_store_par`].
559///
560/// Mirrors [`step_scratch_store_observed`] (same observer contract,
561/// same post-writeback order) and runs the model kernel through
562/// [`CrowdStepPar::step_par`]. The observer loop itself remains
563/// serial so it can hold non-`Send` state (e.g. a
564/// `&mut TelemetryPipeline`) and so observation order is
565/// deterministic in `iter_ids()` order.
566#[cfg(feature = "rayon")]
567#[allow(clippy::too_many_arguments)]
568pub fn step_scratch_store_observed_par<M, S, O>(
569    model: &M,
570    store: &mut S,
571    walls: &[WallSegment],
572    params: &M::Params,
573    dt: f64,
574    scratch: &mut Scratch,
575    peds_buf: &mut Vec<Pedestrian>,
576    observer: &mut O,
577) where
578    M: CrowdStepPar,
579    S: AgentStore<CrowdAgent>,
580    O: CrowdObserver + ?Sized,
581{
582    let ids = store.iter_ids();
583    peds_buf.clear();
584    peds_buf.reserve(ids.len());
585    for &id in &ids {
586        if let Some(agent) = store.get(id) {
587            peds_buf.push(agent.ped);
588        }
589    }
590
591    model.step_par(peds_buf, walls, params, dt, scratch);
592
593    for (row, &id) in ids.iter().enumerate() {
594        if let Some(mut agent) = store.get_mut(id) {
595            agent.ped = peds_buf[row];
596        }
597    }
598
599    for (row, &id) in ids.iter().enumerate() {
600        observer.observe(id, &peds_buf[row]);
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use rustsim_core::prelude::VecStore;
608
609    fn agent_at(id: AgentId, x: f64, dest: f64) -> CrowdAgent {
610        CrowdAgent {
611            id,
612            ped: Pedestrian {
613                pos: [x, 0.0],
614                vel: [0.0, 0.0],
615                radius: 0.25,
616                desired_speed: 1.34,
617                destination: [dest, 0.0],
618            },
619        }
620    }
621
622    #[test]
623    fn soa_round_trip_is_identity() {
624        let a = agent_at(7, 1.5, 9.0);
625        let mut cols: Vec<Vec<f64>> = (0..CrowdAgent::num_columns()).map(|_| Vec::new()).collect();
626        a.extract_row(&mut cols);
627        let col_refs: Vec<&[f64]> = cols.iter().map(|c| c.as_slice()).collect();
628        let mut b = agent_at(7, 0.0, 0.0);
629        b.write_back_row(&col_refs, 0);
630        assert_eq!(a, b);
631    }
632
633    #[test]
634    fn step_scratch_store_advances_toward_destination() {
635        let mut store: VecStore<CrowdAgent> = VecStore::new();
636        store.insert(agent_at(0, 0.0, 10.0));
637        let params = crate::social_force::Params::default();
638        let mut scratch = Scratch::with_capacity(1, crate::social_force::neighbor_cutoff(&params));
639        let mut buf: Vec<Pedestrian> = Vec::with_capacity(1);
640
641        for _ in 0..100 {
642            step_scratch_store(
643                &SocialForceModel,
644                &mut store,
645                &[],
646                &params,
647                0.05,
648                &mut scratch,
649                &mut buf,
650            );
651        }
652
653        let pos_x = store.get(0).unwrap().ped.pos[0];
654        assert!(pos_x > 1.0, "agent should have advanced: pos_x={pos_x}");
655    }
656
657    #[test]
658    fn step_scratch_store_matches_direct_step_scratch() {
659        // Driving N=8 agents through the store adapter must produce
660        // the same trajectory (to machine precision) as calling
661        // `step_scratch` directly on a plain slice.
662        let make = || -> Vec<CrowdAgent> {
663            (0..8)
664                .map(|k| {
665                    let x = (k as f64) * 1.2;
666                    agent_at(k as AgentId, x, x + 5.0)
667                })
668                .collect()
669        };
670        let a = make();
671        let b = make();
672
673        // Path A — direct slice.
674        let mut peds_a: Vec<Pedestrian> = a.iter().map(|c| c.ped).collect();
675        let params = crate::social_force::Params::default();
676        let cutoff = crate::social_force::neighbor_cutoff(&params);
677        let mut scratch_a = Scratch::with_capacity(peds_a.len(), cutoff);
678        for _ in 0..30 {
679            crate::social_force::step_scratch(&mut peds_a, &[], &params, 0.05, &mut scratch_a);
680        }
681
682        // Path B — AgentStore adapter.
683        let mut store: VecStore<CrowdAgent> = VecStore::new();
684        for agent in b {
685            store.insert(agent);
686        }
687        let mut scratch_b = Scratch::with_capacity(8, cutoff);
688        let mut buf: Vec<Pedestrian> = Vec::with_capacity(8);
689        for _ in 0..30 {
690            step_scratch_store(
691                &SocialForceModel,
692                &mut store,
693                &[],
694                &params,
695                0.05,
696                &mut scratch_b,
697                &mut buf,
698            );
699        }
700
701        for (i, direct) in peds_a.iter().enumerate() {
702            let via_store = store.get(i as AgentId).unwrap().ped;
703            assert_eq!(direct.pos, via_store.pos, "position diverged at agent {i}");
704            assert_eq!(direct.vel, via_store.vel, "velocity diverged at agent {i}");
705        }
706    }
707
708    #[test]
709    fn step_columns_f64_matches_direct_step_scratch() {
710        // Running N=8 agents through the SoA-column adapter must
711        // produce bit-exact trajectories against `step_scratch` on a
712        // plain slice. This pins the SoA contract that a future CUDA
713        // kernel will target: same column order, same precision, same
714        // physics.
715        let make = || -> Vec<Pedestrian> {
716            (0..8)
717                .map(|k| {
718                    let x = (k as f64) * 1.2;
719                    Pedestrian {
720                        pos: [x, 0.0],
721                        vel: [0.0, 0.0],
722                        radius: 0.25,
723                        desired_speed: 1.34,
724                        destination: [x + 5.0, 0.0],
725                    }
726                })
727                .collect()
728        };
729        let mut peds_aos = make();
730        let peds_soa = make();
731
732        let params = crate::social_force::Params::default();
733        let cutoff = crate::social_force::neighbor_cutoff(&params);
734
735        // Path A — AoS `step_scratch`.
736        let mut scratch_a = Scratch::with_capacity(peds_aos.len(), cutoff);
737        for _ in 0..30 {
738            crate::social_force::step_scratch(&mut peds_aos, &[], &params, 0.05, &mut scratch_a);
739        }
740
741        // Path B — SoA `step_columns_f64`. Build 8 columns of length n.
742        let n = peds_soa.len();
743        let mut columns: Vec<Vec<f64>> = (0..NUM_COLUMNS).map(|_| Vec::with_capacity(n)).collect();
744        for p in &peds_soa {
745            columns[COL_POS_X].push(p.pos[0]);
746            columns[COL_POS_Y].push(p.pos[1]);
747            columns[COL_VEL_X].push(p.vel[0]);
748            columns[COL_VEL_Y].push(p.vel[1]);
749            columns[COL_RADIUS].push(p.radius);
750            columns[COL_DESIRED_SPEED].push(p.desired_speed);
751            columns[COL_DEST_X].push(p.destination[0]);
752            columns[COL_DEST_Y].push(p.destination[1]);
753        }
754        let mut scratch_b = Scratch::with_capacity(n, cutoff);
755        let mut peds_buf: Vec<Pedestrian> = Vec::with_capacity(n);
756        for _ in 0..30 {
757            step_columns_f64(
758                &SocialForceModel,
759                &mut columns,
760                n,
761                &[],
762                &params,
763                0.05,
764                &mut scratch_b,
765                &mut peds_buf,
766            );
767        }
768
769        for i in 0..n {
770            assert_eq!(
771                peds_aos[i].pos,
772                [columns[COL_POS_X][i], columns[COL_POS_Y][i]],
773                "position diverged at agent {i}"
774            );
775            assert_eq!(
776                peds_aos[i].vel,
777                [columns[COL_VEL_X][i], columns[COL_VEL_Y][i]],
778                "velocity diverged at agent {i}"
779            );
780        }
781    }
782
783    #[test]
784    fn observer_sees_post_tick_state_in_id_order() {
785        // Pin the observer contract: one callback per agent, in
786        // `store.iter_ids()` order, with the `Pedestrian` argument
787        // holding the state that was just written back.
788        let mut store: VecStore<CrowdAgent> = VecStore::new();
789        for k in 0..4 {
790            store.insert(agent_at(
791                k as AgentId,
792                (k as f64) * 1.5,
793                (k as f64) * 1.5 + 5.0,
794            ));
795        }
796        let params = crate::social_force::Params::default();
797        let mut scratch = Scratch::with_capacity(4, crate::social_force::neighbor_cutoff(&params));
798        let mut buf: Vec<Pedestrian> = Vec::with_capacity(4);
799
800        let mut seen: Vec<(AgentId, [f64; 2])> = Vec::new();
801        step_scratch_store_observed(
802            &SocialForceModel,
803            &mut store,
804            &[],
805            &params,
806            0.05,
807            &mut scratch,
808            &mut buf,
809            &mut |id: AgentId, ped: &Pedestrian| {
810                seen.push((id, ped.pos));
811            },
812        );
813
814        assert_eq!(seen.len(), 4);
815        for (row, (id, pos)) in seen.iter().enumerate() {
816            assert_eq!(*id, row as AgentId);
817            let stored = store.get(*id).unwrap().ped.pos;
818            assert_eq!(*pos, stored, "observer saw stale pos at agent {id}");
819        }
820    }
821
822    #[test]
823    fn observed_step_matches_unobserved_step() {
824        // With a no-op observer, `step_scratch_store_observed` must
825        // produce the same trajectory as `step_scratch_store`.
826        let make = || -> Vec<CrowdAgent> {
827            (0..6)
828                .map(|k| agent_at(k as AgentId, (k as f64) * 1.0, (k as f64) * 1.0 + 5.0))
829                .collect()
830        };
831        let params = crate::social_force::Params::default();
832        let cutoff = crate::social_force::neighbor_cutoff(&params);
833
834        let mut store_a: VecStore<CrowdAgent> = VecStore::new();
835        for a in make() {
836            store_a.insert(a);
837        }
838        let mut scratch_a = Scratch::with_capacity(6, cutoff);
839        let mut buf_a: Vec<Pedestrian> = Vec::with_capacity(6);
840
841        let mut store_b: VecStore<CrowdAgent> = VecStore::new();
842        for a in make() {
843            store_b.insert(a);
844        }
845        let mut scratch_b = Scratch::with_capacity(6, cutoff);
846        let mut buf_b: Vec<Pedestrian> = Vec::with_capacity(6);
847
848        for _ in 0..20 {
849            step_scratch_store(
850                &SocialForceModel,
851                &mut store_a,
852                &[],
853                &params,
854                0.05,
855                &mut scratch_a,
856                &mut buf_a,
857            );
858            step_scratch_store_observed(
859                &SocialForceModel,
860                &mut store_b,
861                &[],
862                &params,
863                0.05,
864                &mut scratch_b,
865                &mut buf_b,
866                &mut |_id: AgentId, _p: &Pedestrian| {},
867            );
868        }
869
870        for id in 0..6u64 {
871            let a = store_a.get(id).unwrap().ped;
872            let b = store_b.get(id).unwrap().ped;
873            assert_eq!(a.pos, b.pos, "pos diverged at agent {id}");
874            assert_eq!(a.vel, b.vel, "vel diverged at agent {id}");
875        }
876    }
877}