1pub 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
24pub const ANT_PALETTE: [[u8; 4]; 2] = [
26 [0xE8, 0xE8, 0xF0, 0xFF], [0x3D, 0xD5, 0x8C, 0xFF], ];
29
30pub const STAT_PALETTE: [[u8; 4]; 3] = [
32 [0x3D, 0xD5, 0x8C, 0xFF], [0xF2, 0xE4, 0x5C, 0xFF], [0x2E, 0x8B, 0xE8, 0xFF], ];
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#[derive(Debug)]
56pub struct AntsModel;
57
58#[derive(Debug)]
60pub struct AntParams {
61 pub w: i32,
63 pub h: i32,
65 pub cutdown: f32,
68 pub diagonal: f32,
70 pub reward: f32,
72 pub momentum: f32,
74 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 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 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
166fn 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
186fn 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 #[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 #[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 #[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 #[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 #[test]
314 fn results_do_not_depend_on_the_thread_count() {
315 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 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 #[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}