Skip to main content

henad_models/ants/
mod.rs

1//! Ant foraging as an [`AgentModel`] over a pheromone [`ScalarField`].
2//!
3//! Ants walk between a nest and a food source, around two obstacles. A searching ant lays the trail home and follows
4//! the trail to food, and an ant carrying food does the reverse. Both trails decay each tick.
5
6pub mod field;
7mod lanes;
8mod step;
9
10pub use crate::ants::lanes::{AntLanes, NO_STEP};
11
12use henad_compute::cpu::field::scalar::{Deposits, ScalarField};
13use henad_compute::cpu::primitives::chunked::{STATS_CHUNK, reduce_chunks};
14use henad_core::action::ActionDescriptor;
15use henad_core::authoring::model::agent_model::{AgentModel, StepCtx};
16use henad_core::authoring::model::field::Extent;
17use henad_core::grid::Grid2D;
18use henad_core::helpers::{extract_f32, f32_param};
19use henad_core::params::{ParamDescriptor, ParamValue};
20use henad_core::view::{StatDescriptor, StatValue};
21
22use crate::ants::field::{PheromoneField, TO_FOOD, TO_HOME, nest_cell};
23
24/// Ant colours, indexed by the `has_food` lane: searching, then carrying food.
25pub const ANT_PALETTE: [[u8; 4]; 2] = [
26    [0xE8, 0xE8, 0xF0, 0xFF], // searching
27    [0x3D, 0xD5, 0x8C, 0xFF], // carrying food
28];
29
30/// Stat series colours.
31pub const STAT_PALETTE: [[u8; 4]; 3] = [
32    [0x3D, 0xD5, 0x8C, 0xFF], // carrying
33    [0xF2, 0xE4, 0x5C, 0xFF], // deliveries
34    [0x2E, 0x8B, 0xE8, 0xFF], // total pheromone
35];
36
37henad_core::params! {
38    const UPDATE_CUTDOWN = f32_param("update_cutdown", "Trail Falloff", 0.9, 0.5, 1.0, Some(0.01));
39    const REWARD = f32_param("reward", "Site Reward", 1.0, 0.1, 10.0, Some(0.1));
40    const MOMENTUM = f32_param("momentum", "Momentum Probability", 0.8, 0.0, 1.0, Some(0.01));
41    const RANDOM_ACTION = f32_param("random_action", "Random Action Probability", 0.1, 0.0, 1.0, Some(0.01));
42}
43
44henad_core::actions! {
45    const RESET_COLONY = ActionDescriptor::new("reset_colony", "Reset colony");
46}
47
48/// Ant foraging, ported from krABMaga's `antsforaging`.
49///
50/// A comparison with the reference has to state three differences. Deposits combine with `max`
51/// instead of the last writer winning. Every pheromone read sees the old field, and every write goes to the new
52/// field. The RNG is seeded per chunk per tick instead of drawn per call.
53///
54/// The reference's biased neighbour tie-break is reproduced as it is (see `step::advect_agent`).
55#[derive(Debug)]
56pub struct AntsModel;
57
58/// Parameters of [`AntsModel`], read once per tick.
59#[derive(Debug)]
60pub struct AntParams {
61    /// Width of the field in cells.
62    pub w: i32,
63    /// Height of the field in cells.
64    pub h: i32,
65    /// Factor that multiplies the pheromone of the cell and of its orthogonal neighbours in a deposit,
66    /// `update_cutdown`.
67    pub cutdown: f32,
68    /// Cutdown raised to the diagonal distance, since diagonal neighbours are further away.
69    pub diagonal: f32,
70    /// Reward an ant receives at the nest or the food source, `reward`.
71    pub reward: f32,
72    /// Probability that an ant with no pheromone nearby keeps its last direction, `momentum`.
73    pub momentum: f32,
74    /// Probability that an ant takes a random step instead of the one it chose, `random_action`.
75    pub random_action: f32,
76}
77
78impl AgentModel for AntsModel {
79    const NAME: &'static str = "Ant Foraging";
80    const ID: &'static str = "ants";
81    const DESCRIPTION: &'static str =
82        "Ants lay and follow pheromone trails between a nest and a food source, around obstacles";
83    const PALETTE: &'static [[u8; 4]] = &ANT_PALETTE;
84    const STATS: &'static [StatDescriptor] = &[
85        StatDescriptor::new("Carrying Food", STAT_PALETTE[0]),
86        StatDescriptor::new("Deliveries", STAT_PALETTE[1]),
87        StatDescriptor::new("Total Pheromone", STAT_PALETTE[2]),
88    ];
89    /// Agents per chunk. The movement draws are seeded per chunk, so another value gives other results.
90    const CHUNK: usize = 4096;
91    const ACTIONS: &'static [ActionDescriptor] = ACTION_SPECS;
92    const DEFAULT_AGENTS: u32 = 2_000;
93    const MAX_AGENTS: u32 = 5_000_000;
94    const DEFAULT_EXTENT: Extent = Extent { w: 200.0, h: 200.0 };
95
96    type Lanes = AntLanes;
97    type Field = ScalarField<PheromoneField>;
98    type Index = henad_core::authoring::model::agent_model::NoIndex;
99    type Params = AntParams;
100    type Tally = u64;
101
102    fn param_descriptors() -> Vec<ParamDescriptor> {
103        descriptors()
104    }
105
106    fn from_params(params: &[ParamValue], extent: Extent) -> AntParams {
107        let cutdown = extract_f32(params, UPDATE_CUTDOWN, 0.9);
108        AntParams {
109            w: extent.w as i32,
110            h: extent.h as i32,
111            cutdown,
112            diagonal: cutdown.powf(std::f32::consts::SQRT_2),
113            reward: extract_f32(params, REWARD, 1.0),
114            momentum: extract_f32(params, MOMENTUM, 0.8),
115            random_action: extract_f32(params, RANDOM_ACTION, 0.1),
116        }
117    }
118
119    /// Ants start holding `reward` so they lay home pheromone immediately and the colony has a
120    /// gradient to navigate back along.
121    fn init(lanes: &mut AntLanes, extent: Extent, params: &[ParamValue], _rng: &mut u64) {
122        let (width, height) = extent.cells();
123        let nest = nest_cell(width, height) as u32;
124        let (x, y) = ((nest % width) as f32, (nest / width) as f32);
125        let reward = extract_f32(params, REWARD, 1.0);
126        for i in 0..lanes.pos_x.len() {
127            lanes.pos_x[i] = x;
128            lanes.pos_y[i] = y;
129            lanes.reward[i] = reward;
130        }
131    }
132
133    fn run_deposit_pass(lanes: &AntLanes, deposits: &mut Deposits, ctx: &StepCtx<'_, Self>) {
134        step::deposit(lanes, deposits, ctx);
135    }
136
137    fn run_step_pass(lanes: &mut AntLanes, ctx: &StepCtx<'_, Self>, seed: u64, tick: u64) -> u64 {
138        step::advect(lanes, ctx, seed, tick)
139    }
140
141    #[expect(clippy::single_match, reason = "for future multi-action extendability")]
142    fn act(
143        action: usize,
144        lanes: &mut AntLanes,
145        field: &mut ScalarField<PheromoneField>,
146        extent: Extent,
147        params: &[ParamValue],
148        rng: &mut u64,
149    ) {
150        match action {
151            RESET_COLONY => reset_colony(lanes, field, extent, params, rng),
152            _ => {}
153        }
154    }
155
156    fn stats(lanes: &AntLanes, field: &ScalarField<PheromoneField>, tally: &u64) -> Vec<StatValue> {
157        let carrying = lanes.has_food.iter().filter(|&&f| f != 0).count();
158        vec![
159            StatValue::Scalar(carrying as f64),
160            StatValue::Scalar(*tally as f64),
161            StatValue::Scalar(total_pheromone(field.field(TO_FOOD), field.field(TO_HOME))),
162        ]
163    }
164}
165
166/// Wipes both trails and puts the ants back on the nest, holding a reward.
167///
168/// Wiping the trails alone would end the run. A deposit is the neighbourhood's best value lifted by
169/// the ant's reward, and only a site grants a reward, so from an empty field ants that are not standing
170/// on a site lay nothing and nothing ever grows back.
171fn reset_colony(
172    lanes: &mut AntLanes,
173    field: &mut ScalarField<PheromoneField>,
174    extent: Extent,
175    params: &[ParamValue],
176    rng: &mut u64,
177) {
178    for layer in [TO_FOOD, TO_HOME] {
179        field.field_mut(layer).current_mut().fill(0.0);
180    }
181    AntsModel::init(lanes, extent, params, rng);
182    lanes.has_food.fill(0);
183    lanes.last_step.fill(NO_STEP);
184}
185
186/// Sums both pheromone layers chunk by chunk in index order, so rayon's scheduling cannot change the total.
187fn total_pheromone(to_food: &Grid2D<f32>, to_home: &Grid2D<f32>) -> f64 {
188    field_sum(to_food.current()) + field_sum(to_home.current())
189}
190
191fn field_sum(cells: &[f32]) -> f64 {
192    reduce_chunks(
193        cells.len(),
194        STATS_CHUNK,
195        |r| cells[r].iter().map(|&v| f64::from(v)).sum::<f64>(),
196        |a, b| a + b,
197        0.0,
198    )
199}
200
201#[cfg(test)]
202mod tests {
203
204    use super::*;
205    use crate::ants::field::{CELL_PALETTE, OBSTACLE};
206    use henad_compute::cpu::agent_engine::AgentModelState;
207    use henad_core::model::SimState as _;
208
209    type State = AgentModelState<AntsModel>;
210
211    fn default_state() -> State {
212        State::from_params(&[ParamValue::U32(500), ParamValue::F32(200.0), ParamValue::F32(200.0)])
213    }
214
215    #[test]
216    fn every_ant_starts_on_the_nest_holding_a_reward() {
217        let state = default_state();
218        let lanes = state.lanes();
219        let nest = nest_cell(200, 200) as u32;
220        let (nx, ny) = ((nest % 200) as f32, (nest / 200) as f32);
221        for i in 0..lanes.pos_x.len() {
222            assert_eq!((lanes.pos_x[i], lanes.pos_y[i]), (nx, ny), "ant {i} is not on the nest");
223            assert_eq!(lanes.reward[i], 1.0, "ant {i} has no reward to spend");
224        }
225    }
226
227    /// Every quantised value must land inside the palette, or the renderer silently draws entry 0.
228    #[test]
229    fn every_display_cell_indexes_the_palette() {
230        let mut state = default_state();
231        for _ in 0..50 {
232            state.step();
233        }
234        state.prepare_view();
235        for (c, &cell) in state.field().display_cells().iter().enumerate() {
236            assert!(
237                (cell as usize) < CELL_PALETTE.len(),
238                "cell {c} quantized to {cell}, past the palette"
239            );
240        }
241    }
242
243    /// Forgetting the refresh leaves the grid layer frozen at construction, with sites still
244    /// rendering and pheromone never appearing.
245    #[test]
246    fn the_grid_layer_shows_pheromone_laid_since_construction() {
247        let mut state = default_state();
248        let trail_count = |s: &State| {
249            s.field()
250                .display_cells()
251                .iter()
252                .filter(|&&c| (1..=12).contains(&c))
253                .count()
254        };
255        assert_eq!(trail_count(&state), 0, "no trail should exist before the first tick");
256
257        for _ in 0..100 {
258            state.step();
259        }
260        state.prepare_view();
261        assert!(
262            trail_count(&state) > 0,
263            "ants have been depositing for 100 ticks but the grid layer shows no trail at all"
264        );
265    }
266
267    /// `has_food` goes to the renderer as a palette index, and every value it holds has to index the palette.
268    #[test]
269    fn has_food_stays_a_valid_palette_index() {
270        let mut state = default_state();
271        for _ in 0..100 {
272            state.step();
273        }
274        assert!(
275            state.lanes().has_food.iter().all(|&f| (f as usize) < ANT_PALETTE.len()),
276            "has_food is doubling as the render lane, so it may only hold 0 or 1"
277        );
278    }
279
280    #[test]
281    fn ants_stay_inside_the_bounded_field() {
282        let mut state = default_state();
283        for tick in 0..200 {
284            state.step();
285            let lanes = state.lanes();
286            for i in 0..lanes.pos_x.len() {
287                let (x, y) = (lanes.pos_x[i], lanes.pos_y[i]);
288                assert!(
289                    (0.0..200.0).contains(&x) && (0.0..200.0).contains(&y),
290                    "ant {i} left the field at ({x}, {y}) on tick {tick}; the reference is bounded, not toroidal"
291                );
292            }
293        }
294    }
295
296    /// An obstacle check is easy to forget in the momentum and random action fallbacks.
297    #[test]
298    fn ants_never_enter_an_obstacle() {
299        let mut state = default_state();
300        for tick in 0..200 {
301            state.step();
302            let sites = state.field().sites().to_vec();
303            let lanes = state.lanes();
304            for i in 0..lanes.pos_x.len() {
305                let c = (lanes.pos_y[i] as u32 * 200 + lanes.pos_x[i] as u32) as usize;
306                assert_ne!(sites[c], OBSTACLE, "ant {i} is inside an obstacle on tick {tick}");
307            }
308        }
309    }
310
311    /// Three things could leak scheduling into the result. The scatter arm comes from the worker
312    /// count, the movement RNG is seeded per chunk, and deliveries are a parallel reduction.
313    #[test]
314    fn results_do_not_depend_on_the_thread_count() {
315        /// Returns the ant cells, the deliveries and both pheromone fields as raw bits after a run on
316        /// `threads` workers.
317        fn run(threads: usize) -> (Vec<u32>, u64, Vec<u32>) {
318            let pool = rayon::ThreadPoolBuilder::new()
319                .num_threads(threads)
320                .build()
321                .expect("rayon pool");
322            pool.install(|| {
323                // Three chunks of `CHUNK`, so the per-chunk seeding runs with the work split across workers.
324                let num_agents = 3 * <AntsModel as AgentModel>::CHUNK as u32;
325                let mut state = State::from_params(&[
326                    ParamValue::U32(num_agents),
327                    ParamValue::F32(200.0),
328                    ParamValue::F32(200.0),
329                ]);
330                for _ in 0..200 {
331                    state.step();
332                }
333                let lanes = state.lanes();
334                let cells = lanes
335                    .pos_x
336                    .iter()
337                    .zip(&lanes.pos_y)
338                    .map(|(&x, &y)| y as u32 * 200 + x as u32)
339                    .collect();
340                let field = state
341                    .field()
342                    .field(TO_HOME)
343                    .current()
344                    .iter()
345                    .chain(state.field().field(TO_FOOD).current())
346                    .map(|v| v.to_bits())
347                    .collect();
348                (cells, *state.tally(), field)
349            })
350        }
351
352        let (cells_1, deliveries_1, field_1) = run(1);
353        let (cells_n, deliveries_n, field_n) = run(7);
354        assert_eq!(cells_1, cells_n, "ant positions depend on the thread count");
355        assert_eq!(deliveries_1, deliveries_n, "delivery count depends on the thread count");
356        assert_eq!(
357            field_1, field_n,
358            "pheromone field is not bit-identical across thread counts"
359        );
360    }
361
362    /// The engine owns the extent, so the agent layer and the field layer cannot disagree.
363    #[test]
364    fn both_layers_report_the_same_world() {
365        let state = default_state();
366        let points = state.point_view().expect("ants draw agents");
367        let grid = state.grid_view().expect("ants draw a field");
368        assert_eq!(
369            (points.world_w, points.world_h),
370            (grid.width as f32, grid.height as f32)
371        );
372    }
373}