Skip to main content

henad_core/
action.rs

1//! One-off steps a model offers the user, run between ticks.
2
3use std::fmt;
4use std::num::ParseIntError;
5
6use crate::authoring::primitives::rng::mix_seed;
7use crate::model::SimState;
8
9/// An action a model declares.
10///
11/// The Parameters panel draws a button per entry, and `henad-cli` schedules an action with `--act`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct ActionDescriptor {
14    /// Stable name that `--act` matches on.
15    pub id: &'static str,
16    /// Button label.
17    pub label: &'static str,
18}
19
20impl ActionDescriptor {
21    /// Creates a descriptor from its id and its button label.
22    pub const fn new(id: &'static str, label: &'static str) -> Self {
23        Self { id, label }
24    }
25}
26
27/// Domain separator for the action stream.
28const ACTION_SALT: u64 = 0x00AC_7104_5EED_0001;
29
30/// Returns the start of a state's action stream, from the seed the state was built with.
31///
32/// The action stream is kept apart from the tick stream. Otherwise a press would draw the numbers the next tick
33/// would have.
34pub fn action_seed(seed: Option<u64>) -> u64 {
35    mix_seed(seed.unwrap_or(0) ^ ACTION_SALT)
36}
37
38/// Declares a model's actions and their indices in one place.
39///
40/// The index is the declaration's position, so it is derived rather than written down. Invoke it at
41/// module scope, next to the impl that forwards `ACTIONS` to `ACTION_SPECS`.
42///
43/// ```ignore
44/// actions! {
45///     const RANDOMISE = ActionDescriptor::new("randomise", "Randomise");
46///     const CLEAR = ActionDescriptor::new("clear", "Clear");
47/// }
48/// ```
49#[macro_export]
50macro_rules! actions {
51    ($($(#[$meta:meta])* $vis:vis const $name:ident = $descriptor:expr;)+) => {
52        $crate::__indices!(0usize, $([$(#[$meta])* $vis $name],)+);
53
54        /// This model's actions, in index order.
55        const ACTION_SPECS: &[$crate::__macro_support::ActionDescriptor] = &[$($descriptor),+];
56    };
57}
58
59/// One `--act` entry, resolved against the model's declared actions.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Scheduled {
62    /// Index of the action in the model's declared actions.
63    pub index: usize,
64    /// Id of the action.
65    pub id: String,
66    /// Tick the action is due at.
67    pub tick: u64,
68}
69
70/// Side of a step on which a run of GPU steps fires the actions due.
71///
72/// Each rule mirrors one CPU loop. Two runs back to back share the tick where the first stops and the
73/// second starts, and under one rule only one of the two runs fires it.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum Fire {
76    /// Before the step that leaves a tick, as the CPU benchmark loop does. A run leaves the tick it
77    /// stops on to the caller.
78    BeforeStep,
79    /// After the step that reaches a tick, as the CPU stats loop does, so a sample taken there sees
80    /// the action. A run leaves the tick it starts on to the caller.
81    AfterStep,
82}
83
84/// Entries that a state rejected, in the order given.
85pub type RefusedActions<'a> = Vec<&'a Scheduled>;
86
87/// An `--act` entry that cannot be resolved.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ScheduleError {
90    /// An entry not of the form `ID@TICK`.
91    BadEntry {
92        /// Entry as written.
93        raw: String,
94    },
95    /// An entry whose tick is not a whole number.
96    BadTick {
97        /// Entry as written.
98        raw: String,
99        /// Error of the `u64` parser.
100        source: ParseIntError,
101    },
102    /// An entry for a model that declares no actions.
103    NoActions {
104        /// Id of the model.
105        model: String,
106    },
107    /// An action id that `model` does not declare.
108    UnknownAction {
109        /// Action id as given.
110        id: String,
111        /// Id of the model.
112        model: String,
113        /// Action ids the model declares.
114        known: Vec<&'static str>,
115    },
116}
117
118impl fmt::Display for ScheduleError {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            Self::BadEntry { raw } => write!(f, "bad --act '{raw}', expected ID@TICK"),
122            Self::BadTick { raw, .. } => write!(f, "bad tick in --act '{raw}'"),
123            Self::NoActions { model } => write!(f, "'{model}' declares no actions"),
124            Self::UnknownAction { id, model, known } => {
125                write!(f, "unknown action '{id}' for '{model}' (has {})", known.join(", "))
126            }
127        }
128    }
129}
130
131impl std::error::Error for ScheduleError {
132    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
133        match self {
134            Self::BadTick { source, .. } => Some(source),
135            _ => None,
136        }
137    }
138}
139
140/// Actions to run at set ticks, in the order given.
141///
142/// A loop with no actions holds an empty schedule, and [`Self::run_due`] then returns after a single test.
143#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct Schedule {
145    entries: Vec<Scheduled>,
146}
147
148impl Schedule {
149    /// Resolves each `ID@TICK` against `actions`, the actions of model `model_id`. Order is preserved, so two
150    /// entries at one tick run in the order given.
151    ///
152    /// An entry splits at its last `@`, so an id can contain `@`.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`ScheduleError`] for an entry that is not of the form `ID@TICK`, or refers to no action in `actions`.
157    pub fn parse(raw: &[String], model_id: &str, actions: &[ActionDescriptor]) -> Result<Self, ScheduleError> {
158        let mut entries = Vec::with_capacity(raw.len());
159        for spec in raw {
160            let (id, tick) = spec
161                .rsplit_once('@')
162                .ok_or_else(|| ScheduleError::BadEntry { raw: spec.clone() })?;
163            let tick = tick.parse::<u64>().map_err(|source| ScheduleError::BadTick {
164                raw: spec.clone(),
165                source,
166            })?;
167            let Some(index) = actions.iter().position(|action| action.id == id) else {
168                let known: Vec<&'static str> = actions.iter().map(|action| action.id).collect();
169                if known.is_empty() {
170                    return Err(ScheduleError::NoActions {
171                        model: model_id.to_owned(),
172                    });
173                }
174                return Err(ScheduleError::UnknownAction {
175                    id: id.to_owned(),
176                    model: model_id.to_owned(),
177                    known,
178                });
179            };
180            entries.push(Scheduled {
181                index,
182                id: id.to_owned(),
183                tick,
184            });
185        }
186        Ok(Self { entries })
187    }
188
189    /// Builds a schedule from resolved entries. Two entries due at one tick run in the order given.
190    pub fn from_entries(entries: Vec<Scheduled>) -> Self {
191        Self { entries }
192    }
193
194    /// Entries in the order given.
195    pub fn entries(&self) -> &[Scheduled] {
196        &self.entries
197    }
198
199    /// Returns whether no action is scheduled.
200    pub fn is_empty(&self) -> bool {
201        self.entries.is_empty()
202    }
203
204    /// Highest tick at which any action is due.
205    pub fn last_tick(&self) -> Option<u64> {
206        self.entries.iter().map(|a| a.tick).max()
207    }
208
209    /// Returns the earliest tick after `tick` at which any action is due.
210    pub fn next_due_after(&self, tick: u64) -> Option<u64> {
211        self.entries
212            .iter()
213            .map(|entry| entry.tick)
214            .filter(|&due| due > tick)
215            .min()
216    }
217
218    /// Returns the actions due exactly at `tick`, in the order given.
219    pub fn due(&self, tick: u64) -> impl Iterator<Item = &Scheduled> {
220        self.entries.iter().filter(move |a| a.tick == tick)
221    }
222
223    /// Returns the ticks at which a run of `count` steps from `start` fires under `fire`, in increasing
224    /// order and each once.
225    ///
226    /// [`Fire::BeforeStep`] covers `start..start + count` and [`Fire::AfterStep`] covers
227    /// `start + 1..=start + count`. A run of no steps fires nothing under either rule.
228    pub fn fire_ticks(&self, start: u64, count: u64, fire: Fire) -> Vec<u64> {
229        let window = match fire {
230            Fire::BeforeStep => start..start.saturating_add(count),
231            Fire::AfterStep => start.saturating_add(1)..start.saturating_add(count).saturating_add(1),
232        };
233        let mut ticks: Vec<u64> = self
234            .entries
235            .iter()
236            .map(|a| a.tick)
237            .filter(|t| window.contains(t))
238            .collect();
239        ticks.sort_unstable();
240        ticks.dedup();
241        ticks
242    }
243
244    /// Runs the actions due at the state's current tick, and returns the entries that the state rejected.
245    ///
246    /// A loop calls it either before each step and once at the end, or once at the start and after each step. Either
247    /// way every tick from 0 to the tick where the run stops fires once.
248    #[inline]
249    #[must_use = "a refused action is reported only through the returned entries"]
250    pub fn run_due(&self, state: &mut dyn SimState) -> RefusedActions<'_> {
251        if self.entries.is_empty() {
252            return Vec::new();
253        }
254        let tick = state.tick();
255        let mut refused = Vec::new();
256        for action in self.due(tick) {
257            if !state.act(action.index) {
258                refused.push(action);
259            }
260        }
261        refused
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::{ActionDescriptor, Fire, Schedule, Scheduled};
268
269    /// The macro has to expand in function scope as well as module scope (C-ANYWHERE).
270    #[test]
271    fn actions_macro_numbers_entries_in_declaration_order() {
272        crate::actions! {
273            const RANDOMISE = crate::action::ActionDescriptor::new("randomise", "Randomise");
274            /// An entry can carry a doc comment.
275            const CLEAR = crate::action::ActionDescriptor::new("clear", "Clear");
276        }
277        assert_eq!((RANDOMISE, CLEAR), (0, 1), "indices follow declaration order");
278        assert_eq!(ACTION_SPECS.len(), 2);
279        assert_eq!(ACTION_SPECS[CLEAR].label, "Clear");
280    }
281
282    /// Two states built from one seed must agree, and two states built from different seeds must not.
283    #[test]
284    fn the_action_stream_is_seeded_and_apart_from_the_tick_stream() {
285        use crate::action::action_seed;
286        assert_eq!(action_seed(Some(7)), action_seed(Some(7)));
287        assert_ne!(action_seed(Some(7)), action_seed(Some(8)));
288        assert_ne!(action_seed(Some(7)), crate::authoring::primitives::rng::mix_seed(7));
289        assert_ne!(action_seed(None), 0, "an unseeded state still needs a usable state");
290    }
291
292    fn schedule_at(ticks: &[u64]) -> Schedule {
293        let entries = ticks
294            .iter()
295            .map(|&tick| Scheduled {
296                index: 0,
297                id: "act".to_owned(),
298                tick,
299            })
300            .collect();
301        Schedule::from_entries(entries)
302    }
303
304    /// Returns the ticks each run fires when runs of `counts` steps go back to back from tick 0.
305    fn fire_runs(schedule: &Schedule, counts: &[u64], fire: Fire) -> Vec<Vec<u64>> {
306        let mut start = 0;
307        counts
308            .iter()
309            .map(|&count| {
310                let fired = schedule.fire_ticks(start, count, fire);
311                start += count;
312                fired
313            })
314            .collect()
315    }
316
317    /// Checks that runs placed back to back fire a shared tick once, however the steps are split.
318    ///
319    /// Under the before-step rule the caller fires tick 40, and under the after-step rule it fires tick 0.
320    #[test]
321    fn back_to_back_runs_fire_each_tick_once() {
322        // Two actions at tick 10 fire in one stop. Tick 41 is past the end.
323        let schedule = schedule_at(&[0, 7, 10, 10, 14, 39, 40, 41]);
324        let splits: [&[u64]; 4] = [&[40], &[10, 10, 10, 10], &[7, 7, 7, 7, 7, 5], &[0, 13, 0, 27, 0]];
325        for split in splits {
326            let before = fire_runs(&schedule, split, Fire::BeforeStep).concat();
327            assert_eq!(before, [0, 7, 10, 14, 39], "before the step, runs of {split:?}");
328            let after = fire_runs(&schedule, split, Fire::AfterStep).concat();
329            assert_eq!(after, [7, 10, 14, 39, 40], "after the step, runs of {split:?}");
330        }
331    }
332
333    #[test]
334    fn the_next_due_tick_is_strictly_after_the_one_given() {
335        let schedule = schedule_at(&[40, 7, 10, 10]);
336        assert_eq!(schedule.next_due_after(0), Some(7));
337        assert_eq!(schedule.next_due_after(7), Some(10), "a tick is not due after itself");
338        assert_eq!(schedule.next_due_after(10), Some(40));
339        assert_eq!(schedule.next_due_after(40), None);
340        assert_eq!(Schedule::default().next_due_after(0), None);
341        assert!(Schedule::default().is_empty());
342    }
343
344    #[test]
345    fn an_entry_resolves_to_its_declared_action() {
346        let actions = [
347            ActionDescriptor::new("randomise", "Randomise"),
348            ActionDescriptor::new("clear", "Clear"),
349        ];
350        let raw = ["clear@5", "randomise@2", "clear@5"].map(str::to_owned);
351        let schedule = Schedule::parse(&raw, "life", &actions).expect("declared actions");
352        let resolved: Vec<(usize, u64)> = schedule
353            .entries()
354            .iter()
355            .map(|entry| (entry.index, entry.tick))
356            .collect();
357        assert_eq!(resolved, [(1, 5), (0, 2), (1, 5)], "in the order given");
358
359        let refuse = |raw: &str, actions: &[ActionDescriptor]| {
360            Schedule::parse(&[raw.to_owned()], "life", actions)
361                .expect_err("refused")
362                .to_string()
363        };
364        assert_eq!(refuse("clear", &actions), "bad --act 'clear', expected ID@TICK");
365        assert_eq!(refuse("clear@soon", &actions), "bad tick in --act 'clear@soon'");
366        let bad_tick = Schedule::parse(&["clear@soon".to_owned()], "life", &actions).expect_err("refused");
367        let source = std::error::Error::source(&bad_tick).map(ToString::to_string);
368        assert_eq!(
369            source.as_deref(),
370            Some("invalid digit found in string"),
371            "the tick's parse error is the source"
372        );
373        assert_eq!(refuse("clear@5", &[]), "'life' declares no actions");
374        assert_eq!(
375            refuse("reset@5", &actions),
376            "unknown action 'reset' for 'life' (has randomise, clear)"
377        );
378    }
379
380    /// Checks that an entry splits at its last `@`, so an id can contain `@`.
381    #[test]
382    fn an_id_holding_an_at_sign_resolves() {
383        let actions = [ActionDescriptor::new("spawn@centre", "Spawn at centre")];
384        let schedule = Schedule::parse(&["spawn@centre@100".to_owned()], "life", &actions).expect("a declared action");
385        assert_eq!(
386            schedule.entries(),
387            [Scheduled {
388                index: 0,
389                id: "spawn@centre".to_owned(),
390                tick: 100,
391            }]
392        );
393    }
394}