Skip to main content

murk_arena/
scratch.rs

1//! Per-tick scratch space for temporary propagator allocations.
2//!
3//! [`ScratchRegion`] is a bump allocator over a `Vec<f32>`. It is reset
4//! between propagator invocations by the tick engine, so each propagator
5//! sees a fresh, empty scratch region. The backing allocation is reused
6//! across ticks to avoid repeated heap allocation.
7
8/// Bump-allocated scratch space for temporary per-propagator data.
9///
10/// Propagators can request temporary f32 slices from scratch space for
11/// intermediate calculations (e.g. neighbourhood sums, gradient buffers).
12/// The scratch region is reset between propagator executions within a
13/// single tick — allocations do not persist across propagators or ticks.
14///
15/// # Example (conceptual)
16///
17/// ```ignore
18/// let slice = scratch.alloc(100)?;
19/// // use slice for intermediate computation
20/// // scratch.reset() called by engine before next propagator
21/// ```
22pub struct ScratchRegion {
23    /// Backing storage. Grows on demand, never shrinks during runtime.
24    data: Vec<f32>,
25    /// Current bump pointer (number of f32 elements allocated so far).
26    cursor: usize,
27}
28
29impl ScratchRegion {
30    /// Create a new scratch region with the given initial capacity (in f32 elements).
31    pub fn new(initial_capacity: usize) -> Self {
32        Self {
33            data: vec![0.0; initial_capacity],
34            cursor: 0,
35        }
36    }
37
38    /// Allocate `len` f32 elements from scratch space.
39    ///
40    /// Returns a zero-initialised mutable slice. Returns `None` if the
41    /// scratch region cannot grow to accommodate the request (this should
42    /// not happen in practice since `Vec` grows on demand; `None` is
43    /// returned only if the system is out of memory).
44    pub fn alloc(&mut self, len: usize) -> Option<&mut [f32]> {
45        let new_cursor = self.cursor.checked_add(len)?;
46        if new_cursor > self.data.len() {
47            // Grow to at least double or the required size, whichever is larger.
48            let new_cap = self
49                .data
50                .len()
51                .max(1024)
52                .max(new_cursor)
53                .checked_mul(2)
54                .unwrap_or(new_cursor);
55            self.data.resize(new_cap, 0.0);
56        }
57        let start = self.cursor;
58        self.cursor = new_cursor;
59        // Zero-init the newly allocated region (may have stale data from previous use).
60        let slice = &mut self.data[start..new_cursor];
61        slice.fill(0.0);
62        Some(slice)
63    }
64
65    /// Reset the scratch region for the next propagator.
66    ///
67    /// This does NOT deallocate or zero the backing storage — it simply
68    /// resets the bump pointer. The next `alloc` call will overwrite
69    /// stale data with zeroes before returning.
70    pub fn reset(&mut self) {
71        self.cursor = 0;
72    }
73
74    /// Number of f32 elements currently allocated.
75    pub fn used(&self) -> usize {
76        self.cursor
77    }
78
79    /// Total capacity of the backing storage in f32 elements.
80    pub fn capacity(&self) -> usize {
81        self.data.len()
82    }
83
84    /// Memory usage of the backing storage in bytes.
85    pub fn memory_bytes(&self) -> usize {
86        self.data.len() * std::mem::size_of::<f32>()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn alloc_returns_zeroed_slice() {
96        let mut scratch = ScratchRegion::new(1024);
97        let s = scratch.alloc(10).unwrap();
98        assert_eq!(s.len(), 10);
99        assert!(s.iter().all(|&v| v == 0.0));
100    }
101
102    #[test]
103    fn sequential_allocs_dont_overlap() {
104        let mut scratch = ScratchRegion::new(1024);
105        let a = scratch.alloc(5).unwrap();
106        a[0] = 1.0;
107        a[4] = 5.0;
108        let a_ptr = a.as_ptr();
109
110        let b = scratch.alloc(3).unwrap();
111        b[0] = 10.0;
112        let b_ptr = b.as_ptr();
113
114        // Pointers should not overlap (b starts after a).
115        assert_ne!(a_ptr, b_ptr);
116        assert_eq!(scratch.used(), 8);
117    }
118
119    #[test]
120    fn reset_allows_reuse() {
121        let mut scratch = ScratchRegion::new(1024);
122        scratch.alloc(100).unwrap();
123        assert_eq!(scratch.used(), 100);
124
125        scratch.reset();
126        assert_eq!(scratch.used(), 0);
127
128        // Re-alloc after reset should return zeroed data.
129        let s = scratch.alloc(50).unwrap();
130        assert_eq!(s.len(), 50);
131        assert!(s.iter().all(|&v| v == 0.0));
132    }
133
134    #[test]
135    fn grows_beyond_initial_capacity() {
136        let mut scratch = ScratchRegion::new(10);
137        let s = scratch.alloc(100).unwrap();
138        assert_eq!(s.len(), 100);
139        assert!(scratch.capacity() >= 100);
140    }
141
142    #[test]
143    fn zero_alloc_is_valid() {
144        let mut scratch = ScratchRegion::new(1024);
145        let s = scratch.alloc(0).unwrap();
146        assert!(s.is_empty());
147        assert_eq!(scratch.used(), 0);
148    }
149
150    #[test]
151    fn memory_bytes_tracks_capacity() {
152        let scratch = ScratchRegion::new(1024);
153        assert_eq!(scratch.memory_bytes(), 1024 * 4);
154    }
155
156    #[test]
157    fn growth_overflow_falls_back_to_exact_fit() {
158        // When the doubling multiplication would overflow usize,
159        // the allocator should fall back to exact-fit (new_cursor).
160        // We can't actually allocate usize::MAX/2 f32s, but we can
161        // verify the capacity calculation doesn't panic.
162        let mut scratch = ScratchRegion::new(0);
163        // First alloc triggers growth from 0 → at least 1024 * 2.
164        let s = scratch.alloc(10).unwrap();
165        assert_eq!(s.len(), 10);
166        // The capacity should be at least 2048 (1024 min * 2).
167        assert!(scratch.capacity() >= 2048);
168    }
169}