Skip to main content

asupersync/types/
budget.rs

1//! Budget type with product semiring semantics.
2//!
3//! Budgets constrain the resources available to a task or region:
4//!
5//! - **Deadline**: Absolute time by which work must complete
6//! - **Poll quota**: Maximum number of poll calls
7//! - **Cost quota**: Abstract cost units (for priority scheduling)
8//! - **Priority**: Scheduling priority (higher = more urgent)
9//!
10//! # Semiring Semantics
11//!
12//! Budgets form a product semiring with two key operations:
13//!
14//! | Operation | Deadline | Poll/Cost Quota | Priority |
15//! |-----------|----------|-----------------|----------|
16//! | `meet` (∧) | min (earlier wins) | min (tighter wins) | max (higher urgency wins) |
17//! | identity  | None (no deadline) | u32::MAX / None | 128 (neutral) |
18//!
19//! The **meet** operation (`combine`/`meet`) computes the tightest constraints
20//! from two budgets. This is used when nesting regions or combining timeout
21//! requirements:
22//!
23//! ```
24//! # use asupersync::Budget;
25//! # use asupersync::types::id::Time;
26//! let outer = Budget::new().with_deadline(Time::from_secs(30));
27//! let inner = Budget::new().with_deadline(Time::from_secs(10));
28//!
29//! // Inner timeout is tighter, so it wins
30//! let combined = outer.meet(inner);
31//! assert_eq!(combined.deadline, Some(Time::from_secs(10)));
32//! ```
33//!
34//! # HTTP Timeout Integration
35//!
36//! Budget maps directly to HTTP request timeout management. The pattern is:
37//!
38//! 1. Create a budget from the request timeout configuration
39//! 2. Attach it to the request's capability context (`Cx`)
40//! 3. All downstream operations inherit and respect the budget
41//! 4. When budget is exhausted, operations return `Outcome::Cancelled`
42//!
43//! Budget deadlines are absolute `Time` instants. Convert duration-style
44//! request timeouts with [`Budget::with_timeout`] at request start instead of
45//! passing a duration-shaped `Time` to [`Budget::with_deadline`].
46//!
47//! ## Example: Request Timeout Middleware
48//!
49//! ```ignore
50//! use asupersync::{Budget, Cx, Outcome, Time};
51//! use std::time::Duration;
52//!
53//! // Server configuration
54//! struct ServerConfig {
55//!     request_timeout: Duration,
56//! }
57//!
58//! // Middleware creates budget from config
59//! async fn timeout_middleware<B>(
60//!     req: Request<B>,
61//!     next: Next<B>,
62//!     config: &ServerConfig,
63//! ) -> Outcome<Response, TimeoutError> {
64//!     // Capture the current logical time from the runtime or lab clock.
65//!     let now = Time::from_secs(1_000);
66//!     let budget = Budget::new().with_timeout(now, config.request_timeout);
67//!
68//!     // Get or create Cx, attach budget
69//!     let cx = req.extensions()
70//!         .get::<Cx>()
71//!         .cloned()
72//!         .unwrap_or_else(Cx::new);
73//!     let cx = cx.with_budget(budget);
74//!
75//!     // All downstream operations now respect the timeout
76//!     match next.run_with_cx(req, &cx).await {
77//!         Outcome::Cancelled(reason) if reason.is_deadline() => {
78//!             Outcome::Err(TimeoutError::RequestTimeout)
79//!         }
80//!         other => other,
81//!     }
82//! }
83//! ```
84//!
85//! ## Budget Propagation Through Regions
86//!
87//! Budget flows through the region tree, with each nested region inheriting
88//! and potentially tightening the parent's constraints:
89//!
90//! ```text
91//! Request Region (budget: 30s deadline)
92//! ├── DB Query Region
93//! │   └── Inherits 30s, operation takes 5s
94//! │       Budget remaining: 25s
95//! ├── External API Call
96//! │   └── Has own 10s timeout, meets with parent: min(25s, 10s) = 10s effective
97//! │       Budget remaining: ~15s after completion
98//! └── Response Serialization
99//!     └── Uses remaining ~15s budget
100//! ```
101//!
102//! ## Exhaustion Behavior
103//!
104//! When a budget is exhausted:
105//!
106//! | Resource | Trigger | Result |
107//! |----------|---------|--------|
108//! | Deadline | `now >= deadline` | `Outcome::Cancelled(CancelReason::deadline())` |
109//! | Poll quota | `poll_quota == 0` | `Outcome::Cancelled(CancelReason::budget())` |
110//! | Cost quota | `cost_quota == Some(0)` | `Outcome::Cancelled(CancelReason::budget())` |
111//!
112//! The runtime checks these conditions at scheduling points and propagates
113//! cancellation through the region tree.
114//!
115//! # Creating Budgets
116//!
117//! ```
118//! # use asupersync::Budget;
119//! # use asupersync::types::id::Time;
120//! // Unlimited budget (default)
121//! let unlimited = Budget::unlimited();
122//!
123//! // With an absolute logical deadline
124//! let timed = Budget::with_deadline_secs(30);
125//!
126//! // With a relative timeout from the current logical time
127//! # use std::time::Duration;
128//! let now = Time::from_secs(1_000);
129//! let request = Budget::new().with_timeout(now, Duration::from_secs(30));
130//!
131//! // Builder pattern for multiple constraints
132//! let complex = Budget::new()
133//!     .with_deadline(Time::from_secs(30))
134//!     .with_poll_quota(1000)
135//!     .with_cost_quota(10_000)
136//!     .with_priority(200);
137//! ```
138
139use super::id::Time;
140use crate::tracing_compat::{info, trace};
141use core::fmt;
142use std::time::Duration;
143
144/// A budget constraining resource usage for a task or region.
145///
146/// Budgets form a product semiring for combination:
147/// - Deadlines/quotas use min (tighter wins)
148/// - Priority uses max (higher urgency wins)
149#[derive(Clone, Copy, PartialEq, Eq)]
150pub struct Budget {
151    /// Absolute deadline by which work must complete.
152    pub deadline: Option<Time>,
153    /// Maximum number of poll operations.
154    pub poll_quota: u32,
155    /// Abstract cost quota (for advanced scheduling).
156    pub cost_quota: Option<u64>,
157    /// Scheduling priority (0 = lowest, 255 = highest).
158    pub priority: u8,
159}
160
161impl Budget {
162    /// A budget with no constraints (infinite resources).
163    pub const INFINITE: Self = Self {
164        deadline: None,
165        poll_quota: u32::MAX,
166        cost_quota: None,
167        priority: 0,
168    };
169
170    /// A budget with zero resources (nothing allowed).
171    pub const ZERO: Self = Self {
172        deadline: Some(Time::ZERO),
173        poll_quota: 0,
174        cost_quota: Some(0),
175        priority: 255,
176    };
177
178    /// A minimal budget for cleanup operations.
179    ///
180    /// This provides a small poll quota (100 polls) for cleanup and finalizer code
181    /// to run, but no deadline or cost constraints. Used when requesting cancellation
182    /// to allow tasks a bounded cleanup phase.
183    pub const MINIMAL: Self = Self {
184        deadline: None,
185        poll_quota: 100,
186        cost_quota: None,
187        priority: 128,
188    };
189
190    /// Creates a new budget with default values (priority 128, unlimited quotas).
191    #[inline]
192    #[must_use]
193    pub const fn new() -> Self {
194        Self {
195            deadline: None,
196            poll_quota: u32::MAX,
197            cost_quota: None,
198            priority: 128,
199        }
200    }
201
202    /// Creates an unlimited budget (alias for [`INFINITE`](Self::INFINITE)).
203    ///
204    /// This is the identity element for the meet operation.
205    ///
206    /// # Example
207    ///
208    /// ```
209    /// # use asupersync::Budget;
210    /// let budget = Budget::unlimited();
211    /// assert!(!budget.is_exhausted());
212    /// ```
213    #[inline]
214    #[must_use]
215    pub const fn unlimited() -> Self {
216        Self::INFINITE
217    }
218
219    /// Creates a budget with only an absolute deadline constraint (in seconds).
220    ///
221    /// The value is a logical instant since the runtime epoch, not a timeout
222    /// duration. For a per-operation timeout, use
223    /// [`with_timeout`](Self::with_timeout) with the current logical time.
224    ///
225    /// # Example
226    ///
227    /// ```
228    /// # use asupersync::Budget;
229    /// # use asupersync::types::id::Time;
230    /// let budget = Budget::with_deadline_secs(30);
231    /// assert_eq!(budget.deadline, Some(Time::from_secs(30)));
232    /// ```
233    #[inline]
234    #[must_use]
235    pub const fn with_deadline_secs(secs: u64) -> Self {
236        Self {
237            deadline: Some(Time::from_secs(secs)),
238            poll_quota: u32::MAX,
239            cost_quota: None,
240            priority: 128,
241        }
242    }
243
244    /// Creates a budget with only an absolute deadline constraint (in nanoseconds).
245    ///
246    /// The value is a logical instant since the runtime epoch, not a timeout
247    /// duration. For a per-operation timeout, use
248    /// [`with_timeout`](Self::with_timeout) with the current logical time.
249    ///
250    /// # Example
251    ///
252    /// ```
253    /// # use asupersync::Budget;
254    /// # use asupersync::types::id::Time;
255    /// let budget = Budget::with_deadline_ns(30_000_000_000); // 30 seconds
256    /// assert_eq!(budget.deadline, Some(Time::from_nanos(30_000_000_000)));
257    /// ```
258    #[inline]
259    #[must_use]
260    pub const fn with_deadline_ns(nanos: u64) -> Self {
261        Self {
262            deadline: Some(Time::from_nanos(nanos)),
263            poll_quota: u32::MAX,
264            cost_quota: None,
265            priority: 128,
266        }
267    }
268
269    /// Sets the absolute logical deadline.
270    ///
271    /// The deadline is an instant, not a duration. For example,
272    /// `Time::from_secs(30)` means the runtime/lab clock value `30s`, not
273    /// "30 seconds from now". For relative per-operation timeouts, use
274    /// [`with_timeout`](Self::with_timeout).
275    #[inline]
276    #[must_use]
277    pub const fn with_deadline(mut self, deadline: Time) -> Self {
278        self.deadline = Some(deadline);
279        self
280    }
281
282    /// Sets the deadline to `now + timeout`.
283    ///
284    /// Use this for request, RPC, database, and other per-operation timeout
285    /// budgets. The caller supplies `now` explicitly so production and lab
286    /// runtimes use the same deterministic time source.
287    ///
288    /// # Example
289    ///
290    /// ```
291    /// # use asupersync::Budget;
292    /// # use asupersync::types::id::Time;
293    /// # use std::time::Duration;
294    /// let now = Time::from_secs(100);
295    /// let budget = Budget::new().with_timeout(now, Duration::from_secs(30));
296    ///
297    /// assert_eq!(budget.deadline, Some(Time::from_secs(130)));
298    /// ```
299    #[inline]
300    #[must_use]
301    pub fn with_timeout(mut self, now: Time, timeout: Duration) -> Self {
302        self.deadline = Some(now + timeout);
303        self
304    }
305
306    /// Sets the poll quota.
307    #[inline]
308    #[must_use]
309    pub const fn with_poll_quota(mut self, quota: u32) -> Self {
310        self.poll_quota = quota;
311        self
312    }
313
314    /// Sets the cost quota.
315    #[inline]
316    #[must_use]
317    pub const fn with_cost_quota(mut self, quota: u64) -> Self {
318        self.cost_quota = Some(quota);
319        self
320    }
321
322    /// Sets the priority.
323    #[inline]
324    #[must_use]
325    pub const fn with_priority(mut self, priority: u8) -> Self {
326        self.priority = priority;
327        self
328    }
329
330    /// Returns true if the budget has been exhausted.
331    ///
332    /// This checks only poll and cost quotas, not deadline (which requires current time).
333    #[inline]
334    #[must_use]
335    pub const fn is_exhausted(&self) -> bool {
336        self.poll_quota == 0 || matches!(self.cost_quota, Some(0))
337    }
338
339    /// Returns true if the deadline has passed.
340    #[inline]
341    #[must_use]
342    pub fn is_past_deadline(&self, now: Time) -> bool {
343        self.deadline.is_some_and(|d| now >= d)
344    }
345
346    /// Decrements the poll quota by one, returning the old value.
347    ///
348    /// Returns `None` if already at zero.
349    #[inline]
350    pub fn consume_poll(&mut self) -> Option<u32> {
351        if self.poll_quota > 0 {
352            let old = self.poll_quota;
353            self.poll_quota -= 1;
354            trace!(
355                polls_remaining = self.poll_quota,
356                polls_consumed = 1,
357                "budget poll consumed"
358            );
359            if self.poll_quota == 0 {
360                info!(
361                    exhausted_resource = "polls",
362                    final_quota = 0,
363                    overage_amount = 0,
364                    "budget poll quota exhausted"
365                );
366            }
367            Some(old)
368        } else {
369            trace!(
370                polls_remaining = 0,
371                "budget poll consume failed: already exhausted"
372            );
373            None
374        }
375    }
376
377    /// Combines two budgets using product semiring semantics.
378    ///
379    /// - Deadlines: min (earlier wins)
380    /// - Quotas: min (tighter wins)
381    /// - Priority: max (higher urgency wins)
382    ///
383    /// This is also known as the "meet" operation (∧) in lattice terminology.
384    /// See also: [`meet`](Self::meet).
385    ///
386    /// # Example
387    ///
388    /// ```
389    /// # use asupersync::Budget;
390    /// # use asupersync::types::id::Time;
391    /// let outer = Budget::new()
392    ///     .with_deadline(Time::from_secs(30))
393    ///     .with_poll_quota(1000);
394    ///
395    /// let inner = Budget::new()
396    ///     .with_deadline(Time::from_secs(10))  // tighter
397    ///     .with_poll_quota(5000);              // looser
398    ///
399    /// let combined = outer.combine(inner);
400    /// assert_eq!(combined.deadline, Some(Time::from_secs(10))); // min
401    /// assert_eq!(combined.poll_quota, 1000);                    // min
402    /// ```
403    #[inline]
404    #[must_use]
405    pub fn combine(self, other: Self) -> Self {
406        let combined = Self {
407            deadline: match (self.deadline, other.deadline) {
408                (Some(a), Some(b)) => Some(a.min(b)),
409                (deadline @ Some(_), None) | (None, deadline @ Some(_)) => deadline,
410                (None, None) => None,
411            },
412            poll_quota: self.poll_quota.min(other.poll_quota),
413            cost_quota: match (self.cost_quota, other.cost_quota) {
414                (Some(a), Some(b)) => Some(a.min(b)),
415                (quota @ Some(_), None) | (None, quota @ Some(_)) => quota,
416                (None, None) => None,
417            },
418            priority: self.priority.max(other.priority),
419        };
420
421        // Trace when budget is tightened (any constraint becomes stricter)
422        // For deadline comparison, None means "no constraint" (least restrictive),
423        // so tightening occurs only when a finite deadline replaces None, or when
424        // one finite deadline is earlier than another.
425        let deadline_tightened = match (combined.deadline, self.deadline, other.deadline) {
426            (Some(c), Some(s), _) if c < s => true,
427            (Some(c), _, Some(o)) if c < o => true,
428            (Some(_), None, _) | (Some(_), _, None) => true,
429            _ => false,
430        };
431        let poll_tightened =
432            combined.poll_quota < self.poll_quota || combined.poll_quota < other.poll_quota;
433        let cost_tightened = match (combined.cost_quota, self.cost_quota, other.cost_quota) {
434            (Some(c), Some(s), _) if c < s => true,
435            (Some(c), _, Some(o)) if c < o => true,
436            (Some(_), None, _) | (Some(_), _, None) => true,
437            _ => false,
438        };
439        let priority_tightened =
440            combined.priority > self.priority || combined.priority > other.priority;
441
442        if deadline_tightened || poll_tightened || cost_tightened || priority_tightened {
443            trace!(
444                deadline_tightened,
445                poll_tightened,
446                cost_tightened,
447                self_deadline = ?self.deadline,
448                other_deadline = ?other.deadline,
449                combined_deadline = ?combined.deadline,
450                self_poll_quota = self.poll_quota,
451                other_poll_quota = other.poll_quota,
452                combined_poll_quota = combined.poll_quota,
453                self_cost_quota = ?self.cost_quota,
454                other_cost_quota = ?other.cost_quota,
455                combined_cost_quota = ?combined.cost_quota,
456                self_priority = self.priority,
457                other_priority = other.priority,
458                combined_priority = combined.priority,
459                "budget combined (tightened)"
460            );
461        }
462
463        combined
464    }
465
466    /// Meet operation (∧) - alias for [`combine`](Self::combine).
467    ///
468    /// Computes the tightest constraints from two budgets. This is the
469    /// fundamental operation for nesting budget scopes.
470    ///
471    /// # Example
472    ///
473    /// ```
474    /// # use asupersync::Budget;
475    /// # use asupersync::types::id::Time;
476    /// let parent = Budget::with_deadline_secs(30);
477    /// let child = Budget::with_deadline_secs(10);
478    ///
479    /// // Child deadline is tighter, so it wins
480    /// let effective = parent.meet(child);
481    /// assert_eq!(effective.deadline, Some(Time::from_secs(10)));
482    /// ```
483    #[inline]
484    #[must_use]
485    pub fn meet(self, other: Self) -> Self {
486        self.combine(other)
487    }
488
489    /// Consumes cost quota, returning `true` if successful.
490    ///
491    /// Returns `false` (and does not modify quota) if there isn't enough
492    /// remaining cost quota. If no cost quota is set, always succeeds.
493    ///
494    /// # Example
495    ///
496    /// ```
497    /// # use asupersync::Budget;
498    /// let mut budget = Budget::new().with_cost_quota(100);
499    ///
500    /// assert!(budget.consume_cost(30));   // 70 remaining
501    /// assert!(budget.consume_cost(70));   // 0 remaining
502    /// assert!(!budget.consume_cost(1));   // fails, quota exhausted
503    /// ```
504    #[allow(clippy::used_underscore_binding)]
505    #[inline]
506    pub fn consume_cost(&mut self, cost: u64) -> bool {
507        match self.cost_quota {
508            None => {
509                trace!(
510                    cost_consumed = cost,
511                    cost_remaining = "unlimited",
512                    "budget cost consumed (unlimited)"
513                );
514                true // No quota means unlimited
515            }
516            Some(remaining) if remaining >= cost => {
517                let new_remaining = remaining - cost;
518                self.cost_quota = Some(new_remaining);
519                trace!(
520                    cost_consumed = cost,
521                    cost_remaining = new_remaining,
522                    "budget cost consumed"
523                );
524                if new_remaining == 0 {
525                    info!(
526                        exhausted_resource = "cost",
527                        final_quota = 0,
528                        overage_amount = 0,
529                        "budget cost quota exhausted"
530                    );
531                }
532                true
533            }
534            Some(remaining) => {
535                #[cfg(not(feature = "tracing-integration"))]
536                let _ = remaining;
537                trace!(
538                    cost_requested = cost,
539                    cost_remaining = remaining,
540                    "budget cost consume failed: insufficient quota"
541                );
542                false
543            }
544        }
545    }
546
547    /// Returns the remaining time until the deadline, if any.
548    ///
549    /// Returns `None` if there is no deadline or if the deadline has passed.
550    ///
551    /// # Example
552    ///
553    /// ```
554    /// # use asupersync::Budget;
555    /// # use asupersync::types::id::Time;
556    /// # use std::time::Duration;
557    /// let budget = Budget::with_deadline_secs(30);
558    /// let now = Time::from_secs(10);
559    ///
560    /// let remaining = budget.remaining_time(now);
561    /// assert_eq!(remaining, Some(Duration::from_secs(20)));
562    /// ```
563    #[inline]
564    #[must_use]
565    pub fn remaining_time(&self, now: Time) -> Option<Duration> {
566        self.deadline.and_then(|d| {
567            if now < d {
568                Some(Duration::from_nanos(
569                    d.as_nanos().saturating_sub(now.as_nanos()),
570                ))
571            } else {
572                None
573            }
574        })
575    }
576
577    /// Returns the remaining poll quota.
578    ///
579    /// Returns the current poll quota value. A value of `u32::MAX` indicates
580    /// effectively unlimited polls.
581    ///
582    /// # Example
583    ///
584    /// ```
585    /// # use asupersync::Budget;
586    /// let budget = Budget::new().with_poll_quota(100);
587    /// assert_eq!(budget.remaining_polls(), 100);
588    /// ```
589    #[inline]
590    #[must_use]
591    pub const fn remaining_polls(&self) -> u32 {
592        self.poll_quota
593    }
594
595    /// Returns the remaining cost quota, if any.
596    ///
597    /// Returns `None` if no cost quota is set (unlimited).
598    ///
599    /// # Example
600    ///
601    /// ```
602    /// # use asupersync::Budget;
603    /// let budget = Budget::new().with_cost_quota(1000);
604    /// assert_eq!(budget.remaining_cost(), Some(1000));
605    ///
606    /// let unlimited = Budget::unlimited();
607    /// assert_eq!(unlimited.remaining_cost(), None);
608    /// ```
609    #[inline]
610    #[must_use]
611    pub const fn remaining_cost(&self) -> Option<u64> {
612        self.cost_quota
613    }
614
615    /// Converts the deadline to a timeout duration from the given time.
616    ///
617    /// Returns the same value as [`remaining_time`](Self::remaining_time).
618    /// This method is provided for API compatibility with timeout-based systems.
619    ///
620    /// # Example
621    ///
622    /// ```
623    /// # use asupersync::Budget;
624    /// # use asupersync::types::id::Time;
625    /// # use std::time::Duration;
626    /// let budget = Budget::with_deadline_secs(30);
627    /// let now = Time::from_secs(5);
628    ///
629    /// // 25 seconds remaining
630    /// let timeout = budget.to_timeout(now);
631    /// assert_eq!(timeout, Some(Duration::from_secs(25)));
632    /// ```
633    #[inline]
634    #[must_use]
635    pub fn to_timeout(&self, now: Time) -> Option<Duration> {
636        self.remaining_time(now)
637    }
638}
639
640/// Resource dimension covered by a [`CapabilityBudget`].
641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
642pub enum CapabilityBudgetDimension {
643    /// Resident memory in bytes.
644    MemoryBytes,
645    /// Abstract CPU units.
646    CpuUnits,
647    /// I/O bytes.
648    IoBytes,
649    /// Cleanup budget used by drain/finalizer paths.
650    Cleanup,
651    /// Bytes emitted as proof, trace, or evidence artifacts.
652    ArtifactBytes,
653}
654
655impl CapabilityBudgetDimension {
656    /// Stable lower-case identifier for logs and reports.
657    #[must_use]
658    pub const fn as_str(self) -> &'static str {
659        match self {
660            Self::MemoryBytes => "memory_bytes",
661            Self::CpuUnits => "cpu_units",
662            Self::IoBytes => "io_bytes",
663            Self::Cleanup => "cleanup",
664            Self::ArtifactBytes => "artifact_bytes",
665        }
666    }
667}
668
669/// Required resource dimensions for fail-closed capability-budget admission.
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
671pub struct CapabilityBudgetRequirements {
672    /// Require a memory envelope.
673    pub memory_bytes: bool,
674    /// Require a CPU envelope.
675    pub cpu_units: bool,
676    /// Require an I/O envelope.
677    pub io_bytes: bool,
678    /// Require a cleanup envelope.
679    pub cleanup: bool,
680    /// Require an artifact-emission envelope.
681    pub artifact_bytes: bool,
682}
683
684impl CapabilityBudgetRequirements {
685    /// No resource dimensions are required.
686    pub const NONE: Self = Self {
687        memory_bytes: false,
688        cpu_units: false,
689        io_bytes: false,
690        cleanup: false,
691        artifact_bytes: false,
692    };
693
694    /// Creates an empty requirement set.
695    #[inline]
696    #[must_use]
697    pub const fn new() -> Self {
698        Self::NONE
699    }
700
701    /// Requires a memory envelope.
702    #[inline]
703    #[must_use]
704    pub const fn require_memory_bytes(mut self) -> Self {
705        self.memory_bytes = true;
706        self
707    }
708
709    /// Requires a CPU envelope.
710    #[inline]
711    #[must_use]
712    pub const fn require_cpu_units(mut self) -> Self {
713        self.cpu_units = true;
714        self
715    }
716
717    /// Requires an I/O envelope.
718    #[inline]
719    #[must_use]
720    pub const fn require_io_bytes(mut self) -> Self {
721        self.io_bytes = true;
722        self
723    }
724
725    /// Requires a cleanup envelope.
726    #[inline]
727    #[must_use]
728    pub const fn require_cleanup(mut self) -> Self {
729        self.cleanup = true;
730        self
731    }
732
733    /// Requires an artifact-emission envelope.
734    #[inline]
735    #[must_use]
736    pub const fn require_artifact_bytes(mut self) -> Self {
737        self.artifact_bytes = true;
738        self
739    }
740}
741
742/// Fail-closed refusal for capability-budget planning.
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744pub enum CapabilityBudgetRefusal {
745    /// A required resource dimension had no inherited or child-supplied limit.
746    MissingRequired(CapabilityBudgetDimension),
747    /// A required resource dimension was present but already exhausted.
748    Exhausted(CapabilityBudgetDimension),
749}
750
751impl fmt::Display for CapabilityBudgetRefusal {
752    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
753        match self {
754            Self::MissingRequired(dimension) => {
755                write!(
756                    f,
757                    "required capability budget missing: {}",
758                    dimension.as_str()
759                )
760            }
761            Self::Exhausted(dimension) => {
762                write!(f, "capability budget exhausted: {}", dimension.as_str())
763            }
764        }
765    }
766}
767
768impl std::error::Error for CapabilityBudgetRefusal {}
769
770/// Explicit resource envelope carried by capability contexts and regions.
771///
772/// `None` means no explicit envelope for that dimension. Admission can still
773/// fail closed by requiring dimensions with [`CapabilityBudgetRequirements`].
774/// Child envelopes inherit parent limits and can only tighten them.
775#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
776pub struct CapabilityBudget {
777    /// Resident memory envelope in bytes.
778    pub memory_bytes: Option<u64>,
779    /// Abstract CPU unit envelope.
780    pub cpu_units: Option<u64>,
781    /// I/O byte envelope.
782    pub io_bytes: Option<u64>,
783    /// Cleanup/drain budget envelope.
784    pub cleanup_budget: Option<Budget>,
785    /// Artifact-emission byte envelope.
786    pub artifact_bytes: Option<u64>,
787}
788
789impl CapabilityBudget {
790    /// No explicit resource envelopes.
791    pub const UNSPECIFIED: Self = Self {
792        memory_bytes: None,
793        cpu_units: None,
794        io_bytes: None,
795        cleanup_budget: None,
796        artifact_bytes: None,
797    };
798
799    /// Creates an empty capability budget.
800    #[inline]
801    #[must_use]
802    pub const fn new() -> Self {
803        Self::UNSPECIFIED
804    }
805
806    /// Sets the memory envelope in bytes.
807    #[inline]
808    #[must_use]
809    pub const fn with_memory_bytes(mut self, bytes: u64) -> Self {
810        self.memory_bytes = Some(bytes);
811        self
812    }
813
814    /// Sets the CPU envelope in abstract units.
815    #[inline]
816    #[must_use]
817    pub const fn with_cpu_units(mut self, units: u64) -> Self {
818        self.cpu_units = Some(units);
819        self
820    }
821
822    /// Sets the I/O envelope in bytes.
823    #[inline]
824    #[must_use]
825    pub const fn with_io_bytes(mut self, bytes: u64) -> Self {
826        self.io_bytes = Some(bytes);
827        self
828    }
829
830    /// Sets the cleanup/drain budget envelope.
831    #[inline]
832    #[must_use]
833    pub const fn with_cleanup_budget(mut self, budget: Budget) -> Self {
834        self.cleanup_budget = Some(budget);
835        self
836    }
837
838    /// Sets the artifact-emission envelope in bytes.
839    #[inline]
840    #[must_use]
841    pub const fn with_artifact_bytes(mut self, bytes: u64) -> Self {
842        self.artifact_bytes = Some(bytes);
843        self
844    }
845
846    /// Combines parent and child capability budgets, keeping the tightest
847    /// envelope for each resource dimension.
848    #[inline]
849    #[must_use]
850    pub fn meet(self, child: Self) -> Self {
851        Self {
852            memory_bytes: meet_optional_u64(self.memory_bytes, child.memory_bytes),
853            cpu_units: meet_optional_u64(self.cpu_units, child.cpu_units),
854            io_bytes: meet_optional_u64(self.io_bytes, child.io_bytes),
855            cleanup_budget: match (self.cleanup_budget, child.cleanup_budget) {
856                (Some(parent), Some(child)) => Some(parent.meet(child)),
857                (budget @ Some(_), None) | (None, budget @ Some(_)) => budget,
858                (None, None) => None,
859            },
860            artifact_bytes: meet_optional_u64(self.artifact_bytes, child.artifact_bytes),
861        }
862    }
863
864    /// Computes the effective child envelope and rejects if required dimensions
865    /// are absent or exhausted.
866    ///
867    /// Use this before admitting a region/task group that needs explicit
868    /// resource envelopes. On success the returned budget is the value to carry
869    /// forward in the child `Cx` or region record.
870    #[inline]
871    pub fn plan_child(
872        self,
873        child: Self,
874        requirements: CapabilityBudgetRequirements,
875    ) -> Result<Self, CapabilityBudgetRefusal> {
876        let effective = self.meet(child);
877        effective.validate(requirements)?;
878        Ok(effective)
879    }
880
881    /// Validates this budget against required resource dimensions.
882    #[inline]
883    pub fn validate(
884        self,
885        requirements: CapabilityBudgetRequirements,
886    ) -> Result<(), CapabilityBudgetRefusal> {
887        validate_required_u64(
888            CapabilityBudgetDimension::MemoryBytes,
889            self.memory_bytes,
890            requirements.memory_bytes,
891        )?;
892        validate_required_u64(
893            CapabilityBudgetDimension::CpuUnits,
894            self.cpu_units,
895            requirements.cpu_units,
896        )?;
897        validate_required_u64(
898            CapabilityBudgetDimension::IoBytes,
899            self.io_bytes,
900            requirements.io_bytes,
901        )?;
902        validate_required_cleanup(self.cleanup_budget, requirements.cleanup)?;
903        validate_required_u64(
904            CapabilityBudgetDimension::ArtifactBytes,
905            self.artifact_bytes,
906            requirements.artifact_bytes,
907        )
908    }
909}
910
911#[inline]
912const fn meet_optional_u64(parent: Option<u64>, child: Option<u64>) -> Option<u64> {
913    match (parent, child) {
914        (Some(parent), Some(child)) => Some(if parent < child { parent } else { child }),
915        (value @ Some(_), None) | (None, value @ Some(_)) => value,
916        (None, None) => None,
917    }
918}
919
920#[inline]
921fn validate_required_u64(
922    dimension: CapabilityBudgetDimension,
923    value: Option<u64>,
924    required: bool,
925) -> Result<(), CapabilityBudgetRefusal> {
926    if !required {
927        return Ok(());
928    }
929
930    match value {
931        None => Err(CapabilityBudgetRefusal::MissingRequired(dimension)),
932        Some(0) => Err(CapabilityBudgetRefusal::Exhausted(dimension)),
933        Some(_) => Ok(()),
934    }
935}
936
937#[inline]
938fn validate_required_cleanup(
939    budget: Option<Budget>,
940    required: bool,
941) -> Result<(), CapabilityBudgetRefusal> {
942    if !required {
943        return Ok(());
944    }
945
946    match budget {
947        None => Err(CapabilityBudgetRefusal::MissingRequired(
948            CapabilityBudgetDimension::Cleanup,
949        )),
950        Some(budget) if budget.is_exhausted() => Err(CapabilityBudgetRefusal::Exhausted(
951            CapabilityBudgetDimension::Cleanup,
952        )),
953        Some(_) => Ok(()),
954    }
955}
956
957impl Default for Budget {
958    fn default() -> Self {
959        Self::new()
960    }
961}
962
963impl fmt::Debug for Budget {
964    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
965        let mut d = f.debug_struct("Budget");
966        if let Some(deadline) = self.deadline {
967            d.field("deadline", &deadline);
968        }
969        if self.poll_quota < u32::MAX {
970            d.field("poll_quota", &self.poll_quota);
971        }
972        if let Some(cost) = self.cost_quota {
973            d.field("cost_quota", &cost);
974        }
975        d.field("priority", &self.priority);
976        d.finish()
977    }
978}
979
980// ============================================================================
981// Min-Plus Network Calculus Curves (Hard Bounds)
982// ============================================================================
983
984/// Errors returned when constructing a min-plus curve.
985#[derive(Debug, Clone, PartialEq, Eq)]
986pub enum CurveError {
987    /// Curve samples must not be empty.
988    EmptySamples,
989    /// Curve samples must be nondecreasing.
990    NonMonotone {
991        /// Index where monotonicity was violated.
992        index: usize,
993        /// Previous sample value.
994        prev: u64,
995        /// Next sample value.
996        next: u64,
997    },
998}
999
1000/// A discrete min-plus curve with a linear tail.
1001///
1002/// Curves are defined for nonnegative integer time `t` with:
1003/// - `samples[t]` for `t <= horizon`
1004/// - `samples[horizon] + tail_rate * (t - horizon)` for `t > horizon`
1005///
1006/// Samples must be nondecreasing.
1007#[derive(Debug, Clone, PartialEq, Eq)]
1008pub struct MinPlusCurve {
1009    samples: Vec<u64>,
1010    tail_rate: u64,
1011}
1012
1013impl MinPlusCurve {
1014    /// Creates a curve from samples and a tail rate.
1015    ///
1016    /// # Errors
1017    /// Returns [`CurveError::EmptySamples`] if `samples` is empty, or
1018    /// [`CurveError::NonMonotone`] if samples are not nondecreasing.
1019    #[inline]
1020    pub fn new(samples: Vec<u64>, tail_rate: u64) -> Result<Self, CurveError> {
1021        if samples.is_empty() {
1022            return Err(CurveError::EmptySamples);
1023        }
1024
1025        for idx in 1..samples.len() {
1026            if samples[idx] < samples[idx.saturating_sub(1)] {
1027                return Err(CurveError::NonMonotone {
1028                    index: idx,
1029                    prev: samples[idx.saturating_sub(1)],
1030                    next: samples[idx],
1031                });
1032            }
1033        }
1034
1035        Ok(Self { samples, tail_rate })
1036    }
1037
1038    /// Creates a curve with a flat tail from samples.
1039    ///
1040    /// # Errors
1041    /// Returns an error if samples are empty or not nondecreasing.
1042    #[inline]
1043    pub fn from_samples(samples: Vec<u64>) -> Result<Self, CurveError> {
1044        Self::new(samples, 0)
1045    }
1046
1047    /// Creates a token-bucket arrival curve `burst + rate * t`.
1048    #[inline]
1049    #[must_use]
1050    pub fn from_token_bucket(burst: u64, rate: u64, horizon: usize) -> Self {
1051        let mut samples = Vec::with_capacity(horizon.saturating_add(1));
1052        for t in 0..=horizon {
1053            let inc = rate.saturating_mul(t as u64);
1054            samples.push(burst.saturating_add(inc));
1055        }
1056        Self {
1057            samples,
1058            tail_rate: rate,
1059        }
1060    }
1061
1062    /// Creates a rate-latency service curve `max(0, rate * (t - latency))`.
1063    #[inline]
1064    #[must_use]
1065    pub fn from_rate_latency(rate: u64, latency: usize, horizon: usize) -> Self {
1066        let mut samples = Vec::with_capacity(horizon.saturating_add(1));
1067        for t in 0..=horizon {
1068            if t <= latency {
1069                samples.push(0);
1070            } else {
1071                let dt = (t - latency) as u64;
1072                samples.push(rate.saturating_mul(dt));
1073            }
1074        }
1075        Self {
1076            samples,
1077            tail_rate: rate,
1078        }
1079    }
1080
1081    /// Returns the discrete horizon (last sample index).
1082    #[must_use]
1083    #[inline]
1084    pub fn horizon(&self) -> usize {
1085        self.samples.len().saturating_sub(1)
1086    }
1087
1088    /// Returns the tail rate used for extrapolation beyond the horizon.
1089    #[must_use]
1090    #[inline]
1091    pub fn tail_rate(&self) -> u64 {
1092        self.tail_rate
1093    }
1094
1095    /// Returns the curve value at integer time `t`.
1096    #[must_use]
1097    #[inline]
1098    pub fn value_at(&self, t: usize) -> u64 {
1099        let horizon = self.horizon();
1100        if t <= horizon {
1101            self.samples[t]
1102        } else {
1103            let extra = (t - horizon) as u64;
1104            self.samples[horizon].saturating_add(self.tail_rate.saturating_mul(extra))
1105        }
1106    }
1107
1108    /// Returns the underlying samples.
1109    #[must_use]
1110    #[inline]
1111    pub fn samples(&self) -> &[u64] {
1112        &self.samples
1113    }
1114
1115    /// Computes the min-plus convolution `(self ⊗ other)` over a horizon.
1116    ///
1117    /// This is O(horizon^2) and intended for small horizons/demonstrations.
1118    #[inline]
1119    #[must_use]
1120    pub fn min_plus_convolution(&self, other: &Self, horizon: usize) -> Self {
1121        let mut samples = Vec::with_capacity(horizon.saturating_add(1));
1122        for t in 0..=horizon {
1123            let mut best = u64::MAX;
1124            for s in 0..=t {
1125                let a = self.value_at(s);
1126                let b = other.value_at(t - s);
1127                let sum = a.saturating_add(b);
1128                if sum < best {
1129                    best = sum;
1130                }
1131            }
1132            samples.push(best);
1133        }
1134
1135        Self {
1136            samples,
1137            tail_rate: self.tail_rate.min(other.tail_rate),
1138        }
1139    }
1140}
1141
1142/// Arrival/service curve pair for admission control hard bounds.
1143#[derive(Debug, Clone, PartialEq, Eq)]
1144pub struct CurveBudget {
1145    /// Arrival curve (demand).
1146    pub arrival: MinPlusCurve,
1147    /// Service curve (supply).
1148    pub service: MinPlusCurve,
1149}
1150
1151impl CurveBudget {
1152    /// Computes the backlog bound `sup_t (arrival(t) - service(t))` over a horizon.
1153    #[must_use]
1154    #[inline]
1155    pub fn backlog_bound(&self, horizon: usize) -> u64 {
1156        backlog_bound(&self.arrival, &self.service, horizon)
1157    }
1158
1159    /// Computes the delay bound over a horizon with a max delay search window.
1160    ///
1161    /// Returns `None` if no delay bound is found within `max_delay`.
1162    #[must_use]
1163    #[inline]
1164    pub fn delay_bound(&self, horizon: usize, max_delay: usize) -> Option<usize> {
1165        delay_bound(&self.arrival, &self.service, horizon, max_delay)
1166    }
1167}
1168
1169/// Computes the backlog bound `sup_t (arrival(t) - service(t))` over a horizon.
1170#[inline]
1171#[must_use]
1172pub fn backlog_bound(arrival: &MinPlusCurve, service: &MinPlusCurve, horizon: usize) -> u64 {
1173    let mut worst = 0;
1174    for t in 0..=horizon {
1175        let demand = arrival.value_at(t);
1176        let supply = service.value_at(t);
1177        let backlog = demand.saturating_sub(supply);
1178        if backlog > worst {
1179            worst = backlog;
1180        }
1181    }
1182    worst
1183}
1184
1185/// Computes a delay bound `d` such that `arrival(t) <= service(t + d)` for all `t`.
1186///
1187/// Returns `None` if no bound is found within `max_delay`.
1188#[inline]
1189#[must_use]
1190pub fn delay_bound(
1191    arrival: &MinPlusCurve,
1192    service: &MinPlusCurve,
1193    horizon: usize,
1194    max_delay: usize,
1195) -> Option<usize> {
1196    let mut worst_delay = 0;
1197    for t in 0..=horizon {
1198        let demand = arrival.value_at(t);
1199        let mut found = None;
1200        for d in 0..=max_delay {
1201            if service.value_at(t + d) >= demand {
1202                found = Some(d);
1203                break;
1204            }
1205        }
1206        let delay = found?;
1207        if delay > worst_delay {
1208            worst_delay = delay;
1209        }
1210    }
1211    Some(worst_delay)
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    #![allow(
1217        clippy::pedantic,
1218        clippy::nursery,
1219        clippy::expect_fun_call,
1220        clippy::map_unwrap_or,
1221        clippy::cast_possible_wrap,
1222        clippy::future_not_send
1223    )]
1224    use super::*;
1225    use serde_json::json;
1226
1227    fn scrub_budget_event(deadline: Option<Time>) -> &'static str {
1228        if deadline.is_some() {
1229            "[DEADLINE]"
1230        } else {
1231            "[NONE]"
1232        }
1233    }
1234
1235    // =========================================================================
1236    // Constants Tests
1237    // =========================================================================
1238
1239    #[test]
1240    fn infinite_budget_values() {
1241        let b = Budget::INFINITE;
1242        assert_eq!(b.deadline, None);
1243        assert_eq!(b.poll_quota, u32::MAX);
1244        assert_eq!(b.cost_quota, None);
1245        assert_eq!(b.priority, 0);
1246    }
1247
1248    #[test]
1249    fn zero_budget_values() {
1250        let b = Budget::ZERO;
1251        assert_eq!(b.deadline, Some(Time::ZERO));
1252        assert_eq!(b.poll_quota, 0);
1253        assert_eq!(b.cost_quota, Some(0));
1254        assert_eq!(b.priority, 255);
1255    }
1256
1257    #[test]
1258    fn new_returns_default_priority() {
1259        let b = Budget::new();
1260        assert_eq!(b.priority, 128);
1261        assert_ne!(b, Budget::INFINITE);
1262    }
1263
1264    #[test]
1265    fn default_returns_new() {
1266        assert_eq!(Budget::default(), Budget::new());
1267        assert_ne!(Budget::default(), Budget::INFINITE);
1268    }
1269
1270    // =========================================================================
1271    // Builder Methods Tests
1272    // =========================================================================
1273
1274    #[test]
1275    fn with_deadline_sets_deadline() {
1276        let deadline = Time::from_secs(30);
1277        let budget = Budget::new().with_deadline(deadline);
1278        assert_eq!(budget.deadline, Some(deadline));
1279    }
1280
1281    #[test]
1282    fn with_poll_quota_sets_quota() {
1283        let budget = Budget::new().with_poll_quota(42);
1284        assert_eq!(budget.poll_quota, 42);
1285    }
1286
1287    #[test]
1288    fn with_cost_quota_sets_quota() {
1289        let budget = Budget::new().with_cost_quota(1000);
1290        assert_eq!(budget.cost_quota, Some(1000));
1291    }
1292
1293    #[test]
1294    fn with_priority_sets_priority() {
1295        let budget = Budget::new().with_priority(255);
1296        assert_eq!(budget.priority, 255);
1297    }
1298
1299    #[test]
1300    fn builder_chaining() {
1301        let budget = Budget::new()
1302            .with_deadline(Time::from_secs(10))
1303            .with_poll_quota(100)
1304            .with_cost_quota(5000)
1305            .with_priority(200);
1306
1307        assert_eq!(budget.deadline, Some(Time::from_secs(10)));
1308        assert_eq!(budget.poll_quota, 100);
1309        assert_eq!(budget.cost_quota, Some(5000));
1310        assert_eq!(budget.priority, 200);
1311    }
1312
1313    // =========================================================================
1314    // is_exhausted Tests
1315    // =========================================================================
1316
1317    #[test]
1318    fn is_exhausted_false_for_infinite() {
1319        assert!(!Budget::INFINITE.is_exhausted());
1320    }
1321
1322    #[test]
1323    fn is_exhausted_true_for_zero() {
1324        assert!(Budget::ZERO.is_exhausted());
1325    }
1326
1327    #[test]
1328    fn is_exhausted_when_poll_quota_zero() {
1329        let budget = Budget::new().with_poll_quota(0);
1330        assert!(budget.is_exhausted());
1331    }
1332
1333    #[test]
1334    fn is_exhausted_when_cost_quota_zero() {
1335        let budget = Budget::new().with_cost_quota(0);
1336        assert!(budget.is_exhausted());
1337    }
1338
1339    #[test]
1340    fn is_exhausted_false_with_resources() {
1341        let budget = Budget::new().with_poll_quota(10).with_cost_quota(100);
1342        assert!(!budget.is_exhausted());
1343    }
1344
1345    // =========================================================================
1346    // is_past_deadline Tests
1347    // =========================================================================
1348
1349    #[test]
1350    fn is_past_deadline_true_when_past() {
1351        let budget = Budget::new().with_deadline(Time::from_secs(5));
1352        let now = Time::from_secs(10);
1353        assert!(budget.is_past_deadline(now));
1354    }
1355
1356    #[test]
1357    fn is_past_deadline_false_when_not_past() {
1358        let budget = Budget::new().with_deadline(Time::from_secs(10));
1359        let now = Time::from_secs(5);
1360        assert!(!budget.is_past_deadline(now));
1361    }
1362
1363    #[test]
1364    fn is_past_deadline_true_at_exact_time() {
1365        let deadline = Time::from_secs(5);
1366        let budget = Budget::new().with_deadline(deadline);
1367        assert!(budget.is_past_deadline(deadline));
1368    }
1369
1370    #[test]
1371    fn is_past_deadline_false_when_no_deadline() {
1372        let budget = Budget::new();
1373        assert!(!budget.is_past_deadline(Time::from_secs(1_000_000)));
1374    }
1375
1376    // =========================================================================
1377    // consume_poll Tests
1378    // =========================================================================
1379
1380    #[test]
1381    fn consume_poll_decrements() {
1382        let mut budget = Budget::new().with_poll_quota(2);
1383
1384        assert_eq!(budget.consume_poll(), Some(2));
1385        assert_eq!(budget.poll_quota, 1);
1386
1387        assert_eq!(budget.consume_poll(), Some(1));
1388        assert_eq!(budget.poll_quota, 0);
1389
1390        assert_eq!(budget.consume_poll(), None);
1391        assert_eq!(budget.poll_quota, 0);
1392    }
1393
1394    #[test]
1395    fn consume_poll_returns_none_at_zero() {
1396        let mut budget = Budget::new().with_poll_quota(0);
1397        assert_eq!(budget.consume_poll(), None);
1398        assert_eq!(budget.poll_quota, 0);
1399    }
1400
1401    #[test]
1402    fn consume_poll_transitions_to_exhausted() {
1403        let mut budget = Budget::new().with_poll_quota(1);
1404        assert!(!budget.is_exhausted());
1405
1406        budget.consume_poll();
1407        assert!(budget.is_exhausted());
1408    }
1409
1410    // =========================================================================
1411    // Combine Tests (Product Semiring Semantics)
1412    // =========================================================================
1413
1414    #[test]
1415    fn combine_takes_tighter() {
1416        let a = Budget::new()
1417            .with_deadline(Time::from_secs(10))
1418            .with_poll_quota(100)
1419            .with_priority(50);
1420
1421        let b = Budget::new()
1422            .with_deadline(Time::from_secs(5))
1423            .with_poll_quota(200)
1424            .with_priority(100);
1425
1426        let combined = a.combine(b);
1427
1428        // Deadline: min
1429        assert_eq!(combined.deadline, Some(Time::from_secs(5)));
1430        // Poll quota: min
1431        assert_eq!(combined.poll_quota, 100);
1432        // Priority: max
1433        assert_eq!(combined.priority, 100);
1434    }
1435
1436    #[test]
1437    fn combine_deadline_none_with_some() {
1438        let a = Budget::new(); // No deadline
1439        let b = Budget::new().with_deadline(Time::from_secs(5));
1440
1441        // a.combine(b) should have b's deadline
1442        assert_eq!(a.combine(b).deadline, Some(Time::from_secs(5)));
1443        // b.combine(a) should also have b's deadline
1444        assert_eq!(b.combine(a).deadline, Some(Time::from_secs(5)));
1445    }
1446
1447    #[test]
1448    fn combine_deadline_none_with_none() {
1449        let a = Budget::new();
1450        let b = Budget::new();
1451        assert_eq!(a.combine(b).deadline, None);
1452    }
1453
1454    #[test]
1455    fn combine_cost_quota_none_with_some() {
1456        let a = Budget::new(); // cost_quota = None
1457        let b = Budget::new().with_cost_quota(100);
1458
1459        // Should take the defined quota
1460        assert_eq!(a.combine(b).cost_quota, Some(100));
1461        assert_eq!(b.combine(a).cost_quota, Some(100));
1462    }
1463
1464    #[test]
1465    fn combine_cost_quota_takes_min() {
1466        let a = Budget::new().with_cost_quota(50);
1467        let b = Budget::new().with_cost_quota(100);
1468
1469        assert_eq!(a.combine(b).cost_quota, Some(50));
1470        assert_eq!(b.combine(a).cost_quota, Some(50));
1471    }
1472
1473    #[test]
1474    fn combine_with_zero_absorbs() {
1475        let any_budget = Budget::new()
1476            .with_deadline(Time::from_secs(100))
1477            .with_poll_quota(1000)
1478            .with_cost_quota(10000)
1479            .with_priority(200);
1480
1481        let combined = any_budget.combine(Budget::ZERO);
1482
1483        // ZERO's deadline (Time::ZERO) is tighter
1484        assert_eq!(combined.deadline, Some(Time::ZERO));
1485        // ZERO's poll_quota (0) is tighter
1486        assert_eq!(combined.poll_quota, 0);
1487        // ZERO's cost_quota (Some(0)) is tighter
1488        assert_eq!(combined.cost_quota, Some(0));
1489        // Priority: max of 200 and 255 = 255
1490        assert_eq!(combined.priority, 255);
1491    }
1492
1493    #[test]
1494    fn combine_with_infinite_preserves() {
1495        let budget = Budget::new()
1496            .with_deadline(Time::from_secs(10))
1497            .with_poll_quota(100)
1498            .with_cost_quota(1000)
1499            .with_priority(50);
1500
1501        let combined = budget.combine(Budget::INFINITE);
1502
1503        // All values should be preserved (INFINITE doesn't constrain)
1504        assert_eq!(combined.deadline, Some(Time::from_secs(10)));
1505        assert_eq!(combined.poll_quota, 100);
1506        assert_eq!(combined.cost_quota, Some(1000));
1507        // Priority: max of 50 and 0 = 50
1508        assert_eq!(combined.priority, 50);
1509    }
1510
1511    // =========================================================================
1512    // Capability Budget Planner Tests
1513    // =========================================================================
1514
1515    #[test]
1516    fn capability_budget_child_inherits_parent_and_tightens_overrides() {
1517        let parent = CapabilityBudget::new()
1518            .with_memory_bytes(1_024)
1519            .with_cpu_units(100)
1520            .with_io_bytes(10_000)
1521            .with_cleanup_budget(Budget::new().with_poll_quota(50))
1522            .with_artifact_bytes(4_096);
1523        let child = CapabilityBudget::new()
1524            .with_memory_bytes(2_048)
1525            .with_cpu_units(25)
1526            .with_io_bytes(1_000)
1527            .with_cleanup_budget(Budget::new().with_poll_quota(10));
1528
1529        let effective = parent.meet(child);
1530
1531        assert_eq!(effective.memory_bytes, Some(1_024));
1532        assert_eq!(effective.cpu_units, Some(25));
1533        assert_eq!(effective.io_bytes, Some(1_000));
1534        assert_eq!(
1535            effective.cleanup_budget.map(|budget| budget.poll_quota),
1536            Some(10)
1537        );
1538        assert_eq!(effective.artifact_bytes, Some(4_096));
1539    }
1540
1541    #[test]
1542    fn capability_budget_optional_absent_dimension_admits() {
1543        let requirements = CapabilityBudgetRequirements::NONE;
1544
1545        let effective = CapabilityBudget::new()
1546            .plan_child(CapabilityBudget::new(), requirements)
1547            .expect("optional absent envelopes should admit");
1548
1549        assert_eq!(effective, CapabilityBudget::UNSPECIFIED);
1550    }
1551
1552    #[test]
1553    fn capability_budget_required_absent_dimension_rejects() {
1554        let requirements = CapabilityBudgetRequirements::new().require_memory_bytes();
1555
1556        let err = CapabilityBudget::new()
1557            .plan_child(CapabilityBudget::new(), requirements)
1558            .expect_err("required missing memory budget must reject");
1559
1560        assert_eq!(
1561            err,
1562            CapabilityBudgetRefusal::MissingRequired(CapabilityBudgetDimension::MemoryBytes)
1563        );
1564    }
1565
1566    #[test]
1567    fn capability_budget_required_exhausted_dimension_rejects() {
1568        let requirements = CapabilityBudgetRequirements::new()
1569            .require_cpu_units()
1570            .require_cleanup();
1571        let parent = CapabilityBudget::new()
1572            .with_cpu_units(0)
1573            .with_cleanup_budget(Budget::new().with_poll_quota(10));
1574
1575        let err = parent
1576            .plan_child(CapabilityBudget::new(), requirements)
1577            .expect_err("required exhausted CPU budget must reject");
1578
1579        assert_eq!(
1580            err,
1581            CapabilityBudgetRefusal::Exhausted(CapabilityBudgetDimension::CpuUnits)
1582        );
1583    }
1584
1585    #[test]
1586    fn capability_budget_required_exhausted_cleanup_rejects() {
1587        let requirements = CapabilityBudgetRequirements::new().require_cleanup();
1588        let parent = CapabilityBudget::new().with_cleanup_budget(Budget::new().with_poll_quota(0));
1589
1590        let err = parent
1591            .plan_child(CapabilityBudget::new(), requirements)
1592            .expect_err("required exhausted cleanup budget must reject");
1593
1594        assert_eq!(
1595            err,
1596            CapabilityBudgetRefusal::Exhausted(CapabilityBudgetDimension::Cleanup)
1597        );
1598    }
1599
1600    // =========================================================================
1601    // Debug/Display Tests
1602    // =========================================================================
1603
1604    #[test]
1605    fn debug_shows_constrained_fields() {
1606        let budget = Budget::new()
1607            .with_deadline(Time::from_secs(10))
1608            .with_poll_quota(100)
1609            .with_cost_quota(500);
1610
1611        let debug = format!("{budget:?}");
1612
1613        // Debug should include constrained fields
1614        assert!(debug.contains("deadline"));
1615        assert!(debug.contains("poll_quota"));
1616        assert!(debug.contains("cost_quota"));
1617        assert!(debug.contains("priority"));
1618    }
1619
1620    #[test]
1621    fn debug_omits_unconstrained_fields() {
1622        let budget = Budget::INFINITE;
1623        let debug = format!("{budget:?}");
1624
1625        // INFINITE has no deadline, MAX poll_quota, and no cost_quota
1626        // Debug should omit deadline and poll_quota but include priority
1627        assert!(!debug.contains("deadline"));
1628        assert!(!debug.contains("poll_quota")); // u32::MAX is omitted
1629        assert!(debug.contains("priority"));
1630    }
1631
1632    // =========================================================================
1633    // Equality Tests
1634    // =========================================================================
1635
1636    #[test]
1637    fn equality_works() {
1638        let a = Budget::new()
1639            .with_deadline(Time::from_secs(10))
1640            .with_poll_quota(100);
1641
1642        let b = Budget::new()
1643            .with_deadline(Time::from_secs(10))
1644            .with_poll_quota(100);
1645
1646        assert_eq!(a, b);
1647    }
1648
1649    #[test]
1650    fn inequality_on_deadline() {
1651        let a = Budget::new().with_deadline(Time::from_secs(10));
1652        let b = Budget::new().with_deadline(Time::from_secs(20));
1653        assert_ne!(a, b);
1654    }
1655
1656    #[test]
1657    fn copy_semantics() {
1658        let a = Budget::new().with_poll_quota(100);
1659        let b = a; // Copy
1660        assert_eq!(a.poll_quota, b.poll_quota);
1661        // Modifying b doesn't affect a
1662        let mut c = a;
1663        c.poll_quota = 50;
1664        assert_eq!(a.poll_quota, 100);
1665        assert_eq!(c.poll_quota, 50);
1666    }
1667
1668    // =========================================================================
1669    // New Convenience Method Tests
1670    // =========================================================================
1671
1672    #[test]
1673    fn unlimited_returns_infinite() {
1674        assert_eq!(Budget::unlimited(), Budget::INFINITE);
1675    }
1676
1677    #[test]
1678    fn with_deadline_secs_constructor() {
1679        let budget = Budget::with_deadline_secs(30);
1680        assert_eq!(budget.deadline, Some(Time::from_secs(30)));
1681        assert_eq!(budget.poll_quota, u32::MAX);
1682        assert_eq!(budget.cost_quota, None);
1683        assert_eq!(budget.priority, 128);
1684    }
1685
1686    #[test]
1687    fn with_deadline_ns_constructor() {
1688        let budget = Budget::with_deadline_ns(30_000_000_000);
1689        assert_eq!(budget.deadline, Some(Time::from_nanos(30_000_000_000)));
1690    }
1691
1692    #[test]
1693    fn with_timeout_sets_deadline_relative_to_now() {
1694        let now = Time::from_secs(100);
1695        let budget = Budget::new().with_timeout(now, Duration::from_secs(30));
1696
1697        assert_eq!(budget.deadline, Some(Time::from_secs(130)));
1698        assert_eq!(budget.remaining_time(now), Some(Duration::from_secs(30)));
1699    }
1700
1701    #[test]
1702    fn with_timeout_saturates_at_max_time() {
1703        let now = Time::MAX.saturating_sub_nanos(5);
1704        let budget = Budget::new().with_timeout(now, Duration::from_nanos(10));
1705
1706        assert_eq!(budget.deadline, Some(Time::MAX));
1707    }
1708
1709    #[test]
1710    fn with_timeout_keeps_other_constraints() {
1711        let now = Time::from_secs(7);
1712        let budget = Budget::new()
1713            .with_poll_quota(42)
1714            .with_cost_quota(99)
1715            .with_priority(200)
1716            .with_timeout(now, Duration::from_secs(3));
1717
1718        assert_eq!(budget.deadline, Some(Time::from_secs(10)));
1719        assert_eq!(budget.poll_quota, 42);
1720        assert_eq!(budget.cost_quota, Some(99));
1721        assert_eq!(budget.priority, 200);
1722    }
1723
1724    #[test]
1725    fn meet_is_alias_for_combine() {
1726        let a = Budget::new()
1727            .with_deadline(Time::from_secs(10))
1728            .with_poll_quota(100);
1729
1730        let b = Budget::new()
1731            .with_deadline(Time::from_secs(5))
1732            .with_poll_quota(200);
1733
1734        assert_eq!(a.meet(b), a.combine(b));
1735    }
1736
1737    // =========================================================================
1738    // consume_cost Tests
1739    // =========================================================================
1740
1741    #[test]
1742    fn consume_cost_basic() {
1743        let mut budget = Budget::new().with_cost_quota(100);
1744
1745        assert!(budget.consume_cost(30));
1746        assert_eq!(budget.cost_quota, Some(70));
1747
1748        assert!(budget.consume_cost(70));
1749        assert_eq!(budget.cost_quota, Some(0));
1750
1751        assert!(!budget.consume_cost(1));
1752        assert_eq!(budget.cost_quota, Some(0));
1753    }
1754
1755    #[test]
1756    fn consume_cost_unlimited() {
1757        let mut budget = Budget::new(); // No cost quota = unlimited
1758        assert!(budget.consume_cost(1_000_000));
1759        assert_eq!(budget.cost_quota, None);
1760    }
1761
1762    #[test]
1763    fn consume_cost_exact_amount() {
1764        let mut budget = Budget::new().with_cost_quota(50);
1765        assert!(budget.consume_cost(50));
1766        assert_eq!(budget.cost_quota, Some(0));
1767    }
1768
1769    #[test]
1770    fn consume_cost_transitions_to_exhausted() {
1771        let mut budget = Budget::new().with_cost_quota(10).with_poll_quota(u32::MAX);
1772        assert!(!budget.is_exhausted());
1773
1774        budget.consume_cost(10);
1775        assert!(budget.is_exhausted());
1776    }
1777
1778    // =========================================================================
1779    // Inspection Method Tests
1780    // =========================================================================
1781
1782    #[test]
1783    fn remaining_time_basic() {
1784        let budget = Budget::with_deadline_secs(30);
1785        let now = Time::from_secs(10);
1786
1787        let remaining = budget.remaining_time(now);
1788        assert_eq!(remaining, Some(Duration::from_secs(20)));
1789    }
1790
1791    #[test]
1792    fn remaining_time_no_deadline() {
1793        let budget = Budget::unlimited();
1794        assert_eq!(budget.remaining_time(Time::from_secs(1000)), None);
1795    }
1796
1797    #[test]
1798    fn remaining_time_past_deadline() {
1799        let budget = Budget::with_deadline_secs(10);
1800        assert_eq!(budget.remaining_time(Time::from_secs(15)), None);
1801    }
1802
1803    #[test]
1804    fn remaining_time_at_deadline() {
1805        let budget = Budget::with_deadline_secs(10);
1806        assert_eq!(budget.remaining_time(Time::from_secs(10)), None);
1807    }
1808
1809    #[test]
1810    fn remaining_polls_basic() {
1811        let budget = Budget::new().with_poll_quota(100);
1812        assert_eq!(budget.remaining_polls(), 100);
1813    }
1814
1815    #[test]
1816    fn remaining_polls_unlimited() {
1817        let budget = Budget::unlimited();
1818        assert_eq!(budget.remaining_polls(), u32::MAX);
1819    }
1820
1821    #[test]
1822    fn remaining_cost_basic() {
1823        let budget = Budget::new().with_cost_quota(1000);
1824        assert_eq!(budget.remaining_cost(), Some(1000));
1825    }
1826
1827    #[test]
1828    fn remaining_cost_unlimited() {
1829        let budget = Budget::unlimited();
1830        assert_eq!(budget.remaining_cost(), None);
1831    }
1832
1833    #[test]
1834    fn to_timeout_is_alias_for_remaining_time() {
1835        let budget = Budget::with_deadline_secs(30);
1836        let now = Time::from_secs(10);
1837
1838        assert_eq!(budget.to_timeout(now), budget.remaining_time(now));
1839    }
1840
1841    // =========================================================================
1842    // Min-Plus Network Calculus Tests
1843    // =========================================================================
1844
1845    #[test]
1846    fn min_plus_curve_validation_rejects_non_monotone() {
1847        let err = MinPlusCurve::new(vec![0, 2, 1], 0).expect_err("non-monotone samples");
1848        assert!(matches!(
1849            err,
1850            CurveError::NonMonotone {
1851                index: 2,
1852                prev: 2,
1853                next: 1
1854            }
1855        ));
1856    }
1857
1858    #[test]
1859    fn min_plus_convolution_basic() {
1860        let a = MinPlusCurve::new(vec![0, 1, 2], 1).expect("valid curve");
1861        let b = MinPlusCurve::new(vec![0, 0, 1], 1).expect("valid curve");
1862        let conv = a.min_plus_convolution(&b, 2);
1863        assert_eq!(conv.samples(), &[0, 0, 1]);
1864    }
1865
1866    #[test]
1867    fn min_plus_backlog_delay_demo() {
1868        let arrival = MinPlusCurve::from_token_bucket(5, 2, 10);
1869        let service = MinPlusCurve::from_rate_latency(3, 2, 10);
1870        let budget = CurveBudget { arrival, service };
1871
1872        let backlog = budget.backlog_bound(10);
1873        let delay = budget.delay_bound(10, 10);
1874
1875        assert_eq!(backlog, 9);
1876        assert_eq!(delay, Some(4));
1877    }
1878
1879    // =========================================================================
1880    // Budget Algebra Formal Lemmas (bd-hf5bz)
1881    //
1882    // These tests serve as mechanized proof artifacts for the budget algebra.
1883    // Each test corresponds to a named lemma that downstream proofs
1884    // (bd-3cq88, bd-2qmr4, bd-3fp4g, bd-ecp8u) can reference.
1885    //
1886    // Algebra summary:
1887    //   (Budget, meet, INFINITE) forms a bounded meet-semilattice where:
1888    //   - meet is the pointwise min on (deadline, poll_quota, cost_quota, priority).
1889    //   - INFINITE is the top element (identity for meet).
1890    //   - ZERO is the bottom element (absorbing for meet, modulo priority).
1891    //
1892    // Code mapping:
1893    //   meet        → Budget::meet / Budget::combine  (src/types/budget.rs)
1894    //   identity    → Budget::INFINITE
1895    //   absorbing   → Budget::ZERO
1896    //   consumption → Budget::consume_poll, Budget::consume_cost
1897    //
1898    // Min-plus / tropical structure:
1899    //   (DeadlineMicros, min, add, ∞, 0) forms a tropical semiring
1900    //   used in plan analysis for sequential/parallel budget composition.
1901    //   Code mapping → src/plan/analysis.rs :: DeadlineMicros
1902    //
1903    //   (MinPlusCurve, min_plus_convolution) forms a min-plus algebra
1904    //   for network calculus admission control.
1905    //   Code mapping → MinPlusCurve / CurveBudget (src/types/budget.rs)
1906    // =========================================================================
1907
1908    // -- Lemma 1: meet is commutative --
1909    // ∀ a b. a.meet(b) == b.meet(a)
1910
1911    #[test]
1912    fn lemma_meet_commutative() {
1913        let a = Budget::new()
1914            .with_deadline(Time::from_secs(10))
1915            .with_poll_quota(100)
1916            .with_cost_quota(500)
1917            .with_priority(50);
1918
1919        let b = Budget::new()
1920            .with_deadline(Time::from_secs(5))
1921            .with_poll_quota(200)
1922            .with_cost_quota(300)
1923            .with_priority(100);
1924
1925        assert_eq!(a.meet(b), b.meet(a));
1926    }
1927
1928    #[test]
1929    fn lemma_meet_commutative_with_none_deadline() {
1930        let a = Budget::new().with_poll_quota(100);
1931        let b = Budget::new()
1932            .with_deadline(Time::from_secs(5))
1933            .with_poll_quota(200);
1934        assert_eq!(a.meet(b), b.meet(a));
1935    }
1936
1937    #[test]
1938    fn lemma_meet_commutative_with_none_cost() {
1939        let a = Budget::new().with_cost_quota(100);
1940        let b = Budget::new(); // cost_quota = None
1941        assert_eq!(a.meet(b), b.meet(a));
1942    }
1943
1944    // -- Lemma 2: meet is associative --
1945    // ∀ a b c. a.meet(b.meet(c)) == a.meet(b).meet(c)
1946
1947    #[test]
1948    fn lemma_meet_associative() {
1949        let a = Budget::new()
1950            .with_deadline(Time::from_secs(10))
1951            .with_poll_quota(100)
1952            .with_cost_quota(500)
1953            .with_priority(50);
1954
1955        let b = Budget::new()
1956            .with_deadline(Time::from_secs(5))
1957            .with_poll_quota(200)
1958            .with_cost_quota(300)
1959            .with_priority(100);
1960
1961        let c = Budget::new()
1962            .with_deadline(Time::from_secs(8))
1963            .with_poll_quota(150)
1964            .with_cost_quota(400)
1965            .with_priority(75);
1966
1967        assert_eq!(a.meet(b.meet(c)), a.meet(b).meet(c));
1968    }
1969
1970    #[test]
1971    fn lemma_meet_associative_with_none_fields() {
1972        let a = Budget::new().with_deadline(Time::from_secs(10));
1973        let b = Budget::new().with_cost_quota(300);
1974        let c = Budget::new().with_poll_quota(50);
1975
1976        assert_eq!(a.meet(b.meet(c)), a.meet(b).meet(c));
1977    }
1978
1979    // -- Lemma 3: meet is idempotent --
1980    // ∀ a. a.meet(a) == a
1981
1982    #[test]
1983    fn lemma_meet_idempotent() {
1984        let a = Budget::new()
1985            .with_deadline(Time::from_secs(10))
1986            .with_poll_quota(100)
1987            .with_cost_quota(500)
1988            .with_priority(75);
1989        assert_eq!(a.meet(a), a);
1990    }
1991
1992    #[test]
1993    fn lemma_meet_idempotent_infinite() {
1994        assert_eq!(Budget::INFINITE.meet(Budget::INFINITE), Budget::INFINITE);
1995    }
1996
1997    #[test]
1998    fn lemma_meet_idempotent_zero() {
1999        assert_eq!(Budget::ZERO.meet(Budget::ZERO), Budget::ZERO);
2000    }
2001
2002    // -- Lemma 4: INFINITE is the identity for meet --
2003    // ∀ a. a.meet(INFINITE) == a
2004
2005    #[test]
2006    fn lemma_identity_left() {
2007        let a = Budget::new()
2008            .with_deadline(Time::from_secs(10))
2009            .with_poll_quota(100)
2010            .with_cost_quota(500)
2011            .with_priority(50);
2012
2013        // INFINITE has priority 0 (identity for max).
2014        // meet takes max priority.
2015        let result = Budget::INFINITE.meet(a);
2016        assert_eq!(result.deadline, a.deadline);
2017        assert_eq!(result.poll_quota, a.poll_quota);
2018        assert_eq!(result.cost_quota, a.cost_quota);
2019        assert_eq!(result.priority, a.priority);
2020    }
2021
2022    #[test]
2023    fn lemma_identity_right() {
2024        let a = Budget::new()
2025            .with_deadline(Time::from_secs(10))
2026            .with_poll_quota(100)
2027            .with_cost_quota(500)
2028            .with_priority(200);
2029
2030        let result = a.meet(Budget::INFINITE);
2031        assert_eq!(result.deadline, a.deadline);
2032        assert_eq!(result.poll_quota, a.poll_quota);
2033        assert_eq!(result.cost_quota, a.cost_quota);
2034    }
2035
2036    // -- Lemma 5: ZERO is absorbing for quotas --
2037    // ∀ a. a.meet(ZERO).poll_quota == 0
2038    // ∀ a. a.meet(ZERO).cost_quota == Some(0)
2039    // ∀ a. a.meet(ZERO).deadline == Some(Time::ZERO)
2040
2041    #[test]
2042    fn lemma_zero_absorbing_quotas() {
2043        let a = Budget::new()
2044            .with_deadline(Time::from_secs(100))
2045            .with_poll_quota(1000)
2046            .with_cost_quota(10000);
2047
2048        let result = a.meet(Budget::ZERO);
2049        assert_eq!(result.deadline, Some(Time::ZERO));
2050        assert_eq!(result.poll_quota, 0);
2051        assert_eq!(result.cost_quota, Some(0));
2052    }
2053
2054    #[test]
2055    fn lemma_zero_absorbing_symmetric() {
2056        let a = Budget::new()
2057            .with_deadline(Time::from_secs(100))
2058            .with_poll_quota(1000)
2059            .with_cost_quota(10000);
2060
2061        let left = a.meet(Budget::ZERO);
2062        let right = Budget::ZERO.meet(a);
2063        assert_eq!(left.deadline, right.deadline);
2064        assert_eq!(left.poll_quota, right.poll_quota);
2065        assert_eq!(left.cost_quota, right.cost_quota);
2066    }
2067
2068    // -- Lemma 6: meet is monotone (narrowing) --
2069    // ∀ a b. a.meet(b).poll_quota ≤ a.poll_quota
2070    // ∀ a b. a.meet(b).poll_quota ≤ b.poll_quota
2071    // (and analogously for cost_quota, deadline)
2072
2073    #[test]
2074    fn lemma_meet_monotone_poll() {
2075        let a = Budget::new().with_poll_quota(100);
2076        let b = Budget::new().with_poll_quota(200);
2077        let result = a.meet(b);
2078        assert!(result.poll_quota <= a.poll_quota);
2079        assert!(result.poll_quota <= b.poll_quota);
2080    }
2081
2082    #[test]
2083    fn lemma_meet_monotone_cost() {
2084        let a = Budget::new().with_cost_quota(100);
2085        let b = Budget::new().with_cost_quota(200);
2086        let result = a.meet(b);
2087        assert!(result.cost_quota.unwrap() <= a.cost_quota.unwrap());
2088        assert!(result.cost_quota.unwrap() <= b.cost_quota.unwrap());
2089    }
2090
2091    #[test]
2092    fn lemma_meet_monotone_deadline() {
2093        let a = Budget::new().with_deadline(Time::from_secs(10));
2094        let b = Budget::new().with_deadline(Time::from_secs(20));
2095        let result = a.meet(b);
2096        assert!(result.deadline.unwrap() <= a.deadline.unwrap());
2097        assert!(result.deadline.unwrap() <= b.deadline.unwrap());
2098    }
2099
2100    // -- Lemma 7: consume_poll strictly decreases quota (well-founded) --
2101    // ∀ b. b.poll_quota > 0 → consume_poll(&mut b) = Some(old) ∧ b.poll_quota < old
2102
2103    #[test]
2104    fn lemma_consume_poll_strictly_decreasing() {
2105        let mut budget = Budget::new().with_poll_quota(5);
2106        let mut prev = budget.poll_quota;
2107        while budget.poll_quota > 0 {
2108            let old = budget.consume_poll().expect("quota > 0");
2109            assert_eq!(old, prev);
2110            assert!(budget.poll_quota < prev);
2111            prev = budget.poll_quota;
2112        }
2113        // Exhausted: consume returns None
2114        assert_eq!(budget.consume_poll(), None);
2115    }
2116
2117    // -- Lemma 8: consume_cost strictly decreases quota (well-founded) --
2118    // ∀ b cost. b.cost_quota = Some(q) ∧ q ≥ cost → consume_cost succeeds ∧ new_q < q
2119
2120    #[test]
2121    fn lemma_consume_cost_strictly_decreasing() {
2122        let mut budget = Budget::new().with_cost_quota(100);
2123        let initial = budget.cost_quota.unwrap();
2124        assert!(budget.consume_cost(30));
2125        let after = budget.cost_quota.unwrap();
2126        assert!(after < initial);
2127        assert_eq!(after, 70);
2128    }
2129
2130    // -- Lemma 9: sufficient budget implies progress --
2131    // ∀ b. ¬b.is_exhausted() → (consume_poll succeeds ∨ cost_quota = None ∨ cost_quota > 0)
2132
2133    #[test]
2134    fn lemma_sufficient_budget_enables_progress() {
2135        let mut budget = Budget::new().with_poll_quota(10).with_cost_quota(100);
2136        assert!(!budget.is_exhausted());
2137
2138        // Can make progress via poll
2139        assert!(budget.consume_poll().is_some());
2140        // Can make progress via cost
2141        assert!(budget.consume_cost(1));
2142    }
2143
2144    #[test]
2145    fn lemma_exhausted_blocks_progress() {
2146        let mut budget = Budget::new().with_poll_quota(0);
2147        assert!(budget.is_exhausted());
2148        assert!(budget.consume_poll().is_none());
2149    }
2150
2151    // -- Lemma 10: budget consumption terminates --
2152    // Starting from finite quota q, after exactly q calls to consume_poll,
2153    // the budget is exhausted.
2154
2155    #[test]
2156    fn lemma_poll_termination() {
2157        let q = 7u32;
2158        let mut budget = Budget::new().with_poll_quota(q);
2159        for _ in 0..q {
2160            assert!(!budget.is_exhausted());
2161            budget.consume_poll().expect("should succeed");
2162        }
2163        assert!(budget.is_exhausted());
2164        assert_eq!(budget.poll_quota, 0);
2165    }
2166
2167    #[test]
2168    fn lemma_cost_termination() {
2169        let mut budget = Budget::new().with_cost_quota(100);
2170        // Consume in chunks of 25
2171        for _ in 0..4 {
2172            assert!(budget.consume_cost(25));
2173        }
2174        assert_eq!(budget.cost_quota, Some(0));
2175        assert!(!budget.consume_cost(1));
2176    }
2177
2178    // -- Lemma 11: min-plus curve convolution associativity --
2179    // (a ⊗ b) ⊗ c == a ⊗ (b ⊗ c)  over a finite horizon
2180
2181    #[test]
2182    fn lemma_convolution_associative() {
2183        let a = MinPlusCurve::new(vec![0, 1, 3], 2).expect("valid");
2184        let b = MinPlusCurve::new(vec![0, 2, 4], 2).expect("valid");
2185        let c = MinPlusCurve::new(vec![0, 1, 2], 1).expect("valid");
2186        let h = 4;
2187
2188        let ab_c = a.min_plus_convolution(&b, h).min_plus_convolution(&c, h);
2189        let a_bc = a.min_plus_convolution(&b.min_plus_convolution(&c, h), h);
2190
2191        for t in 0..=h {
2192            assert_eq!(
2193                ab_c.value_at(t),
2194                a_bc.value_at(t),
2195                "associativity failed at t={t}"
2196            );
2197        }
2198    }
2199
2200    // -- Lemma 12: min-plus convolution commutativity --
2201    // a ⊗ b == b ⊗ a  over a finite horizon
2202
2203    #[test]
2204    fn lemma_convolution_commutative() {
2205        let a = MinPlusCurve::new(vec![0, 1, 3], 2).expect("valid");
2206        let b = MinPlusCurve::new(vec![0, 2, 4], 2).expect("valid");
2207        let h = 4;
2208
2209        let ab = a.min_plus_convolution(&b, h);
2210        let ba = b.min_plus_convolution(&a, h);
2211
2212        for t in 0..=h {
2213            assert_eq!(
2214                ab.value_at(t),
2215                ba.value_at(t),
2216                "commutativity failed at t={t}"
2217            );
2218        }
2219    }
2220
2221    // -- Lemma 13: backlog bound monotonicity --
2222    // Increasing arrival or decreasing service cannot reduce backlog.
2223
2224    #[test]
2225    fn lemma_backlog_monotone_arrival() {
2226        let service = MinPlusCurve::from_rate_latency(3, 2, 10);
2227        let small_arrival = MinPlusCurve::from_token_bucket(2, 1, 10);
2228        let large_arrival = MinPlusCurve::from_token_bucket(5, 2, 10);
2229
2230        let small_backlog = backlog_bound(&small_arrival, &service, 10);
2231        let large_backlog = backlog_bound(&large_arrival, &service, 10);
2232
2233        assert!(large_backlog >= small_backlog);
2234    }
2235
2236    // -- Lemma 14: delay bound monotonicity --
2237    // Higher arrival burst leads to equal or worse delay bound.
2238
2239    #[test]
2240    fn lemma_delay_monotone_arrival() {
2241        let service = MinPlusCurve::from_rate_latency(3, 2, 10);
2242        let small_arrival = MinPlusCurve::from_token_bucket(2, 1, 10);
2243        let large_arrival = MinPlusCurve::from_token_bucket(5, 2, 10);
2244
2245        let small_delay = delay_bound(&small_arrival, &service, 10, 20).unwrap_or(0);
2246        let large_delay = delay_bound(&large_arrival, &service, 10, 20).unwrap_or(0);
2247
2248        assert!(large_delay >= small_delay);
2249    }
2250
2251    // -- Lemma 15: convolution tail rate is min of input rates --
2252    // For min-plus convolution: (f ⊗ g)(t) = inf_s [f(s) + g(t-s)]
2253    // the asymptotic growth rate is min(r_f, r_g), not r_f + r_g.
2254
2255    #[test]
2256    fn lemma_convolution_tail_rate_is_min() {
2257        let slow = MinPlusCurve::new(vec![0, 1], 1).expect("valid");
2258        let fast = MinPlusCurve::new(vec![0, 3], 3).expect("valid");
2259        let conv = slow.min_plus_convolution(&fast, 10);
2260
2261        // Tail rate must be min(1, 3) = 1
2262        assert_eq!(conv.tail_rate(), 1);
2263
2264        // Verify beyond-horizon extrapolation matches the brute-force minimum
2265        // at a point well past the computed samples.
2266        let t = 20;
2267        let mut brute = u64::MAX;
2268        for s in 0..=t {
2269            brute = brute.min(slow.value_at(s).saturating_add(fast.value_at(t - s)));
2270        }
2271        assert_eq!(conv.value_at(t), brute);
2272    }
2273
2274    // ── derive-trait coverage (wave 74) ──────────────────────────────────
2275
2276    #[test]
2277    fn budget_clone_copy() {
2278        let b = Budget::new().with_poll_quota(42);
2279        let b2 = b; // Copy
2280        let b3 = b;
2281        assert_eq!(b, b2);
2282        assert_eq!(b2, b3);
2283    }
2284
2285    #[test]
2286    fn curve_error_debug_clone_eq() {
2287        let e1 = CurveError::EmptySamples;
2288        let e2 = e1.clone();
2289        assert_eq!(e1, e2);
2290
2291        let e3 = CurveError::NonMonotone {
2292            index: 2,
2293            prev: 10,
2294            next: 5,
2295        };
2296        let e4 = e3.clone();
2297        assert_eq!(e3, e4);
2298        assert_ne!(e1, e3);
2299        let dbg = format!("{e3:?}");
2300        assert!(dbg.contains("NonMonotone"));
2301    }
2302
2303    #[test]
2304    fn min_plus_curve_debug_clone_eq() {
2305        let c1 = MinPlusCurve::new(vec![0, 1, 2], 1).unwrap();
2306        let c2 = c1.clone();
2307        assert_eq!(c1, c2);
2308        let dbg = format!("{c1:?}");
2309        assert!(dbg.contains("MinPlusCurve"));
2310    }
2311
2312    #[test]
2313    fn curve_budget_debug_clone_eq() {
2314        let arrival = MinPlusCurve::new(vec![0, 2, 4], 2).unwrap();
2315        let service = MinPlusCurve::new(vec![0, 3, 6], 3).unwrap();
2316        let cb = CurveBudget { arrival, service };
2317        let cb2 = cb.clone();
2318        assert_eq!(cb, cb2);
2319        let dbg = format!("{cb:?}");
2320        assert!(dbg.contains("CurveBudget"));
2321    }
2322
2323    #[test]
2324    fn budget_event_snapshot_scrubbed() {
2325        let mut budget = Budget::new()
2326            .with_deadline(Time::from_secs(12))
2327            .with_poll_quota(3)
2328            .with_cost_quota(40)
2329            .with_priority(180);
2330
2331        let before = budget;
2332        let poll_before = budget.consume_poll();
2333        let after_poll = budget;
2334        let cost_ok = budget.consume_cost(15);
2335        let after_cost = budget;
2336
2337        insta::assert_json_snapshot!(
2338            "budget_event_scrubbed",
2339            json!({
2340                "events": [
2341                    {
2342                        "event": "created",
2343                        "deadline": scrub_budget_event(before.deadline),
2344                        "poll_quota": before.poll_quota,
2345                        "cost_quota": before.cost_quota,
2346                        "priority": before.priority,
2347                    },
2348                    {
2349                        "event": "consume_poll",
2350                        "returned": poll_before,
2351                        "poll_quota_after": after_poll.poll_quota,
2352                    },
2353                    {
2354                        "event": "consume_cost",
2355                        "accepted": cost_ok,
2356                        "cost_quota_after": after_cost.cost_quota,
2357                        "exhausted": after_cost.is_exhausted(),
2358                    }
2359                ]
2360            })
2361        );
2362    }
2363}