Skip to main content

laddu_memory/
decision.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{FootprintOverflow, MemoryError, MemoryResult};
4
5/// One memory-derived execution decision.
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub struct MemoryDecision {
8    /// Operation or dataset label.
9    pub label: String,
10    /// Fixed bytes required regardless of event count.
11    pub fixed_bytes: u64,
12    /// Estimated incremental bytes per event.
13    pub bytes_per_event: u64,
14    /// Chosen internal event count.
15    pub chunk_events: usize,
16    /// Estimated peak tracked bytes.
17    pub estimated_peak_bytes: u64,
18    /// Actual tracked high-water bytes when known.
19    pub actual_high_water_bytes: Option<u64>,
20    /// Selected storage/execution strategy.
21    pub strategy: String,
22}
23
24/// Workspace-internal memory footprint used to construct planning decisions.
25#[doc(hidden)]
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct MemoryFootprint {
28    /// Fixed bytes required regardless of event count.
29    pub fixed_bytes: u64,
30    /// Estimated incremental bytes per event.
31    pub bytes_per_event: u64,
32}
33
34impl MemoryFootprint {
35    /// Creates a footprint from byte counts.
36    pub const fn new(fixed_bytes: u64, bytes_per_event: u64) -> Self {
37        Self {
38            fixed_bytes,
39            bytes_per_event,
40        }
41    }
42    /// Creates a footprint containing only a fixed allocation.
43    pub const fn fixed(fixed_bytes: u64) -> Self {
44        Self::new(fixed_bytes, 0)
45    }
46    /// Creates a footprint containing only an event-dependent allocation.
47    pub const fn per_event(bytes_per_event: u64) -> Self {
48        Self::new(0, bytes_per_event)
49    }
50    /// Creates a footprint from platform-sized byte counts using saturation.
51    pub fn from_usize(fixed_bytes: usize, bytes_per_event: usize) -> Self {
52        Self::from_usize_checked(fixed_bytes, bytes_per_event)
53            .unwrap_or(Self::new(u64::MAX, u64::MAX))
54    }
55    /// Creates a footprint from platform-sized byte counts with overflow
56    /// detection.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`FootprintOverflow::Conversion`] when a platform-sized value
61    /// cannot be represented by `u64`.
62    pub fn from_usize_checked(
63        fixed_bytes: usize,
64        bytes_per_event: usize,
65    ) -> Result<Self, FootprintOverflow> {
66        Ok(Self::new(
67            checked_u64(fixed_bytes)?,
68            checked_u64(bytes_per_event)?,
69        ))
70    }
71    /// Adds two fixed/per-event footprint components with overflow detection.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`FootprintOverflow::Addition`] when either component exceeds
76    /// `u64`.
77    pub const fn checked_add(self, other: Self) -> Result<Self, FootprintOverflow> {
78        let fixed_bytes = match self.fixed_bytes.checked_add(other.fixed_bytes) {
79            Some(value) => value,
80            None => return Err(FootprintOverflow::Addition),
81        };
82        let bytes_per_event = match self.bytes_per_event.checked_add(other.bytes_per_event) {
83            Some(value) => value,
84            None => return Err(FootprintOverflow::Addition),
85        };
86        Ok(Self::new(fixed_bytes, bytes_per_event))
87    }
88    /// Scales fixed and per-event components by `factor` with overflow
89    /// detection.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`FootprintOverflow::Multiplication`] when scaling exceeds
94    /// `u64`.
95    pub const fn checked_scale(self, factor: u64) -> Result<Self, FootprintOverflow> {
96        let fixed_bytes = match self.fixed_bytes.checked_mul(factor) {
97            Some(value) => value,
98            None => return Err(FootprintOverflow::Multiplication),
99        };
100        let bytes_per_event = match self.bytes_per_event.checked_mul(factor) {
101            Some(value) => value,
102            None => return Err(FootprintOverflow::Multiplication),
103        };
104        Ok(Self::new(fixed_bytes, bytes_per_event))
105    }
106    /// Scales fixed and per-event components by a platform-sized factor with
107    /// overflow detection.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`FootprintOverflow`] when conversion or scaling overflows.
112    pub fn checked_scale_usize(self, factor: usize) -> Result<Self, FootprintOverflow> {
113        self.checked_scale(checked_u64(factor)?)
114    }
115    /// Calculates the peak bytes for `events` with overflow detection.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`FootprintOverflow`] when conversion, multiplication, or
120    /// addition overflows.
121    pub fn checked_peak_bytes(self, events: usize) -> Result<u64, FootprintOverflow> {
122        let events = checked_u64(events)?;
123        let event_bytes = self
124            .bytes_per_event
125            .checked_mul(events)
126            .ok_or(FootprintOverflow::Multiplication)?;
127        self.fixed_bytes
128            .checked_add(event_bytes)
129            .ok_or(FootprintOverflow::Addition)
130    }
131    /// Estimates peak bytes for `events` using the shared saturation policy.
132    pub fn peak_bytes(self, events: usize) -> u64 {
133        self.checked_peak_bytes(events).unwrap_or(u64::MAX)
134    }
135    fn normalized(mut self) -> Self {
136        self.bytes_per_event = self.bytes_per_event.max(1);
137        self
138    }
139}
140
141/// Workspace-internal named input for a memory-derived decision.
142#[doc(hidden)]
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct MemoryFitRequest {
145    /// Operation or dataset label.
146    pub label: String,
147    /// Fixed and per-event memory estimate.
148    pub footprint: MemoryFootprint,
149    /// Bytes available to this operation.
150    pub available_bytes: u64,
151    /// Maximum number of events the operation may process.
152    pub event_limit: usize,
153    /// Selected storage or execution strategy.
154    pub strategy: String,
155}
156
157impl MemoryDecision {
158    /// Derives the largest event chunk fitting within `available_bytes`.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`MemoryError::BudgetExceeded`] when fixed cost plus one event
163    /// cannot fit.
164    pub fn fit(
165        label: impl Into<String>,
166        fixed_bytes: u64,
167        bytes_per_event: u64,
168        available_bytes: u64,
169        event_limit: usize,
170        strategy: impl Into<String>,
171    ) -> MemoryResult<Self> {
172        MemoryFitRequest {
173            label: label.into(),
174            footprint: MemoryFootprint::new(fixed_bytes, bytes_per_event),
175            available_bytes,
176            event_limit,
177            strategy: strategy.into(),
178        }
179        .evaluate()
180    }
181}
182
183impl MemoryFitRequest {
184    /// Evaluates the largest event chunk fitting the request.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`MemoryError::BudgetExceeded`] when fixed cost plus one event
189    /// cannot fit.
190    pub fn evaluate(mut self) -> MemoryResult<MemoryDecision> {
191        self.footprint = self.footprint.normalized();
192        if self.event_limit == 0 {
193            return Ok(self.decision(0));
194        }
195        let event_capacity = self
196            .available_bytes
197            .saturating_sub(self.footprint.fixed_bytes);
198        let events =
199            saturating_usize(event_capacity / self.footprint.bytes_per_event).min(self.event_limit);
200        if events == 0 {
201            return Err(MemoryError::BudgetExceeded {
202                resource: self.label,
203                requested: self
204                    .footprint
205                    .fixed_bytes
206                    .saturating_add(self.footprint.bytes_per_event),
207                remaining: self.available_bytes,
208            });
209        }
210        Ok(self.decision(events))
211    }
212
213    /// Builds a resident decision covering the full event limit while reporting the supplied chunk.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`MemoryError::BudgetExceeded`] when the resident footprint
218    /// exceeds the available bytes.
219    pub fn evaluate_resident(self, chunk_events: usize) -> MemoryResult<MemoryDecision> {
220        let peak = self.footprint.peak_bytes(self.event_limit);
221        if peak > self.available_bytes {
222            return Err(MemoryError::BudgetExceeded {
223                resource: self.label,
224                requested: peak,
225                remaining: self.available_bytes,
226            });
227        }
228        Ok(self.decision_with_peak(chunk_events, peak))
229    }
230
231    fn decision(self, events: usize) -> MemoryDecision {
232        let peak = self.footprint.peak_bytes(events);
233        self.decision_with_peak(events, peak)
234    }
235
236    fn decision_with_peak(self, chunk_events: usize, peak: u64) -> MemoryDecision {
237        MemoryDecision {
238            label: self.label,
239            fixed_bytes: self.footprint.fixed_bytes,
240            bytes_per_event: self.footprint.bytes_per_event,
241            chunk_events,
242            estimated_peak_bytes: peak,
243            actual_high_water_bytes: None,
244            strategy: self.strategy,
245        }
246    }
247}
248
249fn saturating_usize(value: u64) -> usize {
250    usize::try_from(value).unwrap_or(usize::MAX)
251}
252fn checked_u64(value: usize) -> Result<u64, FootprintOverflow> {
253    u64::try_from(value).map_err(|_| FootprintOverflow::Conversion)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn fits_the_largest_safe_chunk() {
262        let decision = MemoryDecision::fit("test", 100, 8, 1_000, 1_000, "streaming").unwrap();
263        assert_eq!(decision.chunk_events, 112);
264        assert_eq!(decision.estimated_peak_bytes, 996);
265    }
266
267    #[test]
268    fn defines_zero_and_capacity_boundary_policy() {
269        let zero = MemoryDecision::fit("zero", 101, 0, 100, 0, "empty").unwrap();
270        assert_eq!(
271            (
272                zero.bytes_per_event,
273                zero.chunk_events,
274                zero.estimated_peak_bytes
275            ),
276            (1, 0, 101)
277        );
278        let normalized = MemoryDecision::fit("normalized", 0, 0, 100, 200, "streaming").unwrap();
279        assert_eq!(
280            (
281                normalized.bytes_per_event,
282                normalized.chunk_events,
283                normalized.estimated_peak_bytes
284            ),
285            (1, 100, 100)
286        );
287        assert!(MemoryDecision::fit("full", 100, 1, 100, 1, "streaming").is_err());
288        assert!(MemoryDecision::fit("full", 101, 1, 100, 1, "streaming").is_err());
289    }
290
291    #[test]
292    fn saturates_at_integer_boundaries() {
293        let decision =
294            MemoryDecision::fit("maximum", 0, 1, u64::MAX, usize::MAX, "resident").unwrap();
295        assert_eq!(
296            decision.chunk_events,
297            usize::try_from(u64::MAX).unwrap_or(usize::MAX)
298        );
299        assert_eq!(decision.estimated_peak_bytes, u64::MAX);
300        assert_eq!(
301            MemoryFootprint::new(u64::MAX, u64::MAX).peak_bytes(usize::MAX),
302            u64::MAX
303        );
304        assert_eq!(
305            MemoryDecision::fit("overflow", u64::MAX, 1, u64::MAX, 1, "streaming"),
306            Err(MemoryError::BudgetExceeded {
307                resource: "overflow".into(),
308                requested: u64::MAX,
309                remaining: u64::MAX,
310            })
311        );
312    }
313
314    #[test]
315    fn named_and_resident_requests_share_policy() {
316        let request = MemoryFitRequest {
317            label: "named".into(),
318            footprint: MemoryFootprint::from_usize(100, 8),
319            available_bytes: 1_000,
320            event_limit: 100,
321            strategy: "resident".into(),
322        };
323        assert_eq!(request.clone().evaluate().unwrap().chunk_events, 100);
324        let decision = request.evaluate_resident(25).unwrap();
325        assert_eq!(
326            (decision.chunk_events, decision.estimated_peak_bytes),
327            (25, 900)
328        );
329    }
330
331    #[test]
332    fn resident_requests_check_the_full_footprint() {
333        let request = MemoryFitRequest {
334            label: "resident".into(),
335            footprint: MemoryFootprint::new(100, 8),
336            available_bytes: 899,
337            event_limit: 100,
338            strategy: "resident".into(),
339        };
340        assert_eq!(
341            request.evaluate_resident(25),
342            Err(MemoryError::BudgetExceeded {
343                resource: "resident".into(),
344                requested: 900,
345                remaining: 899,
346            })
347        );
348
349        let zero_per_event = MemoryFitRequest {
350            label: "resident".into(),
351            footprint: MemoryFootprint::new(100, 0),
352            available_bytes: 100,
353            event_limit: 100,
354            strategy: "resident".into(),
355        }
356        .evaluate_resident(25)
357        .unwrap();
358        assert_eq!(
359            (
360                zero_per_event.bytes_per_event,
361                zero_per_event.estimated_peak_bytes
362            ),
363            (0, 100)
364        );
365    }
366}