Skip to main content

rustsim_core/
interaction.rs

1//! Space interaction API - add, remove, move, and query agents in space.
2//!
3//! This module provides the ergonomic helper functions that mirror Julia
4//! Agents.jl's `model_accessing_API.jl` and `space_interaction_API.jl`:
5//!
6//! - [`add_agent`], [`add_agent_random`] - insert an agent into both the store and the space.
7//! - [`remove_agent`] - remove an agent from both the store and the space.
8//! - [`move_agent`] - change an agent's position in the space.
9//! - [`nearby_ids`], [`nearby_ids_except`] - query agents near a position.
10//! - [`nearby_agents`], [`nearby_agents_except`] - query agent references near a position.
11//! - [`random_agent`], [`random_id`] - pick a random agent.
12//! - [`all_ids`] - list all agent IDs.
13//!
14//! These functions operate on [`StandardModel`] and require the agent type to
15//! implement [`PositionedAgent`] and the space to implement [`SpaceInteraction`].
16//!
17//! [`StandardModel`]: crate::standard::StandardModel
18
19use crate::{
20    agent::Agent, scheduler::Scheduler, space::Space, standard::StandardModel, store::AgentStore,
21    types::AgentId,
22};
23use rand::seq::SliceRandom;
24use thiserror::Error;
25
26/// Errors that can occur during space interaction operations.
27#[derive(Debug, Error)]
28pub enum InteractionError<E: std::fmt::Debug + std::fmt::Display> {
29    /// An agent with this ID already exists in the store.
30    #[error("duplicate agent id {0}")]
31    DuplicateId(AgentId),
32    /// No agent with this ID was found.
33    #[error("agent not found: {0}")]
34    AgentNotFound(AgentId),
35    /// The agent store and spatial index disagree about this agent.
36    #[error("agent {0} is missing from the spatial index at its stored position")]
37    SpaceIndexMissing(AgentId),
38    /// The agent appears more than once in the spatial index at its stored position.
39    #[error("agent {0} appears more than once in the spatial index at its stored position")]
40    SpaceIndexDuplicate(AgentId),
41    /// The underlying space reported an error (e.g. out of bounds).
42    #[error("space error: {0}")]
43    Space(E),
44    /// A rollback failed after a space mutation error, leaving the model in an unknown state.
45    #[error("space rollback failed during {operation}: original error: {source}; rollback error: {rollback}")]
46    RollbackFailed {
47        /// Operation that was being rolled back.
48        operation: &'static str,
49        /// Original operation error.
50        source: E,
51        /// Rollback error.
52        rollback: E,
53    },
54}
55
56/// Extension trait for agents that have a spatial position.
57///
58/// Implement this for agent types used with spatial models (grids, continuous
59/// spaces, graphs, etc.). The position type is determined by the space.
60pub trait PositionedAgent: Agent {
61    /// The position type (e.g. `(usize, usize)` for grids, `ContinuousPos` for continuous space).
62    type Position: Clone;
63
64    /// Current position of the agent.
65    fn position(&self) -> &Self::Position;
66
67    /// Update the agent's position.
68    fn set_position(&mut self, position: Self::Position);
69}
70
71/// Trait that spaces implement to support agent lifecycle and neighbor queries.
72///
73/// Each space type defines its own `Error` type and provides methods to
74/// add/remove agents, generate random positions, and find nearby agent IDs.
75pub trait SpaceInteraction<A: PositionedAgent>: Space {
76    /// Error type for space operations.
77    type Error: std::fmt::Debug + std::fmt::Display;
78
79    /// Generate a random valid position within this space.
80    fn random_position<R: rand::RngCore>(&self, rng: &mut R) -> A::Position;
81
82    /// Register an agent with the space at its current position.
83    fn add_agent(&mut self, agent: &A) -> Result<(), Self::Error>;
84
85    /// Deregister an agent from the space.
86    fn remove_agent(&mut self, agent: &A) -> Result<(), Self::Error>;
87
88    /// Return all agent IDs within `radius` of `position`.
89    ///
90    /// The meaning of `radius` depends on the space: grid cells (Chebyshev),
91    /// Euclidean distance (continuous), or graph hops (graph).
92    fn nearby_ids(&self, position: &A::Position, radius: usize) -> Vec<AgentId>;
93}
94
95/// Add a positioned agent to both the store and the space.
96///
97/// Returns [`InteractionError::DuplicateId`] if an agent with the same ID
98/// already exists, or [`InteractionError::Space`] if the space rejects the position.
99pub fn add_agent<S, A, Store, Props, R, Sch>(
100    model: &mut StandardModel<S, A, Store, Props, R, Sch>,
101    agent: A,
102) -> Result<(), InteractionError<S::Error>>
103where
104    A: PositionedAgent,
105    S: SpaceInteraction<A>,
106    Store: AgentStore<A>,
107    R: rand::RngCore,
108    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
109{
110    let id = agent.id();
111    if model.agents.contains(id) {
112        return Err(InteractionError::DuplicateId(id));
113    }
114
115    // Register with the space before committing the store insert. If the
116    // space rejects the position, the model remains unchanged.
117    model
118        .space
119        .add_agent(&agent)
120        .map_err(InteractionError::Space)?;
121
122    model.agents.insert(agent);
123
124    // Update max_id
125    if id > model.max_id {
126        model.max_id = id;
127    }
128
129    Ok(())
130}
131
132/// Validate that every positioned agent in the store is present exactly once
133/// in the spatial index at its stored position.
134pub fn validate_space_index<S, A, Store, Props, R, Sch>(
135    model: &StandardModel<S, A, Store, Props, R, Sch>,
136) -> Result<(), InteractionError<S::Error>>
137where
138    A: PositionedAgent,
139    S: SpaceInteraction<A>,
140    Store: AgentStore<A>,
141    R: rand::RngCore,
142    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
143{
144    for id in model.agents.iter_ids() {
145        let Some(agent) = model.agents.get(id) else {
146            continue;
147        };
148        let matches = model
149            .space
150            .nearby_ids(agent.position(), 0)
151            .into_iter()
152            .filter(|candidate| *candidate == id)
153            .count();
154        match matches {
155            0 => return Err(InteractionError::SpaceIndexMissing(id)),
156            1 => {}
157            _ => return Err(InteractionError::SpaceIndexDuplicate(id)),
158        }
159    }
160    Ok(())
161}
162
163/// Remove a positioned agent from both the store and the space.
164///
165/// Returns `Ok(None)` if no agent with this ID was found.
166pub fn remove_agent<S, A, Store, Props, R, Sch>(
167    model: &mut StandardModel<S, A, Store, Props, R, Sch>,
168    id: AgentId,
169) -> Result<Option<A>, InteractionError<S::Error>>
170where
171    A: PositionedAgent,
172    S: SpaceInteraction<A>,
173    Store: AgentStore<A>,
174    R: rand::RngCore,
175    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
176{
177    // Need to access agent to remove from space
178    if let Some(agent_ref) = model.agents.get(id) {
179        model
180            .space
181            .remove_agent(&*agent_ref)
182            .map_err(InteractionError::Space)?;
183    } else {
184        return Ok(None);
185    }
186
187    Ok(model.agents.remove(id))
188}
189
190/// Move an agent to a new position in the space.
191///
192/// Returns [`InteractionError::AgentNotFound`] if no agent with this ID was found,
193/// or [`InteractionError::Space`] if the space rejects the new position.
194pub fn move_agent<S, A, Store, Props, R, Sch>(
195    model: &mut StandardModel<S, A, Store, Props, R, Sch>,
196    id: AgentId,
197    new_position: A::Position,
198) -> Result<(), InteractionError<S::Error>>
199where
200    A: PositionedAgent,
201    S: SpaceInteraction<A>,
202    Store: AgentStore<A>,
203    R: rand::RngCore,
204    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
205{
206    let mut agent_ref = model
207        .agents
208        .get_mut(id)
209        .ok_or(InteractionError::AgentNotFound(id))?;
210
211    let old_position = agent_ref.position().clone();
212
213    model
214        .space
215        .remove_agent(&*agent_ref)
216        .map_err(InteractionError::Space)?;
217
218    agent_ref.set_position(new_position);
219
220    if let Err(source) = model.space.add_agent(&*agent_ref) {
221        agent_ref.set_position(old_position);
222        if let Err(rollback) = model.space.add_agent(&*agent_ref) {
223            return Err(InteractionError::RollbackFailed {
224                operation: "move_agent",
225                source,
226                rollback,
227            });
228        }
229        return Err(InteractionError::Space(source));
230    }
231
232    Ok(())
233}
234
235/// Pick a random agent ID from the model.
236///
237/// Returns `None` if the agent store is empty.
238pub fn random_id<S, A, Store, Props, R, Sch>(
239    model: &mut StandardModel<S, A, Store, Props, R, Sch>,
240) -> Option<AgentId>
241where
242    A: Agent,
243    S: Space,
244    Store: AgentStore<A>,
245    R: rand::RngCore,
246    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
247{
248    let ids: Vec<AgentId> = model.agents.iter_ids();
249    if ids.is_empty() {
250        return None;
251    }
252
253    let mut rng = model.rng_mut();
254    ids.choose(&mut *rng).copied()
255}
256
257/// Get a vector of all agent IDs in the model.
258pub fn all_ids<S, A, Store, Props, R, Sch>(
259    model: &StandardModel<S, A, Store, Props, R, Sch>,
260) -> Vec<AgentId>
261where
262    A: Agent,
263    S: Space,
264    Store: AgentStore<A>,
265    R: rand::RngCore,
266    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
267{
268    model.agents.iter_ids()
269}
270
271/// Query agent IDs near a position, using the space's distance metric.
272///
273/// The meaning of `radius` depends on the space: grid cells (Chebyshev),
274/// Euclidean distance (continuous), or graph hops (graph).
275pub fn nearby_ids<S, A, Store, Props, R, Sch>(
276    model: &StandardModel<S, A, Store, Props, R, Sch>,
277    position: &A::Position,
278    radius: usize,
279) -> Vec<AgentId>
280where
281    A: PositionedAgent,
282    S: SpaceInteraction<A>,
283    Store: AgentStore<A>,
284    R: rand::RngCore,
285    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
286{
287    model.space.nearby_ids(position, radius)
288}
289
290/// Query agent references near a position, using the space's distance metric.
291///
292/// The meaning of `radius` depends on the space: grid cells (Chebyshev),
293/// Euclidean distance (continuous), or graph hops (graph).
294pub fn nearby_agents<'a, S, A, Store, Props, R, Sch>(
295    model: &'a StandardModel<S, A, Store, Props, R, Sch>,
296    position: &A::Position,
297    radius: usize,
298) -> Vec<std::cell::Ref<'a, A>>
299where
300    A: PositionedAgent,
301    S: SpaceInteraction<A>,
302    Store: AgentStore<A>,
303    R: rand::RngCore,
304    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
305{
306    model
307        .space
308        .nearby_ids(position, radius)
309        .into_iter()
310        .filter_map(|id| model.agents.get(id))
311        .collect()
312}
313
314/// Query agent IDs near a position, excluding a specific agent.
315///
316/// The meaning of `radius` depends on the space: grid cells (Chebyshev),
317/// Euclidean distance (continuous), or graph hops (graph).
318pub fn nearby_ids_except<S, A, Store, Props, R, Sch>(
319    model: &StandardModel<S, A, Store, Props, R, Sch>,
320    position: &A::Position,
321    radius: usize,
322    exclude_id: AgentId,
323) -> Vec<AgentId>
324where
325    A: PositionedAgent,
326    S: SpaceInteraction<A>,
327    Store: AgentStore<A>,
328    R: rand::RngCore,
329    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
330{
331    model
332        .space
333        .nearby_ids(position, radius)
334        .into_iter()
335        .filter(|&id| id != exclude_id)
336        .collect()
337}
338
339/// Query agent references near a position, excluding a specific agent.
340///
341/// The meaning of `radius` depends on the space: grid cells (Chebyshev),
342/// Euclidean distance (continuous), or graph hops (graph).
343pub fn nearby_agents_except<'a, S, A, Store, Props, R, Sch>(
344    model: &'a StandardModel<S, A, Store, Props, R, Sch>,
345    position: &A::Position,
346    radius: usize,
347    exclude_id: AgentId,
348) -> Vec<std::cell::Ref<'a, A>>
349where
350    A: PositionedAgent,
351    S: SpaceInteraction<A>,
352    Store: AgentStore<A>,
353    R: rand::RngCore,
354    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
355{
356    model
357        .space
358        .nearby_ids(position, radius)
359        .into_iter()
360        .filter(|&id| id != exclude_id)
361        .filter_map(|id| model.agents.get(id))
362        .collect()
363}
364
365/// Pick a random agent reference from the model.
366///
367/// Returns `None` if the agent store is empty.
368pub fn random_agent<'a, S, A, Store, Props, R, Sch>(
369    model: &'a mut StandardModel<S, A, Store, Props, R, Sch>,
370) -> Option<std::cell::Ref<'a, A>>
371where
372    A: Agent,
373    S: Space,
374    Store: AgentStore<A>,
375    R: rand::RngCore,
376    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
377{
378    let ids = model.agents.iter_ids();
379    if ids.is_empty() {
380        return None;
381    }
382
383    let mut rng = model.rng_mut();
384    let id = *ids.choose(&mut *rng)?;
385    drop(rng);
386
387    model.agents.get(id)
388}
389
390/// Add an agent to the model at a random position in the space.
391///
392/// The agent must not already exist in the store. If the space rejects the
393/// position, returns [`InteractionError::Space`].
394pub fn add_agent_random<S, A, Store, Props, R, Sch>(
395    model: &mut StandardModel<S, A, Store, Props, R, Sch>,
396    mut agent: A,
397) -> Result<(), InteractionError<S::Error>>
398where
399    A: PositionedAgent,
400    S: SpaceInteraction<A>,
401    Store: AgentStore<A>,
402    R: rand::RngCore,
403    Sch: Scheduler<StandardModel<S, A, Store, Props, R, Sch>>,
404{
405    let mut rng = model.rng_mut();
406    let position = model.space.random_position(&mut *rng);
407    drop(rng);
408
409    agent.set_position(position);
410    add_agent(model, agent)
411}