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(¶ms));
48//! let mut peds = Vec::with_capacity(1);
49//! for _ in 0..100 {
50//! step_scratch_store(&SocialForceModel, &mut store, &[], ¶ms, 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(¶ms));
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/// ¶ms,
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, ¶ms, 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(¶ms));
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 ¶ms,
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(¶ms);
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, &[], ¶ms, 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 ¶ms,
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(¶ms);
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, &[], ¶ms, 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 ¶ms,
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(¶ms));
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 ¶ms,
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(¶ms);
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 ¶ms,
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 ¶ms,
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}