Skip to main content

henad_core/
view.rs

1//! Views a state passes to the renderer, and the stat values and history the UI charts.
2
3/// A 2D grid for rendering, each cell a `u8` index into the palette.
4pub struct GridView<'a> {
5    /// Width in cells.
6    pub width: u32,
7    /// Height in cells.
8    pub height: u32,
9    /// Palette index of each cell, row-major.
10    pub cells: &'a [u8],
11    /// RGBA colours that the cells index.
12    pub palette: &'static [[u8; 4]],
13}
14
15/// Prints the grid's size, not its cells.
16impl std::fmt::Debug for GridView<'_> {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.debug_struct("GridView")
19            .field("width", &self.width)
20            .field("height", &self.height)
21            .field("palette_len", &self.palette.len())
22            .finish_non_exhaustive()
23    }
24}
25
26/// An agent population for rendering.
27///
28/// Both layers are stretched to the same rect, so a composite model wants
29/// `world_w = width as f32`. Nothing checks this across the crate boundary.
30pub struct PointView<'a> {
31    /// Position of each agent along x.
32    pub pos_x: &'a [f32],
33    /// Position of each agent along y.
34    pub pos_y: &'a [f32],
35    /// Width of the world the positions lie in.
36    pub world_w: f32,
37    /// Height of the world the positions lie in.
38    pub world_h: f32,
39    /// One palette index per agent. `None` colours the whole population `palette[0]`.
40    pub color: Option<&'a [u8]>,
41    /// RGBA colours that the agents index.
42    pub palette: &'static [[u8; 4]],
43}
44
45/// Prints the point count and the world, not the positions.
46impl std::fmt::Debug for PointView<'_> {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("PointView")
49            .field("len", &self.pos_x.len())
50            .field("world_w", &self.world_w)
51            .field("world_h", &self.world_h)
52            .field("colored", &self.color.is_some())
53            .field("palette_len", &self.palette.len())
54            .finish_non_exhaustive()
55    }
56}
57
58/// Edges for rendering. Endpoints are indices into the point view's positions.
59pub struct EdgeView<'a> {
60    /// Source node of each edge.
61    pub src: &'a [u32],
62    /// Destination node of each edge.
63    pub dst: &'a [u32],
64    /// One palette index per edge. `None` colours every edge `palette[0]`.
65    pub color: Option<&'a [u8]>,
66    /// RGBA colours that the edges index.
67    pub palette: &'static [[u8; 4]],
68    /// Whether the edges are directed.
69    pub directed: bool,
70    /// Version of the graph. It moves whenever the edges, their colours or their direction change.
71    pub version: u64,
72}
73
74/// Prints the edge count and the version, not the edges.
75impl std::fmt::Debug for EdgeView<'_> {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("EdgeView")
78            .field("len", &self.src.len())
79            .field("colored", &self.color.is_some())
80            .field("palette_len", &self.palette.len())
81            .field("directed", &self.directed)
82            .field("version", &self.version)
83            .finish_non_exhaustive()
84    }
85}
86
87/// Value of one stat series at one sample.
88#[derive(Debug, Clone)]
89pub enum StatValue {
90    /// A single number.
91    Scalar(f64),
92    /// A 2D vector.
93    Vector2D {
94        /// Component along the x axis.
95        x: f64,
96        /// Component along the y axis.
97        y: f64,
98    },
99    /// A histogram.
100    Histogram {
101        /// Bucket boundaries, one more than there are buckets.
102        edges: Vec<f64>,
103        /// Number of values in each bucket. `counts[i]` counts the values in `[edges[i], edges[i + 1])`.
104        counts: Vec<u64>,
105    },
106}
107
108impl StatValue {
109    /// Returns one representative number, for charting: the value itself, a vector's magnitude or a
110    /// histogram's total count.
111    pub fn scalar(&self) -> f64 {
112        match self {
113            Self::Scalar(v) => *v,
114            Self::Vector2D { x, y } => x.hypot(*y),
115            Self::Histogram { counts, .. } => counts.iter().sum::<u64>() as f64,
116        }
117    }
118}
119
120/// One sample of a stat series, with the series' label and colour.
121#[derive(Debug, Clone)]
122pub struct StatEntry {
123    /// Label of the series.
124    pub label: &'static str,
125    /// Value of this sample.
126    pub value: StatValue,
127    /// RGBA colour of the series.
128    pub color: [u8; 4],
129}
130
131/// A stat series a model declares.
132#[derive(Debug, Clone)]
133pub struct StatDescriptor {
134    /// Label that identifies the series in the UI, in result columns and in a stop condition.
135    pub label: &'static str,
136    /// RGBA colour of the series in the chart.
137    pub color: [u8; 4],
138}
139
140impl StatDescriptor {
141    /// Creates a descriptor from a label and a colour.
142    pub const fn new(label: &'static str, color: [u8; 4]) -> Self {
143        Self { label, color }
144    }
145}
146
147/// Pairs a model's declared series with the values it just produced.
148///
149/// A model declares labels and colours once as a const and returns bare values, so the labels and the values
150/// cannot drift apart. A short `values` leaves the trailing series out rather than mislabelling anything.
151pub fn stat_entries(descriptors: &'static [StatDescriptor], values: Vec<StatValue>) -> Vec<StatEntry> {
152    descriptors
153        .iter()
154        .zip(values)
155        .map(|(d, value)| StatEntry {
156            label: d.label,
157            value,
158            color: d.color,
159        })
160        .collect()
161}
162
163/// Ring-buffer history of stat values, polled every snapshot.
164///
165/// The charts read one `f64` per series per frame, so that is what a sample costs. A series that
166/// a scalar cannot round-trip keeps its full value alongside. An export then carries the same columns
167/// that the headless runner writes.
168pub struct StatsHistory {
169    /// One column per stat series, each holding `capacity` entries.
170    columns: Vec<Vec<f64>>,
171    /// Full values, `None` for a scalar series whose `f64` column already holds everything.
172    full: Vec<Option<Vec<StatValue>>>,
173    /// Ticks corresponding to each entry in the columns. Same length as each column.
174    ticks: Vec<u64>,
175    descriptors: Vec<StatDescriptor>,
176    /// Total number of entries written, past the capacity once the history wraps.
177    write_count: usize,
178    /// Maximum number of samples kept, `None` to keep every sample so a whole run can be exported.
179    capacity: Option<usize>,
180}
181
182/// Prints the series labels and the sample counts, not the samples.
183impl std::fmt::Debug for StatsHistory {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        let labels: Vec<&str> = self.descriptors.iter().map(|descriptor| descriptor.label).collect();
186        f.debug_struct("StatsHistory")
187            .field("labels", &labels)
188            .field("len", &self.len())
189            .field("write_count", &self.write_count)
190            .field("capacity", &self.capacity)
191            .finish_non_exhaustive()
192    }
193}
194
195impl StatsHistory {
196    /// Creates an empty history of `descriptors`, keeping at most `capacity` samples, or every sample
197    /// for `None`.
198    pub fn new(descriptors: Vec<StatDescriptor>, capacity: Option<usize>) -> Self {
199        let reserve = capacity.unwrap_or(0);
200        let columns = vec![Vec::with_capacity(reserve); descriptors.len()];
201        let full = vec![None; descriptors.len()];
202        let ticks = Vec::with_capacity(reserve);
203        Self {
204            columns,
205            full,
206            ticks,
207            descriptors,
208            write_count: 0,
209            capacity,
210        }
211    }
212
213    /// Records one sample, replacing the newest entry if it has the same tick.
214    ///
215    /// A publish repeats a tick when nothing stepped, as after an action or while a paused layout
216    /// relaxes. The replacement keeps one entry per tick, holding the latest stats for it.
217    ///
218    /// The first sample fixes which series keep a full value, the same way it fixes a CSV column
219    /// layout. A series that changes kind later is a model bug, and
220    /// [`crate::export::StatsWriter`] already reports it as a bug.
221    pub fn push_entries(&mut self, stats: &[StatEntry], tick: u64) {
222        let newest = self.len().checked_sub(1).and_then(|j| self.buf_index(j));
223        if let Some(idx) = newest
224            && self.ticks[idx] == tick
225        {
226            self.write_at(idx, stats, tick);
227            return;
228        }
229
230        if self.write_count == 0 {
231            for (slot, entry) in self.full.iter_mut().zip(stats) {
232                if !matches!(entry.value, StatValue::Scalar(_)) {
233                    *slot = Some(Vec::new());
234                }
235            }
236        }
237
238        match self.capacity {
239            Some(capacity) if self.write_count >= capacity => {
240                // The buffer is full, so the oldest entry is overwritten.
241                self.write_at(self.write_count % capacity, stats, tick);
242            }
243            _ => {
244                for (col, entry) in self.columns.iter_mut().zip(stats) {
245                    col.push(entry.value.scalar());
246                }
247                for (slot, entry) in self.full.iter_mut().zip(stats) {
248                    if let Some(values) = slot {
249                        values.push(entry.value.clone());
250                    }
251                }
252                self.ticks.push(tick);
253            }
254        }
255        self.write_count += 1;
256    }
257
258    /// Writes a sample over buffer slot `idx`.
259    fn write_at(&mut self, idx: usize, stats: &[StatEntry], tick: u64) {
260        for (col, entry) in self.columns.iter_mut().zip(stats) {
261            col[idx] = entry.value.scalar();
262        }
263        for (slot, entry) in self.full.iter_mut().zip(stats) {
264            if let Some(values) = slot {
265                values[idx] = entry.value.clone();
266            }
267        }
268        self.ticks[idx] = tick;
269    }
270
271    /// Series the history records.
272    pub fn descriptors(&self) -> &[StatDescriptor] {
273        &self.descriptors
274    }
275
276    /// Number of entries stored, at most the capacity.
277    pub fn len(&self) -> usize {
278        self.capacity.map_or(self.write_count, |cap| self.write_count.min(cap))
279    }
280
281    /// Returns whether no sample has been recorded.
282    pub fn is_empty(&self) -> bool {
283        self.write_count == 0
284    }
285
286    /// Maximum number of samples the history keeps, or `None` while it keeps every sample.
287    pub fn capacity(&self) -> Option<usize> {
288        self.capacity
289    }
290
291    /// Total number of writes, including those that wrapped.
292    pub fn write_count(&self) -> usize {
293        self.write_count
294    }
295
296    /// Returns the buffer slot holding logical index `j`, where 0 is the oldest visible entry.
297    fn buf_index(&self, j: usize) -> Option<usize> {
298        if j >= self.len() {
299            return None;
300        }
301        match self.capacity {
302            Some(capacity) => Some((self.write_count.saturating_sub(capacity) + j) % capacity),
303            None => Some(j),
304        }
305    }
306
307    /// Returns the value of series `col` and its tick at logical index `j`, where 0 is the oldest visible entry.
308    pub fn get(&self, col: usize, j: usize) -> Option<(f64, u64)> {
309        let idx = self.buf_index(j)?;
310        let value = self.columns.get(col)?.get(idx).copied()?;
311        let tick = self.ticks.get(idx).copied()?;
312        Some((value, tick))
313    }
314
315    /// Returns the tick at logical index `j`, where 0 is the oldest visible entry.
316    pub fn tick(&self, j: usize) -> Option<u64> {
317        self.ticks.get(self.buf_index(j)?).copied()
318    }
319
320    /// Returns the sample at logical index `j`, in the shape that [`crate::export::StatsWriter`] accepts.
321    pub fn entries(&self, j: usize) -> Option<Vec<StatEntry>> {
322        let idx = self.buf_index(j)?;
323        let entries = self
324            .descriptors
325            .iter()
326            .enumerate()
327            .map(|(col, desc)| {
328                let value = match self.full.get(col).and_then(Option::as_ref) {
329                    Some(values) => values[idx].clone(),
330                    None => StatValue::Scalar(self.columns[col][idx]),
331                };
332                StatEntry {
333                    label: desc.label,
334                    value,
335                    color: desc.color,
336                }
337            })
338            .collect();
339        Some(entries)
340    }
341
342    /// Heap memory held by the history, in bytes.
343    pub fn heap_bytes(&self) -> usize {
344        let scalars = self.columns.iter().map(|c| c.capacity() * 8).sum::<usize>();
345        let full = self
346            .full
347            .iter()
348            .flatten()
349            .map(|values| {
350                values.capacity() * size_of::<StatValue>() + values.iter().map(stat_value_heap).sum::<usize>()
351            })
352            .sum::<usize>();
353        scalars + full + self.ticks.capacity() * 8
354    }
355
356    /// Changes the capacity to `new_capacity`, keeping the most recent entries that fit.
357    pub fn resize(&mut self, new_capacity: Option<usize>) {
358        let filled = self.len();
359        let keep = new_capacity.map_or(filled, |cap| filled.min(cap));
360        let skip = filled - keep;
361
362        let slots: Vec<usize> = (skip..filled).filter_map(|j| self.buf_index(j)).collect();
363
364        let new_columns: Vec<Vec<f64>> = self
365            .columns
366            .iter()
367            .map(|column| slots.iter().map(|&i| column[i]).collect())
368            .collect();
369        let new_full: Vec<Option<Vec<StatValue>>> = self
370            .full
371            .iter()
372            .map(|slot| {
373                slot.as_ref()
374                    .map(|values| slots.iter().map(|&i| values[i].clone()).collect())
375            })
376            .collect();
377        let new_ticks: Vec<u64> = slots.iter().map(|&i| self.ticks[i]).collect();
378
379        self.columns = new_columns;
380        self.full = new_full;
381        self.ticks = new_ticks;
382        self.capacity = new_capacity;
383        self.write_count = keep;
384    }
385}
386
387/// Heap memory that a stat value owns beyond its own bytes. Only a histogram owns heap memory.
388fn stat_value_heap(value: &StatValue) -> usize {
389    match value {
390        StatValue::Scalar(_) | StatValue::Vector2D { .. } => 0,
391        StatValue::Histogram { edges, counts } => edges.capacity() * 8 + counts.capacity() * 8,
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::{StatDescriptor, StatEntry, StatValue, StatsHistory};
398
399    const C: [u8; 4] = [1, 2, 3, 255];
400
401    fn history(capacity: Option<usize>, labels: &[&'static str]) -> StatsHistory {
402        let descriptors = labels.iter().map(|l| StatDescriptor::new(l, C)).collect();
403        StatsHistory::new(descriptors, capacity)
404    }
405
406    fn scalars(values: &[f64]) -> Vec<StatEntry> {
407        values
408            .iter()
409            .map(|v| StatEntry {
410                label: "s",
411                value: StatValue::Scalar(*v),
412                color: C,
413            })
414            .collect()
415    }
416
417    fn vec2(x: f64, y: f64) -> Vec<StatEntry> {
418        vec![StatEntry {
419            label: "v",
420            value: StatValue::Vector2D { x, y },
421            color: C,
422        }]
423    }
424
425    #[test]
426    fn a_bounded_history_keeps_the_newest_entries() {
427        let mut h = history(Some(3), &["a"]);
428        for tick in 0u32..5 {
429            h.push_entries(&scalars(&[f64::from(tick)]), u64::from(tick));
430        }
431        assert_eq!(h.len(), 3);
432        assert_eq!(h.get(0, 0), Some((2.0, 2)));
433        assert_eq!(h.get(0, 2), Some((4.0, 4)));
434        assert_eq!(h.get(0, 3), None);
435    }
436
437    /// An action publishes at the tick it was pressed on. Its stats replace that tick's entry
438    /// rather than adding a second entry, whether or not a bounded history has wrapped.
439    #[test]
440    fn a_sample_at_the_newest_tick_replaces_it() {
441        for (capacity, ticks) in [(None, 3u32), (Some(2), 2), (Some(2), 5)] {
442            let mut h = history(capacity, &["v"]);
443            for tick in 0..ticks {
444                h.push_entries(&vec2(f64::from(tick), 0.0), u64::from(tick));
445            }
446            let newest = u64::from(ticks - 1);
447            let (len, writes) = (h.len(), h.write_count());
448            h.push_entries(&vec2(3.0, 4.0), newest);
449
450            assert_eq!((h.len(), h.write_count()), (len, writes), "a second entry was added");
451            assert_eq!(h.get(0, len - 1), Some((5.0, newest)));
452            let sample = h.entries(len - 1).expect("the newest entry exists");
453            assert!(matches!(sample[0].value, StatValue::Vector2D { x, y } if x == 3.0 && y == 4.0));
454            assert_eq!(h.tick(len - 2), Some(newest - 1), "an older entry changed");
455        }
456    }
457
458    #[test]
459    fn an_unlimited_history_drops_nothing() {
460        let mut h = history(None, &["a"]);
461        for tick in 0u32..1000 {
462            h.push_entries(&scalars(&[f64::from(tick)]), u64::from(tick));
463        }
464        assert_eq!(h.len(), 1000);
465        assert_eq!(h.capacity(), None);
466        assert_eq!(h.get(0, 0), Some((0.0, 0)));
467        assert_eq!(h.get(0, 999), Some((999.0, 999)));
468    }
469
470    /// A scalar cannot round-trip a vector, and an export drawn from history must still carry x and y.
471    #[test]
472    fn a_vector_series_keeps_its_components() {
473        let mut h = history(Some(4), &["v"]);
474        h.push_entries(&vec2(3.0, 4.0), 0);
475        h.push_entries(&vec2(6.0, 8.0), 1);
476
477        // The chart still sees one number, the magnitude.
478        assert_eq!(h.get(0, 0), Some((5.0, 0)));
479
480        let sample = h.entries(1).expect("index 1 exists");
481        assert!(
482            matches!(sample[0].value, StatValue::Vector2D { x, y } if x == 6.0 && y == 8.0),
483            "got {:?}",
484            sample[0].value
485        );
486        assert_eq!(sample[0].label, "v");
487    }
488
489    #[test]
490    fn a_scalar_series_rebuilds_from_its_own_column() {
491        let mut h = history(Some(4), &["a", "b"]);
492        h.push_entries(&scalars(&[1.0, 2.0]), 7);
493        let sample = h.entries(0).expect("index 0 exists");
494        assert_eq!(sample.len(), 2);
495        assert!(matches!(sample[0].value, StatValue::Scalar(v) if v == 1.0));
496        assert!(matches!(sample[1].value, StatValue::Scalar(v) if v == 2.0));
497        assert!(h.entries(1).is_none());
498    }
499
500    /// Wrapping must not scramble the full values against their scalars.
501    #[test]
502    fn full_values_wrap_with_their_column() {
503        let mut h = history(Some(2), &["v"]);
504        for i in 0u32..5 {
505            h.push_entries(&vec2(f64::from(i), 0.0), u64::from(i));
506        }
507        for (j, expected) in [3.0, 4.0].into_iter().enumerate() {
508            let sample = h.entries(j).expect("both slots are filled");
509            assert!(
510                matches!(sample[0].value, StatValue::Vector2D { x, .. } if x == expected),
511                "slot {j} got {:?}",
512                sample[0].value
513            );
514            assert_eq!(h.get(0, j).map(|(v, _)| v), Some(expected));
515        }
516    }
517
518    #[test]
519    fn shrinking_keeps_the_newest_and_growing_keeps_everything() {
520        let mut h = history(Some(10), &["v"]);
521        for i in 0u32..6 {
522            h.push_entries(&vec2(f64::from(i), 0.0), u64::from(i));
523        }
524
525        h.resize(Some(3));
526        assert_eq!(h.len(), 3);
527        assert_eq!(h.tick(0), Some(3));
528        assert!(matches!(h.entries(0).expect("kept")[0].value, StatValue::Vector2D { x, .. } if x == 3.0));
529
530        h.resize(None);
531        assert_eq!(h.len(), 3, "growing adds no samples back");
532        h.push_entries(&vec2(9.0, 0.0), 9);
533        assert_eq!(h.len(), 4);
534        assert_eq!(h.tick(3), Some(9));
535    }
536
537    #[test]
538    fn a_scalar_series_costs_less_than_a_vector_one() {
539        let mut scalar = history(Some(64), &["a"]);
540        let mut vector = history(Some(64), &["v"]);
541        for i in 0u32..64 {
542            scalar.push_entries(&scalars(&[f64::from(i)]), u64::from(i));
543            vector.push_entries(&vec2(f64::from(i), 0.0), u64::from(i));
544        }
545        assert!(
546            vector.heap_bytes() > scalar.heap_bytes(),
547            "{} vs {}",
548            vector.heap_bytes(),
549            scalar.heap_bytes()
550        );
551    }
552}