Skip to main content

formualizer_eval/engine/
resource_ledger.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use formualizer_common::{
6    ExcelError, ExcelErrorExtra, ExcelErrorKind, ResourceExhaustionDetail, ResourceExhaustionReason,
7};
8
9use crate::instant::FzInstant;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum EvaluationIncompleteReason {
14    FormulaPlaneTopologyCandidates,
15    FormulaPlaneTopologyEdges,
16    FormulaPlaneTopologyRetainedBytes,
17    FormulaPlaneTopologyScratchBytes,
18    FormulaPlaneTopologyAllocation,
19    FormulaPlaneTopologySemanticStructural,
20    DirtyClosureWork,
21}
22
23impl EvaluationIncompleteReason {
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::FormulaPlaneTopologyCandidates => "formula_plane_topology_candidates",
27            Self::FormulaPlaneTopologyEdges => "formula_plane_topology_edges",
28            Self::FormulaPlaneTopologyRetainedBytes => "formula_plane_topology_retained_bytes",
29            Self::FormulaPlaneTopologyScratchBytes => "formula_plane_topology_scratch_bytes",
30            Self::FormulaPlaneTopologyAllocation => "formula_plane_topology_allocation",
31            Self::FormulaPlaneTopologySemanticStructural => {
32                "formula_plane_topology_semantic_structural"
33            }
34            Self::DirtyClosureWork => "dirty_closure_work",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct SemanticResourceBudget {
41    pub max_rows: Option<u32>,
42    pub max_columns: Option<u32>,
43}
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct AdmissionResourceBudget {
47    /// Hard cap on the exact final graph vertex count at common mutation preflight.
48    pub graph_vertex_hard_limit: Option<usize>,
49    /// Hard cap on the exact final graph edge count at common mutation preflight.
50    pub graph_edge_hard_limit: Option<usize>,
51    /// Hard cap on formula cells actually materialized into the legacy graph.
52    pub materialization_cells: Option<u64>,
53    /// Hard cap on bytes attributed to newly materialized graph vertices and edges.
54    pub materialized_graph_bytes: Option<u64>,
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub(crate) struct GraphAdmission {
59    pub final_vertices: usize,
60    pub final_edges: usize,
61    pub materialization_cells: u64,
62    pub added_vertices: usize,
63    pub added_edges: usize,
64}
65
66impl GraphAdmission {
67    pub(crate) fn materialized_graph_bytes(self) -> Result<u64, ResourceLedgerError> {
68        let vertices = u64::try_from(self.added_vertices).map_err(|_| {
69            ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
70                reason: ResourceExhaustionReason::ArithmeticOverflow,
71                limit: u64::MAX,
72                observed: u64::MAX,
73                request_id: None,
74            })
75        })?;
76        let edges = u64::try_from(self.added_edges).map_err(|_| {
77            ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
78                reason: ResourceExhaustionReason::ArithmeticOverflow,
79                limit: u64::MAX,
80                observed: u64::MAX,
81                request_id: None,
82            })
83        })?;
84        vertices
85            .checked_mul(64)
86            .and_then(|bytes| {
87                edges
88                    .checked_mul(16)
89                    .and_then(|edge_bytes| bytes.checked_add(edge_bytes))
90            })
91            .ok_or(ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
92                reason: ResourceExhaustionReason::ArithmeticOverflow,
93                limit: u64::MAX,
94                observed: u64::MAX,
95                request_id: None,
96            }))
97    }
98}
99
100pub(crate) fn graph_admission_enabled(budgets: &EvaluationBudgets) -> bool {
101    let admission = &budgets.admission;
102    admission.graph_vertex_hard_limit.is_some()
103        || admission.graph_edge_hard_limit.is_some()
104        || admission.materialization_cells.is_some()
105        || admission.materialized_graph_bytes.is_some()
106}
107
108pub(crate) fn preflight_graph_admission(
109    budgets: &EvaluationBudgets,
110    usage: GraphAdmission,
111    request_id: Option<u64>,
112) -> Result<(), ResourceLedgerError> {
113    let exhausted = |reason, limit, observed| {
114        ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
115            reason,
116            limit,
117            observed,
118            request_id,
119        })
120    };
121    if let Some(limit) = budgets.admission.graph_vertex_hard_limit
122        && usage.final_vertices > limit
123    {
124        return Err(exhausted(
125            ResourceExhaustionReason::GraphVertices,
126            limit as u64,
127            usage.final_vertices as u64,
128        ));
129    }
130    if let Some(limit) = budgets.admission.graph_edge_hard_limit
131        && usage.final_edges > limit
132    {
133        return Err(exhausted(
134            ResourceExhaustionReason::GraphEdges,
135            limit as u64,
136            usage.final_edges as u64,
137        ));
138    }
139    if let Some(limit) = budgets.admission.materialization_cells
140        && usage.materialization_cells > limit
141    {
142        return Err(exhausted(
143            ResourceExhaustionReason::MaterializationCells,
144            limit,
145            usage.materialization_cells,
146        ));
147    }
148    let bytes = usage.materialized_graph_bytes()?;
149    if let Some(limit) = budgets.admission.materialized_graph_bytes
150        && bytes > limit
151    {
152        return Err(exhausted(ResourceExhaustionReason::Admission, limit, bytes));
153    }
154    Ok(())
155}
156
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158pub struct RetainedResourceBudget {
159    pub total_bytes: Option<u64>,
160    pub mixed_cache_bytes: Option<u64>,
161    pub lookup_cache_bytes: Option<u64>,
162}
163
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
165pub struct ScratchResourceBudget {
166    pub total_bytes: Option<u64>,
167    pub schedule_discovery_bytes: Option<u64>,
168    pub graph_source_bytes: Option<u64>,
169    pub spill_overlay_bytes: Option<u64>,
170    /// Request scratch backing policy. Declarative until disk-backed scratch is activated.
171    pub disk_scratch_policy: Option<DiskScratchPolicy>,
172}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
175pub struct WorkResourceBudget {
176    pub max_work_units: Option<u64>,
177}
178
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct DeadlineResourceBudget {
181    pub max_elapsed: Option<Duration>,
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
185pub struct OptimizationResourceBudget {
186    pub mixed_cache_candidates: Option<usize>,
187    pub mixed_cache_edges: Option<usize>,
188    pub max_threads: Option<usize>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Default)]
192pub struct EvaluationBudgets {
193    pub semantic: SemanticResourceBudget,
194    pub admission: AdmissionResourceBudget,
195    pub retained: RetainedResourceBudget,
196    pub scratch: ScratchResourceBudget,
197    pub work: WorkResourceBudget,
198    pub deadline: DeadlineResourceBudget,
199    pub optimization: OptimizationResourceBudget,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum DiskScratchPolicy {
204    NativeTemporary,
205    MemoryOnly,
206}
207
208impl DiskScratchPolicy {
209    pub const fn as_str(self) -> &'static str {
210        match self {
211            Self::NativeTemporary => "native_temporary",
212            Self::MemoryOnly => "memory_only",
213        }
214    }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ResourceEnvelope {
219    pub retained_bytes: u64,
220    pub request_scratch_bytes: u64,
221    pub materialized_graph_bytes: u64,
222    pub max_work_units: u64,
223    pub deadline: Option<Duration>,
224    pub max_threads: usize,
225    pub disk_scratch: DiskScratchPolicy,
226}
227
228impl ResourceEnvelope {
229    /// Deterministically derive detailed budgets from this aggregate envelope.
230    pub fn to_budgets(&self) -> EvaluationBudgets {
231        let cache_pool = self.retained_bytes / 8;
232        let mixed_cache = cache_pool.saturating_mul(60) / 100;
233        let lookup_cache = cache_pool.saturating_sub(mixed_cache);
234        let schedule = self.request_scratch_bytes / 2;
235        let graph_source = self.request_scratch_bytes.saturating_mul(35) / 100;
236        let spill_overlay = self
237            .request_scratch_bytes
238            .saturating_sub(schedule)
239            .saturating_sub(graph_source);
240        EvaluationBudgets {
241            admission: AdmissionResourceBudget {
242                materialized_graph_bytes: Some(self.materialized_graph_bytes),
243                ..AdmissionResourceBudget::default()
244            },
245            retained: RetainedResourceBudget {
246                total_bytes: Some(self.retained_bytes),
247                mixed_cache_bytes: Some(mixed_cache),
248                lookup_cache_bytes: Some(lookup_cache),
249            },
250            scratch: ScratchResourceBudget {
251                total_bytes: Some(self.request_scratch_bytes),
252                schedule_discovery_bytes: Some(schedule),
253                graph_source_bytes: Some(graph_source),
254                spill_overlay_bytes: Some(spill_overlay),
255                disk_scratch_policy: Some(self.disk_scratch),
256            },
257            work: WorkResourceBudget {
258                max_work_units: Some(self.max_work_units),
259            },
260            deadline: DeadlineResourceBudget {
261                max_elapsed: self.deadline,
262            },
263            optimization: OptimizationResourceBudget {
264                mixed_cache_candidates: Some(
265                    usize::try_from(mixed_cache / 64).unwrap_or(usize::MAX),
266                ),
267                mixed_cache_edges: Some(usize::try_from(mixed_cache / 64).unwrap_or(usize::MAX)),
268                max_threads: Some(self.max_threads),
269            },
270            ..EvaluationBudgets::default()
271        }
272    }
273}
274
275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
276#[non_exhaustive]
277pub enum LegacyResourceConfigDisposition {
278    #[default]
279    NotPresent,
280    Mapped,
281    IgnoredByExplicitBudget,
282}
283
284impl LegacyResourceConfigDisposition {
285    pub const fn as_str(self) -> &'static str {
286        match self {
287            Self::NotPresent => "not_present",
288            Self::Mapped => "mapped",
289            Self::IgnoredByExplicitBudget => "ignored_by_explicit_budget",
290        }
291    }
292}
293
294/// Field-level disposition of deprecated resource configuration.
295///
296/// Every legacy value fills only its otherwise-unset destination budget field. An explicit
297/// destination wins independently, so one `max_memory_mb` value can map to retained memory while
298/// being ignored for scratch memory. Engines expose at most one diagnostic containing all fields.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct EvaluationResourceConfigDiagnostic {
301    pub max_vertices: LegacyResourceConfigDisposition,
302    pub max_memory_mb_retained: LegacyResourceConfigDisposition,
303    pub max_memory_mb_scratch: LegacyResourceConfigDisposition,
304    pub max_eval_time: LegacyResourceConfigDisposition,
305    /// Compatibility diagnostic field. Always false now that common admission is active.
306    pub graph_admission_activation_deferred_to_c2: bool,
307}
308
309#[derive(Debug, Clone)]
310pub(crate) struct ResolvedEvaluationBudgets {
311    pub budgets: EvaluationBudgets,
312    pub diagnostic: Option<EvaluationResourceConfigDiagnostic>,
313}
314
315pub(crate) fn split_legacy_memory_bytes(bytes: u64) -> (u64, u64) {
316    let scratch = bytes / 2;
317    (bytes.saturating_sub(scratch), scratch)
318}
319
320pub(crate) fn resolve_evaluation_budgets(
321    explicit: &EvaluationBudgets,
322    max_vertices: Option<usize>,
323    max_memory_mb: Option<usize>,
324    max_eval_time: Option<Duration>,
325) -> ResolvedEvaluationBudgets {
326    let legacy_present =
327        max_vertices.is_some() || max_memory_mb.is_some() || max_eval_time.is_some();
328    if !legacy_present {
329        return ResolvedEvaluationBudgets {
330            budgets: explicit.clone(),
331            diagnostic: None,
332        };
333    }
334
335    let mut budgets = explicit.clone();
336    let mut diagnostic = EvaluationResourceConfigDiagnostic {
337        max_vertices: LegacyResourceConfigDisposition::NotPresent,
338        max_memory_mb_retained: LegacyResourceConfigDisposition::NotPresent,
339        max_memory_mb_scratch: LegacyResourceConfigDisposition::NotPresent,
340        max_eval_time: LegacyResourceConfigDisposition::NotPresent,
341        graph_admission_activation_deferred_to_c2: false,
342    };
343    if let Some(max_vertices) = max_vertices {
344        if budgets.admission.graph_vertex_hard_limit.is_none() {
345            budgets.admission.graph_vertex_hard_limit = Some(max_vertices);
346            diagnostic.max_vertices = LegacyResourceConfigDisposition::Mapped;
347        } else {
348            diagnostic.max_vertices = LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
349        }
350    }
351    if let Some(max_eval_time) = max_eval_time {
352        if budgets.deadline.max_elapsed.is_none() {
353            budgets.deadline.max_elapsed = Some(max_eval_time);
354            diagnostic.max_eval_time = LegacyResourceConfigDisposition::Mapped;
355        } else {
356            diagnostic.max_eval_time = LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
357        }
358    }
359    if let Some(memory_mb) = max_memory_mb {
360        let bytes = u64::try_from(memory_mb)
361            .unwrap_or(u64::MAX)
362            .saturating_mul(1024 * 1024);
363        let (retained, scratch) = split_legacy_memory_bytes(bytes);
364        if budgets.retained.total_bytes.is_none() {
365            budgets.retained.total_bytes = Some(retained);
366            diagnostic.max_memory_mb_retained = LegacyResourceConfigDisposition::Mapped;
367        } else {
368            diagnostic.max_memory_mb_retained =
369                LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
370        }
371        if budgets.scratch.total_bytes.is_none() {
372            budgets.scratch.total_bytes = Some(scratch);
373            diagnostic.max_memory_mb_scratch = LegacyResourceConfigDisposition::Mapped;
374        } else {
375            diagnostic.max_memory_mb_scratch =
376                LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
377        }
378    }
379    ResolvedEvaluationBudgets {
380        budgets,
381        diagnostic: Some(diagnostic),
382    }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
386#[non_exhaustive]
387pub enum ResourceLedgerError {
388    Exhausted(ResourceExhaustionDetail),
389    ReleaseUnderflow {
390        reason: ResourceExhaustionReason,
391        reserved: u64,
392        released: u64,
393    },
394}
395
396impl ResourceLedgerError {
397    pub fn into_excel_error(self) -> ExcelError {
398        let detail = match self {
399            Self::Exhausted(detail) => detail,
400            Self::ReleaseUnderflow {
401                reserved, released, ..
402            } => ResourceExhaustionDetail {
403                reason: ResourceExhaustionReason::ArithmeticOverflow,
404                limit: reserved,
405                observed: released,
406                request_id: None,
407            },
408        };
409        ExcelError::new(ExcelErrorKind::NImpl)
410            .with_message(format!(
411                "evaluation resource exhausted: {} (observed {}, limit {})",
412                detail.reason.as_str(),
413                detail.observed,
414                detail.limit
415            ))
416            .with_extra(ExcelErrorExtra::Resource {
417                detail: Box::new(detail),
418            })
419    }
420}
421
422#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
423pub struct ResourceLedgerSnapshot {
424    pub retained_limit: Option<u64>,
425    pub mixed_cache_limit: Option<u64>,
426    pub retained_current: u64,
427    pub retained_peak: u64,
428    pub scratch_limit: Option<u64>,
429    pub schedule_discovery_limit: Option<u64>,
430    pub scratch_current: u64,
431    pub scratch_peak: u64,
432    pub disk_scratch_policy: Option<DiskScratchPolicy>,
433    pub work_limit: Option<u64>,
434    pub work_charged: u64,
435    pub deadline_ns: Option<u64>,
436    pub deadline_checkpoints: u64,
437    pub exhaustion: Option<ResourceExhaustionReason>,
438}
439
440type ElapsedClock = Arc<dyn Fn() -> Duration + Send + Sync>;
441
442/// Internal accounting mechanism for a single evaluation request.
443///
444/// The budget and snapshot *data* types in this module are public so callers can
445/// configure limits and read outcomes. The ledger itself drives reserve/release
446/// pairing and work charging inside the engine, and publishing that protocol would
447/// freeze an internal contract that no external caller can meaningfully use.
448pub(crate) struct ResourceLedger {
449    request_id: Option<u64>,
450    budgets: EvaluationBudgets,
451    retained_current: u64,
452    retained_peak: u64,
453    mixed_cache_current: u64,
454    scratch_current: u64,
455    scratch_peak: u64,
456    work_charged: u64,
457    deadline_checkpoints: u64,
458    exhaustion: Option<ResourceExhaustionReason>,
459    elapsed: ElapsedClock,
460}
461
462impl fmt::Debug for ResourceLedger {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        f.debug_struct("ResourceLedger")
465            .field("request_id", &self.request_id)
466            .field("budgets", &self.budgets)
467            .field("snapshot", &self.snapshot())
468            .finish()
469    }
470}
471
472impl ResourceLedger {
473    pub fn new(request_id: Option<u64>, budgets: EvaluationBudgets) -> Self {
474        let started = FzInstant::now();
475        Self::with_elapsed_clock(request_id, budgets, Arc::new(move || started.elapsed()))
476    }
477
478    fn with_elapsed_clock(
479        request_id: Option<u64>,
480        budgets: EvaluationBudgets,
481        elapsed: ElapsedClock,
482    ) -> Self {
483        Self {
484            request_id,
485            budgets,
486            retained_current: 0,
487            retained_peak: 0,
488            mixed_cache_current: 0,
489            scratch_current: 0,
490            scratch_peak: 0,
491            work_charged: 0,
492            deadline_checkpoints: 0,
493            exhaustion: None,
494            elapsed,
495        }
496    }
497
498    #[cfg(test)]
499    pub(crate) fn with_test_elapsed_clock(
500        request_id: Option<u64>,
501        budgets: EvaluationBudgets,
502        elapsed: ElapsedClock,
503    ) -> Self {
504        Self::with_elapsed_clock(request_id, budgets, elapsed)
505    }
506
507    fn exhausted(
508        &mut self,
509        reason: ResourceExhaustionReason,
510        limit: u64,
511        observed: u64,
512    ) -> ResourceLedgerError {
513        self.exhaustion = Some(reason);
514        ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
515            reason,
516            limit,
517            observed,
518            request_id: self.request_id,
519        })
520    }
521
522    /// Observe retained ownership without activating the C1b retained cap.
523    pub(crate) fn observe_retained(&mut self, bytes: u64) {
524        self.retained_current = self.retained_current.saturating_add(bytes);
525        self.retained_peak = self.retained_peak.max(self.retained_current);
526    }
527
528    /// Observe scoped scratch ownership without activating non-C1b scratch fields.
529    pub(crate) fn observe_scratch(&mut self, bytes: u64) {
530        self.scratch_current = self.scratch_current.saturating_add(bytes);
531        self.scratch_peak = self.scratch_peak.max(self.scratch_current);
532    }
533
534    fn minimum_limit(first: Option<u64>, second: Option<u64>) -> Option<u64> {
535        match (first, second) {
536            (Some(first), Some(second)) => Some(first.min(second)),
537            (Some(limit), None) | (None, Some(limit)) => Some(limit),
538            (None, None) => None,
539        }
540    }
541
542    pub(crate) fn mixed_cache_limit(&self) -> Option<u64> {
543        Self::minimum_limit(
544            self.budgets.retained.total_bytes,
545            self.budgets.retained.mixed_cache_bytes,
546        )
547    }
548
549    pub(crate) fn schedule_discovery_limit(&self) -> Option<u64> {
550        Self::minimum_limit(
551            self.budgets.scratch.total_bytes,
552            self.budgets.scratch.schedule_discovery_bytes,
553        )
554    }
555
556    pub(crate) fn graph_source_limit(&self) -> Option<u64> {
557        Self::minimum_limit(
558            self.budgets.scratch.total_bytes,
559            self.budgets.scratch.graph_source_bytes,
560        )
561    }
562
563    pub(crate) fn disk_scratch_policy(&self) -> Option<DiskScratchPolicy> {
564        self.budgets.scratch.disk_scratch_policy
565    }
566
567    pub(crate) fn can_reserve_schedule_discovery(&self, bytes: u64) -> bool {
568        let Some(next) = self.scratch_current.checked_add(bytes) else {
569            return false;
570        };
571        self.schedule_discovery_limit()
572            .is_none_or(|limit| next <= limit)
573    }
574
575    pub(crate) fn account_mixed_cache(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
576        let Some(without_previous) = self.retained_current.checked_sub(self.mixed_cache_current)
577        else {
578            return Err(ResourceLedgerError::ReleaseUnderflow {
579                reason: ResourceExhaustionReason::RetainedMemory,
580                reserved: self.retained_current,
581                released: self.mixed_cache_current,
582            });
583        };
584        let Some(next) = without_previous.checked_add(bytes) else {
585            return Err(self.exhausted(
586                ResourceExhaustionReason::ArithmeticOverflow,
587                u64::MAX,
588                u64::MAX,
589            ));
590        };
591        if let Some(limit) = self.mixed_cache_limit()
592            && next > limit
593        {
594            return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
595        }
596        self.retained_current = next;
597        self.mixed_cache_current = bytes;
598        self.retained_peak = self.retained_peak.max(next);
599        Ok(())
600    }
601
602    pub(crate) fn reserve_graph_source(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
603        let Some(next) = self.scratch_current.checked_add(bytes) else {
604            return Err(self.exhausted(
605                ResourceExhaustionReason::ArithmeticOverflow,
606                u64::MAX,
607                u64::MAX,
608            ));
609        };
610        if let Some(limit) = self.graph_source_limit()
611            && next > limit
612        {
613            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
614        }
615        self.scratch_current = next;
616        self.scratch_peak = self.scratch_peak.max(next);
617        Ok(())
618    }
619
620    pub(crate) fn reserve_schedule_discovery(
621        &mut self,
622        bytes: u64,
623    ) -> Result<(), ResourceLedgerError> {
624        let Some(next) = self.scratch_current.checked_add(bytes) else {
625            return Err(self.exhausted(
626                ResourceExhaustionReason::ArithmeticOverflow,
627                u64::MAX,
628                u64::MAX,
629            ));
630        };
631        if let Some(limit) = self.schedule_discovery_limit()
632            && next > limit
633        {
634            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
635        }
636        self.scratch_current = next;
637        self.scratch_peak = self.scratch_peak.max(next);
638        Ok(())
639    }
640
641    pub fn reserve_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
642        let Some(next) = self.retained_current.checked_add(bytes) else {
643            return Err(self.exhausted(
644                ResourceExhaustionReason::ArithmeticOverflow,
645                u64::MAX,
646                u64::MAX,
647            ));
648        };
649        if let Some(limit) = self.budgets.retained.total_bytes
650            && next > limit
651        {
652            return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
653        }
654        self.retained_current = next;
655        self.retained_peak = self.retained_peak.max(next);
656        Ok(())
657    }
658
659    pub fn release_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
660        let Some(next) = self.retained_current.checked_sub(bytes) else {
661            return Err(ResourceLedgerError::ReleaseUnderflow {
662                reason: ResourceExhaustionReason::RetainedMemory,
663                reserved: self.retained_current,
664                released: bytes,
665            });
666        };
667        self.retained_current = next;
668        Ok(())
669    }
670
671    pub fn reserve_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
672        let Some(next) = self.scratch_current.checked_add(bytes) else {
673            return Err(self.exhausted(
674                ResourceExhaustionReason::ArithmeticOverflow,
675                u64::MAX,
676                u64::MAX,
677            ));
678        };
679        if let Some(limit) = self.budgets.scratch.total_bytes
680            && next > limit
681        {
682            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
683        }
684        self.scratch_current = next;
685        self.scratch_peak = self.scratch_peak.max(next);
686        Ok(())
687    }
688
689    pub fn release_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
690        let Some(next) = self.scratch_current.checked_sub(bytes) else {
691            return Err(ResourceLedgerError::ReleaseUnderflow {
692                reason: ResourceExhaustionReason::ScratchMemory,
693                reserved: self.scratch_current,
694                released: bytes,
695            });
696        };
697        self.scratch_current = next;
698        Ok(())
699    }
700
701    pub(crate) fn scratch_checkpoint(&self) -> u64 {
702        self.scratch_current
703    }
704
705    pub(crate) fn release_scratch_to(
706        &mut self,
707        checkpoint: u64,
708    ) -> Result<(), ResourceLedgerError> {
709        if checkpoint > self.scratch_current {
710            return Err(ResourceLedgerError::ReleaseUnderflow {
711                reason: ResourceExhaustionReason::ScratchMemory,
712                reserved: self.scratch_current,
713                released: checkpoint,
714            });
715        }
716        self.scratch_current = checkpoint;
717        Ok(())
718    }
719
720    pub fn release_all_scratch(&mut self) {
721        self.scratch_current = 0;
722    }
723
724    pub fn charge_work(&mut self, units: u64) -> Result<(), ResourceLedgerError> {
725        let Some(next) = self.work_charged.checked_add(units) else {
726            return Err(self.exhausted(
727                ResourceExhaustionReason::ArithmeticOverflow,
728                u64::MAX,
729                u64::MAX,
730            ));
731        };
732        if let Some(limit) = self.budgets.work.max_work_units
733            && next > limit
734        {
735            return Err(self.exhausted(ResourceExhaustionReason::WorkUnits, limit, next));
736        }
737        self.work_charged = next;
738        Ok(())
739    }
740
741    pub fn checkpoint_deadline(&mut self) -> Result<(), ResourceLedgerError> {
742        self.preflight_commit_window(Duration::ZERO)
743    }
744
745    pub(crate) fn preflight_commit_window(
746        &mut self,
747        estimate: Duration,
748    ) -> Result<(), ResourceLedgerError> {
749        self.deadline_checkpoints = self.deadline_checkpoints.saturating_add(1);
750        let Some(limit) = self.budgets.deadline.max_elapsed else {
751            return Ok(());
752        };
753        let elapsed = (self.elapsed)();
754        let projected = elapsed.checked_add(estimate).unwrap_or(Duration::MAX);
755        if projected >= limit {
756            let limit_ns = u64::try_from(limit.as_nanos()).unwrap_or(u64::MAX);
757            let observed_ns = u64::try_from(projected.as_nanos()).unwrap_or(u64::MAX);
758            return Err(self.exhausted(ResourceExhaustionReason::Deadline, limit_ns, observed_ns));
759        }
760        Ok(())
761    }
762
763    pub fn snapshot(&self) -> ResourceLedgerSnapshot {
764        ResourceLedgerSnapshot {
765            retained_limit: self.budgets.retained.total_bytes,
766            mixed_cache_limit: self.mixed_cache_limit(),
767            retained_current: self.retained_current,
768            retained_peak: self.retained_peak,
769            scratch_limit: self.budgets.scratch.total_bytes,
770            schedule_discovery_limit: self.schedule_discovery_limit(),
771            scratch_current: self.scratch_current,
772            scratch_peak: self.scratch_peak,
773            disk_scratch_policy: self.budgets.scratch.disk_scratch_policy,
774            work_limit: self.budgets.work.max_work_units,
775            work_charged: self.work_charged,
776            deadline_ns: self
777                .budgets
778                .deadline
779                .max_elapsed
780                .map(|duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)),
781            deadline_checkpoints: self.deadline_checkpoints,
782            exhaustion: self.exhaustion,
783        }
784    }
785}