Skip to main content

motion_core/
budget.rs

1//! Shared logical resource admission for bounded geometry and rendering.
2use crate::{PixelSize, ValidationError};
3use std::mem::size_of;
4
5/// Logical preparation/composition budgets, not a process memory cap.
6#[derive(Clone, Copy, Debug)]
7pub struct RasterLimits {
8    /// Maximum raster pixels, including filter guard bands and empty frames.
9    pub max_pixels: u64,
10    /// Maximum live logical bytes, including scratch; capacity/overhead is excluded.
11    pub max_bytes: u64,
12    /// Maximum admitted work units; `u64::MAX` disables the authoring work cap.
13    pub max_work: u64,
14}
15
16impl Default for RasterLimits {
17    fn default() -> Self {
18        Self::authoring()
19    }
20}
21
22impl RasterLimits {
23    /// Default trusted-authoring admission, bounded by pixels and logical bytes.
24    pub const fn authoring() -> Self {
25        Self {
26            max_pixels: 16_777_216,
27            max_bytes: 256 * 1024 * 1024,
28            max_work: u64::MAX,
29        }
30    }
31
32    /// Opt-in bounded raster work; arbitrary scene callbacks are still trusted Rust.
33    pub const fn sandbox() -> Self {
34        Self {
35            max_work: 200_000_000,
36            ..Self::authoring()
37        }
38    }
39
40    /// Checks positive allowances and whether the raster fits the pixel limit.
41    /// Does not allocate pixels or reserve the eventual frame's byte/work cost.
42    pub fn validate_size(self, size: PixelSize) -> Result<(), ValidationError> {
43        Budget::new(size, self).map(|_| ())
44    }
45}
46
47/// Mutable admission counters for one bounded geometry or rendering operation.
48/// Callers release transient payloads from `live_bytes`; work is never refunded.
49pub struct Budget {
50    /// Immutable allowances used by subsequent admission checks.
51    pub limits: RasterLimits,
52    live_bytes: u64,
53    peak_bytes: u64,
54    work: u64,
55}
56
57impl Budget {
58    /// Creates empty counters after checking positive allowances and pixel count.
59    pub fn new(size: PixelSize, limits: RasterLimits) -> Result<Self, ValidationError> {
60        for (field, value) in [
61            ("max_pixels", limits.max_pixels),
62            ("max_bytes", limits.max_bytes),
63            ("max_work", limits.max_work),
64        ] {
65            if value == 0 {
66                return Err(ValidationError::invalid(
67                    "prepared",
68                    field,
69                    "Raster limits must be positive",
70                ));
71            }
72        }
73        if u64::from(size.width) * u64::from(size.height) > limits.max_pixels {
74            return Err(ValidationError::limit(
75                "prepared",
76                "max_pixels",
77                "Output dimensions exceed the pixel budget",
78            ));
79        }
80        Ok(Self {
81            limits,
82            live_bytes: 0,
83            peak_bytes: 0,
84            work: 0,
85        })
86    }
87
88    /// Resumes counters recorded by an earlier planning pass over the same limits.
89    ///
90    /// The caller vouches that `live_bytes`, `peak_bytes` and `work` already passed
91    /// admission against `limits`; nothing is re-checked.
92    pub fn resume(limits: RasterLimits, live_bytes: u64, peak_bytes: u64, work: u64) -> Self {
93        Self {
94            limits,
95            live_bytes,
96            peak_bytes: peak_bytes.max(live_bytes),
97            work,
98        }
99    }
100
101    /// Current logical payload bytes, excluding allocation capacity and overhead.
102    pub fn live_bytes(&self) -> u64 {
103        self.live_bytes
104    }
105
106    /// Highest admitted live-byte total observed by this operation.
107    pub fn peak_bytes(&self) -> u64 {
108        self.peak_bytes
109    }
110
111    /// Cumulative admitted algorithm work units, not elapsed time.
112    pub fn work_done(&self) -> u64 {
113        self.work
114    }
115
116    /// Returns transient payload previously admitted with [`Self::bytes`]; the peak is kept.
117    ///
118    /// Releasing more than is live is a caller bug and saturates at zero rather than wrapping.
119    pub fn release(&mut self, amount: u64) {
120        self.live_bytes = self.live_bytes.saturating_sub(amount);
121    }
122
123    /// Restarts peak tracking at the current live bytes and returns the previous peak.
124    ///
125    /// Use it to measure one nested operation, then call [`Self::merge_peak`] with the
126    /// returned value so the enclosing operation's peak is not lost.
127    pub fn reset_peak(&mut self) -> u64 {
128        std::mem::replace(&mut self.peak_bytes, self.live_bytes)
129    }
130
131    /// Raises the peak to at least `peak`, for example after [`Self::reset_peak`].
132    pub fn merge_peak(&mut self, peak: u64) {
133        self.peak_bytes = self.peak_bytes.max(peak);
134    }
135
136    /// Replaces the live-byte count with an externally measured total, raising the peak if needed.
137    pub fn set_live_bytes(&mut self, live_bytes: u64) {
138        self.live_bytes = live_bytes;
139        self.peak_bytes = self.peak_bytes.max(live_bytes);
140    }
141
142    /// Admits additional live payload and updates the peak; failure leaves counters unchanged.
143    pub fn bytes(&mut self, amount: u64, path: &str) -> Result<(), ValidationError> {
144        let next = self
145            .live_bytes
146            .checked_add(amount)
147            .filter(|value| *value <= self.limits.max_bytes)
148            .ok_or_else(|| {
149                ValidationError::limit(path, "max_bytes", "storage exceeds the logical byte budget")
150            })?;
151        self.live_bytes = next;
152        self.peak_bytes = self.peak_bytes.max(next);
153        Ok(())
154    }
155
156    /// Admits cumulative work before execution; failure leaves the counter unchanged.
157    pub fn work(&mut self, amount: u64, path: &str) -> Result<(), ValidationError> {
158        let next = self
159            .work
160            .checked_add(amount)
161            .filter(|value| *value <= self.limits.max_work)
162            .ok_or_else(|| ValidationError::limit(path, "max_work", "work exceeds the budget"))?;
163        self.work = next;
164        Ok(())
165    }
166
167    /// Charges one element's logical bytes before fallibly reserving and appending.
168    /// An allocation failure retains the admission charge; discard the partial operation.
169    pub fn push<Value>(
170        &mut self,
171        values: &mut Vec<Value>,
172        value: Value,
173        path: &str,
174    ) -> Result<(), ValidationError> {
175        self.bytes(size_of::<Value>() as u64, path)?;
176        values.try_reserve(1).map_err(|_| {
177            ValidationError::limit(path, "max_bytes", "Could not reserve metadata storage")
178        })?;
179        values.push(value);
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn authoring_admits_large_work_but_retains_storage_and_pixel_limits() {
190        let limits = RasterLimits::default();
191        let mut budget = Budget::new(PixelSize::new(1920, 1080), limits).unwrap();
192        budget.work(4_000_000_000, "particles").unwrap();
193        assert_eq!(budget.work, 4_000_000_000);
194        budget.bytes(limits.max_bytes, "pixels").unwrap();
195        assert!(budget.bytes(1, "pixels").is_err());
196        assert_eq!(budget.live_bytes, limits.max_bytes);
197        assert!(limits.validate_size(PixelSize::new(8192, 8192)).is_err());
198    }
199
200    #[test]
201    fn sandbox_and_explicit_work_caps_reject_without_mutating_counters() {
202        for limits in [
203            RasterLimits::sandbox(),
204            RasterLimits {
205                max_work: 17,
206                ..RasterLimits::default()
207            },
208        ] {
209            let mut budget = Budget::new(PixelSize::new(1, 1), limits).unwrap();
210            budget.work(limits.max_work, "particles").unwrap();
211            assert!(budget.work(1, "particles").is_err());
212            assert_eq!(budget.work, limits.max_work);
213        }
214    }
215}