Skip to main content

formualizer_eval/engine/
eval_delta.rs

1use formualizer_common::{
2    ExcelError, ExcelErrorExtra, ExcelErrorKind, PackedSheetCell, ResourceExhaustionDetail,
3    ResourceExhaustionReason, SheetId,
4};
5
6pub const TARGET_EVAL_DELTA_VERSION: u16 = 1;
7
8/// Controls expansion of run-aware target deltas into the legacy per-cell shape.
9///
10/// The compatibility default is intentionally unlimited. Callers that need a hard
11/// allocation boundary must opt into `CellLimit`.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum EvalDeltaCompatibilityPolicy {
14    #[default]
15    Unlimited,
16    CellLimit(usize),
17}
18
19/// Opt-in control for evaluation delta collection.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum DeltaMode {
22    /// Do not collect deltas (default).
23    #[default]
24    Off,
25    /// Collect changed grid cell addresses (no values).
26    Cells,
27}
28
29/// Engine-level evaluation deltas for a single evaluation pass.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct EvalDelta {
33    pub changed_cells: Vec<PackedSheetCell>,
34}
35
36impl EvalDelta {
37    pub fn is_empty(&self) -> bool {
38        self.changed_cells.is_empty()
39    }
40}
41
42/// A changed rectangular region identified within one workbook evaluation session.
43///
44/// `SheetId` values are workbook-session-local identities; do not persist these records or
45/// replay them into another workbook or session.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum EvalDeltaRecord {
49    Run {
50        sheet_id: SheetId,
51        start_row: u32,
52        start_col: u32,
53        end_row: u32,
54        end_col: u32,
55    },
56    Region {
57        sheet_id: SheetId,
58        start_row: u32,
59        start_col: u32,
60        end_row: u32,
61        end_col: u32,
62    },
63}
64
65impl EvalDeltaRecord {
66    pub fn sheet_id(&self) -> SheetId {
67        match self {
68            Self::Run { sheet_id, .. } | Self::Region { sheet_id, .. } => *sheet_id,
69        }
70    }
71
72    pub fn bounds(&self) -> (u32, u32, u32, u32) {
73        match self {
74            Self::Run {
75                start_row,
76                start_col,
77                end_row,
78                end_col,
79                ..
80            }
81            | Self::Region {
82                start_row,
83                start_col,
84                end_row,
85                end_col,
86                ..
87            } => (*start_row, *start_col, *end_row, *end_col),
88        }
89    }
90}
91
92/// Versioned target-evaluation changes for one workbook evaluation session.
93///
94/// The `SheetId` values carried by records are workbook-session-local identities and are not
95/// persistable or replayable across workbooks or sessions.
96#[derive(Debug, Clone, PartialEq, Eq)]
97#[non_exhaustive]
98pub struct TargetEvalDelta {
99    pub version: u16,
100    pub records: Vec<EvalDeltaRecord>,
101}
102
103impl Default for TargetEvalDelta {
104    fn default() -> Self {
105        Self {
106            version: TARGET_EVAL_DELTA_VERSION,
107            records: Vec::new(),
108        }
109    }
110}
111
112impl TargetEvalDelta {
113    pub fn is_empty(&self) -> bool {
114        self.records.is_empty()
115    }
116
117    pub fn compatibility_cells(&self, limit: usize) -> Result<EvalDelta, ExcelError> {
118        self.compatibility_cells_with_policy(EvalDeltaCompatibilityPolicy::CellLimit(limit))
119    }
120
121    pub fn compatibility_cells_with_policy(
122        &self,
123        policy: EvalDeltaCompatibilityPolicy,
124    ) -> Result<EvalDelta, ExcelError> {
125        let mut observed = 0usize;
126        for record in &self.records {
127            let (start_row, start_col, end_row, end_col) = record.bounds();
128            let cells = (end_row.saturating_sub(start_row) as usize + 1)
129                .saturating_mul(end_col.saturating_sub(start_col) as usize + 1);
130            observed = observed.saturating_add(cells);
131            if let EvalDeltaCompatibilityPolicy::CellLimit(limit) = policy
132                && observed > limit
133            {
134                return Err(ExcelError::new(ExcelErrorKind::NImpl)
135                    .with_message(format!(
136                        "target delta compatibility expansion exceeded {limit} cells"
137                    ))
138                    .with_extra(ExcelErrorExtra::Resource {
139                        detail: Box::new(ResourceExhaustionDetail {
140                            reason: ResourceExhaustionReason::WorkUnits,
141                            limit: limit as u64,
142                            observed: observed as u64,
143                            request_id: None,
144                        }),
145                    }));
146            }
147        }
148
149        let mut changed_cells = Vec::new();
150        changed_cells.try_reserve(observed).map_err(|_| {
151            ExcelError::new(ExcelErrorKind::NImpl)
152                .with_message("target delta compatibility expansion allocation failed")
153                .with_extra(ExcelErrorExtra::Resource {
154                    detail: Box::new(ResourceExhaustionDetail {
155                        reason: ResourceExhaustionReason::ScratchMemory,
156                        limit: observed as u64,
157                        observed: observed as u64,
158                        request_id: None,
159                    }),
160                })
161        })?;
162        for record in &self.records {
163            let sheet_id = record.sheet_id();
164            let (start_row, start_col, end_row, end_col) = record.bounds();
165            for row in start_row..=end_row {
166                for col in start_col..=end_col {
167                    let packed = PackedSheetCell::try_new(sheet_id, row, col).ok_or_else(|| {
168                        ExcelError::new(ExcelErrorKind::NImpl)
169                            .with_message("target delta cell exceeds packed compatibility bounds")
170                            .with_extra(ExcelErrorExtra::Resource {
171                                detail: Box::new(ResourceExhaustionDetail {
172                                    reason: ResourceExhaustionReason::Admission,
173                                    limit: u64::from(PackedSheetCell::MAX_ROW0)
174                                        .saturating_mul(u64::from(PackedSheetCell::MAX_COL0)),
175                                    observed: u64::from(row).saturating_mul(u64::from(col)),
176                                    request_id: None,
177                                }),
178                            })
179                    })?;
180                    changed_cells.push(packed);
181                }
182            }
183        }
184        changed_cells.sort_unstable();
185        changed_cells.dedup();
186        Ok(EvalDelta { changed_cells })
187    }
188}
189
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191struct DeltaRect {
192    start_row: u32,
193    start_col: u32,
194    end_row: u32,
195    end_col: u32,
196}
197
198pub(crate) struct DeltaCollector {
199    pub(crate) mode: DeltaMode,
200    changed: Vec<(SheetId, DeltaRect)>,
201}
202
203impl DeltaCollector {
204    pub(crate) fn new(mode: DeltaMode) -> Self {
205        Self {
206            mode,
207            changed: Vec::new(),
208        }
209    }
210
211    #[inline]
212    pub(crate) fn record_cell(&mut self, sheet_id: SheetId, row0: u32, col0: u32) {
213        self.record_region(sheet_id, row0, col0, row0, col0);
214    }
215
216    #[inline]
217    pub(crate) fn record_packed(&mut self, packed: PackedSheetCell) {
218        if self.mode == DeltaMode::Off {
219            return;
220        }
221        self.record_cell(packed.sheet_id(), packed.row0(), packed.col0());
222    }
223
224    pub(crate) fn record_region(
225        &mut self,
226        sheet_id: SheetId,
227        start_row: u32,
228        start_col: u32,
229        end_row: u32,
230        end_col: u32,
231    ) {
232        if self.mode == DeltaMode::Off || start_row > end_row || start_col > end_col {
233            return;
234        }
235        self.changed.push((
236            sheet_id,
237            DeltaRect {
238                start_row,
239                start_col,
240                end_row,
241                end_col,
242            },
243        ));
244    }
245
246    pub(crate) fn finish_target(mut self) -> TargetEvalDelta {
247        if self.mode == DeltaMode::Off {
248            return TargetEvalDelta::default();
249        }
250        // Keep recording append-only. Two sort/sweep passes normalize horizontal
251        // and vertical runs at finish, avoiding an all-prior-record scan per write.
252        self.changed.sort_unstable_by_key(|(sheet_id, record)| {
253            (
254                *sheet_id,
255                record.start_row,
256                record.end_row,
257                record.start_col,
258                record.end_col,
259            )
260        });
261        let mut horizontal: Vec<(SheetId, DeltaRect)> = Vec::with_capacity(self.changed.len());
262        for (sheet_id, record) in self.changed {
263            if let Some((previous_sheet, previous)) = horizontal.last_mut()
264                && *previous_sheet == sheet_id
265                && previous.start_row == record.start_row
266                && previous.end_row == record.end_row
267                && record.start_col <= previous.end_col.saturating_add(1)
268            {
269                previous.end_col = previous.end_col.max(record.end_col);
270            } else {
271                horizontal.push((sheet_id, record));
272            }
273        }
274        horizontal.sort_unstable_by_key(|(sheet_id, record)| {
275            (
276                *sheet_id,
277                record.start_col,
278                record.end_col,
279                record.start_row,
280                record.end_row,
281            )
282        });
283        let mut normalized: Vec<(SheetId, DeltaRect)> = Vec::with_capacity(horizontal.len());
284        for (sheet_id, record) in horizontal {
285            if let Some((previous_sheet, previous)) = normalized.last_mut()
286                && *previous_sheet == sheet_id
287                && previous.start_col == record.start_col
288                && previous.end_col == record.end_col
289                && record.start_row <= previous.end_row.saturating_add(1)
290            {
291                previous.end_row = previous.end_row.max(record.end_row);
292            } else {
293                normalized.push((sheet_id, record));
294            }
295        }
296        normalized.sort_unstable_by_key(|(sheet_id, record)| {
297            (
298                *sheet_id,
299                record.start_row,
300                record.start_col,
301                record.end_row,
302                record.end_col,
303            )
304        });
305        let records = normalized
306            .into_iter()
307            .map(|(sheet_id, record)| {
308                if record.start_row == record.end_row || record.start_col == record.end_col {
309                    EvalDeltaRecord::Run {
310                        sheet_id,
311                        start_row: record.start_row,
312                        start_col: record.start_col,
313                        end_row: record.end_row,
314                        end_col: record.end_col,
315                    }
316                } else {
317                    EvalDeltaRecord::Region {
318                        sheet_id,
319                        start_row: record.start_row,
320                        start_col: record.start_col,
321                        end_row: record.end_row,
322                        end_col: record.end_col,
323                    }
324                }
325            })
326            .collect();
327        TargetEvalDelta {
328            version: TARGET_EVAL_DELTA_VERSION,
329            records,
330        }
331    }
332
333    pub(crate) fn finish(self) -> Result<EvalDelta, ExcelError> {
334        self.finish_with_policy(EvalDeltaCompatibilityPolicy::Unlimited)
335    }
336
337    pub(crate) fn finish_with_policy(
338        self,
339        policy: EvalDeltaCompatibilityPolicy,
340    ) -> Result<EvalDelta, ExcelError> {
341        self.finish_target().compatibility_cells_with_policy(policy)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn collector_coalesces_horizontal_and_vertical_runs() {
351        let mut collector = DeltaCollector::new(DeltaMode::Cells);
352        for col in 1..=1000 {
353            collector.record_cell(0, 5, col);
354        }
355        for row in 10..=20 {
356            collector.record_cell(0, row, 8);
357        }
358        let delta = collector.finish_target();
359        assert_eq!(delta.version, TARGET_EVAL_DELTA_VERSION);
360        assert_eq!(delta.records.len(), 2);
361        assert!(
362            delta
363                .records
364                .iter()
365                .all(|record| matches!(record, EvalDeltaRecord::Run { .. }))
366        );
367    }
368
369    #[test]
370    fn legacy_default_expands_more_than_one_hundred_thousand_cells() {
371        let mut collector = DeltaCollector::new(DeltaMode::Cells);
372        collector.record_region(0, 0, 0, 6, 16_383);
373        let delta = collector.finish().unwrap();
374        assert_eq!(delta.changed_cells.len(), 114_688);
375    }
376
377    #[test]
378    fn explicit_compatibility_cap_accepts_cap_and_rejects_cap_plus_one() {
379        let mut at_cap = DeltaCollector::new(DeltaMode::Cells);
380        at_cap.record_region(0, 0, 0, 0, 99);
381        assert_eq!(
382            at_cap
383                .finish_with_policy(EvalDeltaCompatibilityPolicy::CellLimit(100))
384                .unwrap()
385                .changed_cells
386                .len(),
387            100
388        );
389
390        let mut over_cap = DeltaCollector::new(DeltaMode::Cells);
391        over_cap.record_region(0, 0, 0, 0, 100);
392        let error = over_cap
393            .finish_with_policy(EvalDeltaCompatibilityPolicy::CellLimit(100))
394            .unwrap_err();
395        assert!(matches!(error.extra, ExcelErrorExtra::Resource { .. }));
396    }
397
398    #[test]
399    fn compatibility_expansion_returns_typed_overflow_without_truncation() {
400        let delta = TargetEvalDelta {
401            version: TARGET_EVAL_DELTA_VERSION,
402            records: vec![EvalDeltaRecord::Region {
403                sheet_id: 0,
404                start_row: 0,
405                start_col: 0,
406                end_row: 100,
407                end_col: 100,
408            }],
409        };
410        let error = delta.compatibility_cells(100).unwrap_err();
411        assert!(matches!(error.extra, ExcelErrorExtra::Resource { .. }));
412    }
413}