Skip to main content

motion_core/
budget.rs

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