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//! ## Example: Request Timeout Middleware
44//!
45//! ```ignore
46//! use asupersync::{Budget, Cx, Outcome, Time};
47//! use std::time::Duration;
48//!
49//! // Server configuration
50//! struct ServerConfig {
51//!     request_timeout: Duration,
52//! }
53//!
54//! // Middleware creates budget from config
55//! async fn timeout_middleware<B>(
56//!     req: Request<B>,
57//!     next: Next<B>,
58//!     config: &ServerConfig,
59//! ) -> Outcome<Response, TimeoutError> {
60//!     // Convert wall-clock timeout to lab-compatible Time
61//!     let deadline = Time::from_secs(config.request_timeout.as_secs());
62//!     let budget = Budget::new().with_deadline(deadline);
63//!
64//!     // Get or create Cx, attach budget
65//!     let cx = req.extensions()
66//!         .get::<Cx>()
67//!         .cloned()
68//!         .unwrap_or_else(Cx::new);
69//!     let cx = cx.with_budget(budget);
70//!
71//!     // All downstream operations now respect the timeout
72//!     match next.run_with_cx(req, &cx).await {
73//!         Outcome::Cancelled(reason) if reason.is_deadline() => {
74//!             Outcome::Err(TimeoutError::RequestTimeout)
75//!         }
76//!         other => other,
77//!     }
78//! }
79//! ```
80//!
81//! ## Budget Propagation Through Regions
82//!
83//! Budget flows through the region tree, with each nested region inheriting
84//! and potentially tightening the parent's constraints:
85//!
86//! ```text
87//! Request Region (budget: 30s deadline)
88//! ├── DB Query Region
89//! │   └── Inherits 30s, operation takes 5s
90//! │       Budget remaining: 25s
91//! ├── External API Call
92//! │   └── Has own 10s timeout, meets with parent: min(25s, 10s) = 10s effective
93//! │       Budget remaining: ~15s after completion
94//! └── Response Serialization
95//!     └── Uses remaining ~15s budget
96//! ```
97//!
98//! ## Exhaustion Behavior
99//!
100//! When a budget is exhausted:
101//!
102//! | Resource | Trigger | Result |
103//! |----------|---------|--------|
104//! | Deadline | `now >= deadline` | `Outcome::Cancelled(CancelReason::deadline())` |
105//! | Poll quota | `poll_quota == 0` | `Outcome::Cancelled(CancelReason::budget())` |
106//! | Cost quota | `cost_quota == Some(0)` | `Outcome::Cancelled(CancelReason::budget())` |
107//!
108//! The runtime checks these conditions at scheduling points and propagates
109//! cancellation through the region tree.
110//!
111//! # Creating Budgets
112//!
113//! ```
114//! # use asupersync::Budget;
115//! # use asupersync::types::id::Time;
116//! // Unlimited budget (default)
117//! let unlimited = Budget::unlimited();
118//!
119//! // With specific deadline
120//! let timed = Budget::with_deadline_secs(30);
121//!
122//! // Builder pattern for multiple constraints
123//! let complex = Budget::new()
124//!     .with_deadline(Time::from_secs(30))
125//!     .with_poll_quota(1000)
126//!     .with_cost_quota(10_000)
127//!     .with_priority(200);
128//! ```
129
130use super::id::Time;
131use crate::tracing_compat::{info, trace};
132use core::fmt;
133use std::time::Duration;
134
135/// A budget constraining resource usage for a task or region.
136///
137/// Budgets form a product semiring for combination:
138/// - Deadlines/quotas use min (tighter wins)
139/// - Priority uses max (higher urgency wins)
140#[derive(Clone, Copy, PartialEq, Eq)]
141pub struct Budget {
142    /// Absolute deadline by which work must complete.
143    pub deadline: Option<Time>,
144    /// Maximum number of poll operations.
145    pub poll_quota: u32,
146    /// Abstract cost quota (for advanced scheduling).
147    pub cost_quota: Option<u64>,
148    /// Scheduling priority (0 = lowest, 255 = highest).
149    pub priority: u8,
150}
151
152impl Budget {
153    /// A budget with no constraints (infinite resources).
154    pub const INFINITE: Self = Self {
155        deadline: None,
156        poll_quota: u32::MAX,
157        cost_quota: None,
158        priority: 0,
159    };
160
161    /// A budget with zero resources (nothing allowed).
162    pub const ZERO: Self = Self {
163        deadline: Some(Time::ZERO),
164        poll_quota: 0,
165        cost_quota: Some(0),
166        priority: 255,
167    };
168
169    /// A minimal budget for cleanup operations.
170    ///
171    /// This provides a small poll quota (100 polls) for cleanup and finalizer code
172    /// to run, but no deadline or cost constraints. Used when requesting cancellation
173    /// to allow tasks a bounded cleanup phase.
174    pub const MINIMAL: Self = Self {
175        deadline: None,
176        poll_quota: 100,
177        cost_quota: None,
178        priority: 128,
179    };
180
181    /// Creates a new budget with default values (priority 128, unlimited quotas).
182    #[inline]
183    #[must_use]
184    pub const fn new() -> Self {
185        Self {
186            deadline: None,
187            poll_quota: u32::MAX,
188            cost_quota: None,
189            priority: 128,
190        }
191    }
192
193    /// Creates an unlimited budget (alias for [`INFINITE`](Self::INFINITE)).
194    ///
195    /// This is the identity element for the meet operation.
196    ///
197    /// # Example
198    ///
199    /// ```
200    /// # use asupersync::Budget;
201    /// let budget = Budget::unlimited();
202    /// assert!(!budget.is_exhausted());
203    /// ```
204    #[inline]
205    #[must_use]
206    pub const fn unlimited() -> Self {
207        Self::INFINITE
208    }
209
210    /// Creates a budget with only a deadline constraint (in seconds).
211    ///
212    /// This is a convenience constructor for HTTP timeout scenarios.
213    ///
214    /// # Example
215    ///
216    /// ```
217    /// # use asupersync::Budget;
218    /// # use asupersync::types::id::Time;
219    /// let budget = Budget::with_deadline_secs(30);
220    /// assert_eq!(budget.deadline, Some(Time::from_secs(30)));
221    /// ```
222    #[inline]
223    #[must_use]
224    pub const fn with_deadline_secs(secs: u64) -> Self {
225        Self {
226            deadline: Some(Time::from_secs(secs)),
227            poll_quota: u32::MAX,
228            cost_quota: None,
229            priority: 128,
230        }
231    }
232
233    /// Creates a budget with only a deadline constraint (in nanoseconds).
234    ///
235    /// # Example
236    ///
237    /// ```
238    /// # use asupersync::Budget;
239    /// # use asupersync::types::id::Time;
240    /// let budget = Budget::with_deadline_ns(30_000_000_000); // 30 seconds
241    /// assert_eq!(budget.deadline, Some(Time::from_nanos(30_000_000_000)));
242    /// ```
243    #[inline]
244    #[must_use]
245    pub const fn with_deadline_ns(nanos: u64) -> Self {
246        Self {
247            deadline: Some(Time::from_nanos(nanos)),
248            poll_quota: u32::MAX,
249            cost_quota: None,
250            priority: 128,
251        }
252    }
253
254    /// Sets the deadline.
255    #[inline]
256    #[must_use]
257    pub const fn with_deadline(mut self, deadline: Time) -> Self {
258        self.deadline = Some(deadline);
259        self
260    }
261
262    /// Sets the poll quota.
263    #[inline]
264    #[must_use]
265    pub const fn with_poll_quota(mut self, quota: u32) -> Self {
266        self.poll_quota = quota;
267        self
268    }
269
270    /// Sets the cost quota.
271    #[inline]
272    #[must_use]
273    pub const fn with_cost_quota(mut self, quota: u64) -> Self {
274        self.cost_quota = Some(quota);
275        self
276    }
277
278    /// Sets the priority.
279    #[inline]
280    #[must_use]
281    pub const fn with_priority(mut self, priority: u8) -> Self {
282        self.priority = priority;
283        self
284    }
285
286    /// Returns true if the budget has been exhausted.
287    ///
288    /// This checks only poll and cost quotas, not deadline (which requires current time).
289    #[inline]
290    #[must_use]
291    pub const fn is_exhausted(&self) -> bool {
292        self.poll_quota == 0 || matches!(self.cost_quota, Some(0))
293    }
294
295    /// Returns true if the deadline has passed.
296    #[inline]
297    #[must_use]
298    pub fn is_past_deadline(&self, now: Time) -> bool {
299        self.deadline.is_some_and(|d| now >= d)
300    }
301
302    /// Decrements the poll quota by one, returning the old value.
303    ///
304    /// Returns `None` if already at zero.
305    #[inline]
306    pub fn consume_poll(&mut self) -> Option<u32> {
307        if self.poll_quota > 0 {
308            let old = self.poll_quota;
309            self.poll_quota -= 1;
310            trace!(
311                polls_remaining = self.poll_quota,
312                polls_consumed = 1,
313                "budget poll consumed"
314            );
315            if self.poll_quota == 0 {
316                info!(
317                    exhausted_resource = "polls",
318                    final_quota = 0,
319                    overage_amount = 0,
320                    "budget poll quota exhausted"
321                );
322            }
323            Some(old)
324        } else {
325            trace!(
326                polls_remaining = 0,
327                "budget poll consume failed: already exhausted"
328            );
329            None
330        }
331    }
332
333    /// Combines two budgets using product semiring semantics.
334    ///
335    /// - Deadlines: min (earlier wins)
336    /// - Quotas: min (tighter wins)
337    /// - Priority: max (higher urgency wins)
338    ///
339    /// This is also known as the "meet" operation (∧) in lattice terminology.
340    /// See also: [`meet`](Self::meet).
341    ///
342    /// # Example
343    ///
344    /// ```
345    /// # use asupersync::Budget;
346    /// # use asupersync::types::id::Time;
347    /// let outer = Budget::new()
348    ///     .with_deadline(Time::from_secs(30))
349    ///     .with_poll_quota(1000);
350    ///
351    /// let inner = Budget::new()
352    ///     .with_deadline(Time::from_secs(10))  // tighter
353    ///     .with_poll_quota(5000);              // looser
354    ///
355    /// let combined = outer.combine(inner);
356    /// assert_eq!(combined.deadline, Some(Time::from_secs(10))); // min
357    /// assert_eq!(combined.poll_quota, 1000);                    // min
358    /// ```
359    #[inline]
360    #[must_use]
361    pub fn combine(self, other: Self) -> Self {
362        let combined = Self {
363            deadline: match (self.deadline, other.deadline) {
364                (Some(a), Some(b)) => Some(if a < b { a } else { b }),
365                (Some(a), None) => Some(a),
366                (None, Some(b)) => Some(b),
367                (None, None) => None,
368            },
369            poll_quota: self.poll_quota.min(other.poll_quota),
370            cost_quota: match (self.cost_quota, other.cost_quota) {
371                (Some(a), Some(b)) => Some(a.min(b)),
372                (Some(a), None) => Some(a),
373                (None, Some(b)) => Some(b),
374                (None, None) => None,
375            },
376            priority: self.priority.max(other.priority),
377        };
378
379        // Trace when budget is tightened (any constraint becomes stricter)
380        // For deadline comparison, None means "no constraint" (least restrictive),
381        // so tightening occurs only when a finite deadline replaces None, or when
382        // one finite deadline is earlier than another.
383        let deadline_tightened = match (combined.deadline, self.deadline, other.deadline) {
384            (Some(c), Some(s), _) if c < s => true,
385            (Some(c), _, Some(o)) if c < o => true,
386            (Some(_), None, _) | (Some(_), _, None) => true,
387            _ => false,
388        };
389        let poll_tightened =
390            combined.poll_quota < self.poll_quota || combined.poll_quota < other.poll_quota;
391        let cost_tightened = match (combined.cost_quota, self.cost_quota, other.cost_quota) {
392            (Some(c), Some(s), _) if c < s => true,
393            (Some(c), _, Some(o)) if c < o => true,
394            (Some(_), None, _) | (Some(_), _, None) => true,
395            _ => false,
396        };
397        let priority_tightened =
398            combined.priority > self.priority || combined.priority > other.priority;
399
400        if deadline_tightened || poll_tightened || cost_tightened || priority_tightened {
401            trace!(
402                deadline_tightened,
403                poll_tightened,
404                cost_tightened,
405                self_deadline = ?self.deadline,
406                other_deadline = ?other.deadline,
407                combined_deadline = ?combined.deadline,
408                self_poll_quota = self.poll_quota,
409                other_poll_quota = other.poll_quota,
410                combined_poll_quota = combined.poll_quota,
411                self_cost_quota = ?self.cost_quota,
412                other_cost_quota = ?other.cost_quota,
413                combined_cost_quota = ?combined.cost_quota,
414                self_priority = self.priority,
415                other_priority = other.priority,
416                combined_priority = combined.priority,
417                "budget combined (tightened)"
418            );
419        }
420
421        combined
422    }
423
424    /// Meet operation (∧) - alias for [`combine`](Self::combine).
425    ///
426    /// Computes the tightest constraints from two budgets. This is the
427    /// fundamental operation for nesting budget scopes.
428    ///
429    /// # Example
430    ///
431    /// ```
432    /// # use asupersync::Budget;
433    /// # use asupersync::types::id::Time;
434    /// let parent = Budget::with_deadline_secs(30);
435    /// let child = Budget::with_deadline_secs(10);
436    ///
437    /// // Child deadline is tighter, so it wins
438    /// let effective = parent.meet(child);
439    /// assert_eq!(effective.deadline, Some(Time::from_secs(10)));
440    /// ```
441    #[inline]
442    #[must_use]
443    pub fn meet(self, other: Self) -> Self {
444        self.combine(other)
445    }
446
447    /// Consumes cost quota, returning `true` if successful.
448    ///
449    /// Returns `false` (and does not modify quota) if there isn't enough
450    /// remaining cost quota. If no cost quota is set, always succeeds.
451    ///
452    /// # Example
453    ///
454    /// ```
455    /// # use asupersync::Budget;
456    /// let mut budget = Budget::new().with_cost_quota(100);
457    ///
458    /// assert!(budget.consume_cost(30));   // 70 remaining
459    /// assert!(budget.consume_cost(70));   // 0 remaining
460    /// assert!(!budget.consume_cost(1));   // fails, quota exhausted
461    /// ```
462    #[allow(clippy::used_underscore_binding)]
463    #[inline]
464    pub fn consume_cost(&mut self, cost: u64) -> bool {
465        match self.cost_quota {
466            None => {
467                trace!(
468                    cost_consumed = cost,
469                    cost_remaining = "unlimited",
470                    "budget cost consumed (unlimited)"
471                );
472                true // No quota means unlimited
473            }
474            Some(remaining) if remaining >= cost => {
475                let new_remaining = remaining - cost;
476                self.cost_quota = Some(new_remaining);
477                trace!(
478                    cost_consumed = cost,
479                    cost_remaining = new_remaining,
480                    "budget cost consumed"
481                );
482                if new_remaining == 0 {
483                    info!(
484                        exhausted_resource = "cost",
485                        final_quota = 0,
486                        overage_amount = 0,
487                        "budget cost quota exhausted"
488                    );
489                }
490                true
491            }
492            Some(remaining) => {
493                #[cfg(not(feature = "tracing-integration"))]
494                let _ = remaining;
495                trace!(
496                    cost_requested = cost,
497                    cost_remaining = remaining,
498                    "budget cost consume failed: insufficient quota"
499                );
500                false
501            }
502        }
503    }
504
505    /// Returns the remaining time until the deadline, if any.
506    ///
507    /// Returns `None` if there is no deadline or if the deadline has passed.
508    ///
509    /// # Example
510    ///
511    /// ```
512    /// # use asupersync::Budget;
513    /// # use asupersync::types::id::Time;
514    /// # use std::time::Duration;
515    /// let budget = Budget::with_deadline_secs(30);
516    /// let now = Time::from_secs(10);
517    ///
518    /// let remaining = budget.remaining_time(now);
519    /// assert_eq!(remaining, Some(Duration::from_secs(20)));
520    /// ```
521    #[inline]
522    #[must_use]
523    pub fn remaining_time(&self, now: Time) -> Option<Duration> {
524        self.deadline.and_then(|d| {
525            if now < d {
526                Some(Duration::from_nanos(
527                    d.as_nanos().saturating_sub(now.as_nanos()),
528                ))
529            } else {
530                None
531            }
532        })
533    }
534
535    /// Returns the remaining poll quota.
536    ///
537    /// Returns the current poll quota value. A value of `u32::MAX` indicates
538    /// effectively unlimited polls.
539    ///
540    /// # Example
541    ///
542    /// ```
543    /// # use asupersync::Budget;
544    /// let budget = Budget::new().with_poll_quota(100);
545    /// assert_eq!(budget.remaining_polls(), 100);
546    /// ```
547    #[inline]
548    #[must_use]
549    pub const fn remaining_polls(&self) -> u32 {
550        self.poll_quota
551    }
552
553    /// Returns the remaining cost quota, if any.
554    ///
555    /// Returns `None` if no cost quota is set (unlimited).
556    ///
557    /// # Example
558    ///
559    /// ```
560    /// # use asupersync::Budget;
561    /// let budget = Budget::new().with_cost_quota(1000);
562    /// assert_eq!(budget.remaining_cost(), Some(1000));
563    ///
564    /// let unlimited = Budget::unlimited();
565    /// assert_eq!(unlimited.remaining_cost(), None);
566    /// ```
567    #[inline]
568    #[must_use]
569    pub const fn remaining_cost(&self) -> Option<u64> {
570        self.cost_quota
571    }
572
573    /// Converts the deadline to a timeout duration from the given time.
574    ///
575    /// Returns the same value as [`remaining_time`](Self::remaining_time).
576    /// This method is provided for API compatibility with timeout-based systems.
577    ///
578    /// # Example
579    ///
580    /// ```
581    /// # use asupersync::Budget;
582    /// # use asupersync::types::id::Time;
583    /// # use std::time::Duration;
584    /// let budget = Budget::with_deadline_secs(30);
585    /// let now = Time::from_secs(5);
586    ///
587    /// // 25 seconds remaining
588    /// let timeout = budget.to_timeout(now);
589    /// assert_eq!(timeout, Some(Duration::from_secs(25)));
590    /// ```
591    #[inline]
592    #[must_use]
593    pub fn to_timeout(&self, now: Time) -> Option<Duration> {
594        self.remaining_time(now)
595    }
596}
597
598impl Default for Budget {
599    fn default() -> Self {
600        Self::new()
601    }
602}
603
604impl fmt::Debug for Budget {
605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606        let mut d = f.debug_struct("Budget");
607        if let Some(deadline) = self.deadline {
608            d.field("deadline", &deadline);
609        }
610        if self.poll_quota < u32::MAX {
611            d.field("poll_quota", &self.poll_quota);
612        }
613        if let Some(cost) = self.cost_quota {
614            d.field("cost_quota", &cost);
615        }
616        d.field("priority", &self.priority);
617        d.finish()
618    }
619}
620
621// ============================================================================
622// Min-Plus Network Calculus Curves (Hard Bounds)
623// ============================================================================
624
625/// Errors returned when constructing a min-plus curve.
626#[derive(Debug, Clone, PartialEq, Eq)]
627pub enum CurveError {
628    /// Curve samples must not be empty.
629    EmptySamples,
630    /// Curve samples must be nondecreasing.
631    NonMonotone {
632        /// Index where monotonicity was violated.
633        index: usize,
634        /// Previous sample value.
635        prev: u64,
636        /// Next sample value.
637        next: u64,
638    },
639}
640
641/// A discrete min-plus curve with a linear tail.
642///
643/// Curves are defined for nonnegative integer time `t` with:
644/// - `samples[t]` for `t <= horizon`
645/// - `samples[horizon] + tail_rate * (t - horizon)` for `t > horizon`
646///
647/// Samples must be nondecreasing.
648#[derive(Debug, Clone, PartialEq, Eq)]
649pub struct MinPlusCurve {
650    samples: Vec<u64>,
651    tail_rate: u64,
652}
653
654impl MinPlusCurve {
655    /// Creates a curve from samples and a tail rate.
656    ///
657    /// # Errors
658    /// Returns [`CurveError::EmptySamples`] if `samples` is empty, or
659    /// [`CurveError::NonMonotone`] if samples are not nondecreasing.
660    #[inline]
661    pub fn new(samples: Vec<u64>, tail_rate: u64) -> Result<Self, CurveError> {
662        if samples.is_empty() {
663            return Err(CurveError::EmptySamples);
664        }
665
666        for idx in 1..samples.len() {
667            if samples[idx] < samples[idx - 1] {
668                return Err(CurveError::NonMonotone {
669                    index: idx,
670                    prev: samples[idx - 1],
671                    next: samples[idx],
672                });
673            }
674        }
675
676        Ok(Self { samples, tail_rate })
677    }
678
679    /// Creates a curve with a flat tail from samples.
680    ///
681    /// # Errors
682    /// Returns an error if samples are empty or not nondecreasing.
683    #[inline]
684    pub fn from_samples(samples: Vec<u64>) -> Result<Self, CurveError> {
685        Self::new(samples, 0)
686    }
687
688    /// Creates a token-bucket arrival curve `burst + rate * t`.
689    #[inline]
690    #[must_use]
691    pub fn from_token_bucket(burst: u64, rate: u64, horizon: usize) -> Self {
692        let mut samples = Vec::with_capacity(horizon.saturating_add(1));
693        for t in 0..=horizon {
694            let inc = rate.saturating_mul(t as u64);
695            samples.push(burst.saturating_add(inc));
696        }
697        Self {
698            samples,
699            tail_rate: rate,
700        }
701    }
702
703    /// Creates a rate-latency service curve `max(0, rate * (t - latency))`.
704    #[inline]
705    #[must_use]
706    pub fn from_rate_latency(rate: u64, latency: usize, horizon: usize) -> Self {
707        let mut samples = Vec::with_capacity(horizon.saturating_add(1));
708        for t in 0..=horizon {
709            if t <= latency {
710                samples.push(0);
711            } else {
712                let dt = (t - latency) as u64;
713                samples.push(rate.saturating_mul(dt));
714            }
715        }
716        Self {
717            samples,
718            tail_rate: rate,
719        }
720    }
721
722    /// Returns the discrete horizon (last sample index).
723    #[must_use]
724    #[inline]
725    pub fn horizon(&self) -> usize {
726        self.samples.len().saturating_sub(1)
727    }
728
729    /// Returns the tail rate used for extrapolation beyond the horizon.
730    #[must_use]
731    #[inline]
732    pub fn tail_rate(&self) -> u64 {
733        self.tail_rate
734    }
735
736    /// Returns the curve value at integer time `t`.
737    #[must_use]
738    #[inline]
739    pub fn value_at(&self, t: usize) -> u64 {
740        let horizon = self.horizon();
741        if t <= horizon {
742            self.samples[t]
743        } else {
744            let extra = (t - horizon) as u64;
745            self.samples[horizon].saturating_add(self.tail_rate.saturating_mul(extra))
746        }
747    }
748
749    /// Returns the underlying samples.
750    #[must_use]
751    #[inline]
752    pub fn samples(&self) -> &[u64] {
753        &self.samples
754    }
755
756    /// Computes the min-plus convolution `(self ⊗ other)` over a horizon.
757    ///
758    /// This is O(horizon^2) and intended for small horizons/demonstrations.
759    #[inline]
760    #[must_use]
761    pub fn min_plus_convolution(&self, other: &Self, horizon: usize) -> Self {
762        let mut samples = Vec::with_capacity(horizon.saturating_add(1));
763        for t in 0..=horizon {
764            let mut best = u64::MAX;
765            for s in 0..=t {
766                let a = self.value_at(s);
767                let b = other.value_at(t - s);
768                let sum = a.saturating_add(b);
769                if sum < best {
770                    best = sum;
771                }
772            }
773            samples.push(best);
774        }
775
776        Self {
777            samples,
778            tail_rate: self.tail_rate.min(other.tail_rate),
779        }
780    }
781}
782
783/// Arrival/service curve pair for admission control hard bounds.
784#[derive(Debug, Clone, PartialEq, Eq)]
785pub struct CurveBudget {
786    /// Arrival curve (demand).
787    pub arrival: MinPlusCurve,
788    /// Service curve (supply).
789    pub service: MinPlusCurve,
790}
791
792impl CurveBudget {
793    /// Computes the backlog bound `sup_t (arrival(t) - service(t))` over a horizon.
794    #[must_use]
795    #[inline]
796    pub fn backlog_bound(&self, horizon: usize) -> u64 {
797        backlog_bound(&self.arrival, &self.service, horizon)
798    }
799
800    /// Computes the delay bound over a horizon with a max delay search window.
801    ///
802    /// Returns `None` if no delay bound is found within `max_delay`.
803    #[must_use]
804    #[inline]
805    pub fn delay_bound(&self, horizon: usize, max_delay: usize) -> Option<usize> {
806        delay_bound(&self.arrival, &self.service, horizon, max_delay)
807    }
808}
809
810/// Computes the backlog bound `sup_t (arrival(t) - service(t))` over a horizon.
811#[inline]
812#[must_use]
813pub fn backlog_bound(arrival: &MinPlusCurve, service: &MinPlusCurve, horizon: usize) -> u64 {
814    let mut worst = 0;
815    for t in 0..=horizon {
816        let demand = arrival.value_at(t);
817        let supply = service.value_at(t);
818        let backlog = demand.saturating_sub(supply);
819        if backlog > worst {
820            worst = backlog;
821        }
822    }
823    worst
824}
825
826/// Computes a delay bound `d` such that `arrival(t) <= service(t + d)` for all `t`.
827///
828/// Returns `None` if no bound is found within `max_delay`.
829#[inline]
830#[must_use]
831pub fn delay_bound(
832    arrival: &MinPlusCurve,
833    service: &MinPlusCurve,
834    horizon: usize,
835    max_delay: usize,
836) -> Option<usize> {
837    let mut worst_delay = 0;
838    for t in 0..=horizon {
839        let demand = arrival.value_at(t);
840        let mut found = None;
841        for d in 0..=max_delay {
842            if service.value_at(t + d) >= demand {
843                found = Some(d);
844                break;
845            }
846        }
847        let delay = found?;
848        if delay > worst_delay {
849            worst_delay = delay;
850        }
851    }
852    Some(worst_delay)
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use serde_json::json;
859
860    fn scrub_budget_event(deadline: Option<Time>) -> &'static str {
861        if deadline.is_some() {
862            "[DEADLINE]"
863        } else {
864            "[NONE]"
865        }
866    }
867
868    // =========================================================================
869    // Constants Tests
870    // =========================================================================
871
872    #[test]
873    fn infinite_budget_values() {
874        let b = Budget::INFINITE;
875        assert_eq!(b.deadline, None);
876        assert_eq!(b.poll_quota, u32::MAX);
877        assert_eq!(b.cost_quota, None);
878        assert_eq!(b.priority, 0);
879    }
880
881    #[test]
882    fn zero_budget_values() {
883        let b = Budget::ZERO;
884        assert_eq!(b.deadline, Some(Time::ZERO));
885        assert_eq!(b.poll_quota, 0);
886        assert_eq!(b.cost_quota, Some(0));
887        assert_eq!(b.priority, 255);
888    }
889
890    #[test]
891    fn new_returns_default_priority() {
892        let b = Budget::new();
893        assert_eq!(b.priority, 128);
894        assert_ne!(b, Budget::INFINITE);
895    }
896
897    #[test]
898    fn default_returns_new() {
899        assert_eq!(Budget::default(), Budget::new());
900        assert_ne!(Budget::default(), Budget::INFINITE);
901    }
902
903    // =========================================================================
904    // Builder Methods Tests
905    // =========================================================================
906
907    #[test]
908    fn with_deadline_sets_deadline() {
909        let deadline = Time::from_secs(30);
910        let budget = Budget::new().with_deadline(deadline);
911        assert_eq!(budget.deadline, Some(deadline));
912    }
913
914    #[test]
915    fn with_poll_quota_sets_quota() {
916        let budget = Budget::new().with_poll_quota(42);
917        assert_eq!(budget.poll_quota, 42);
918    }
919
920    #[test]
921    fn with_cost_quota_sets_quota() {
922        let budget = Budget::new().with_cost_quota(1000);
923        assert_eq!(budget.cost_quota, Some(1000));
924    }
925
926    #[test]
927    fn with_priority_sets_priority() {
928        let budget = Budget::new().with_priority(255);
929        assert_eq!(budget.priority, 255);
930    }
931
932    #[test]
933    fn builder_chaining() {
934        let budget = Budget::new()
935            .with_deadline(Time::from_secs(10))
936            .with_poll_quota(100)
937            .with_cost_quota(5000)
938            .with_priority(200);
939
940        assert_eq!(budget.deadline, Some(Time::from_secs(10)));
941        assert_eq!(budget.poll_quota, 100);
942        assert_eq!(budget.cost_quota, Some(5000));
943        assert_eq!(budget.priority, 200);
944    }
945
946    // =========================================================================
947    // is_exhausted Tests
948    // =========================================================================
949
950    #[test]
951    fn is_exhausted_false_for_infinite() {
952        assert!(!Budget::INFINITE.is_exhausted());
953    }
954
955    #[test]
956    fn is_exhausted_true_for_zero() {
957        assert!(Budget::ZERO.is_exhausted());
958    }
959
960    #[test]
961    fn is_exhausted_when_poll_quota_zero() {
962        let budget = Budget::new().with_poll_quota(0);
963        assert!(budget.is_exhausted());
964    }
965
966    #[test]
967    fn is_exhausted_when_cost_quota_zero() {
968        let budget = Budget::new().with_cost_quota(0);
969        assert!(budget.is_exhausted());
970    }
971
972    #[test]
973    fn is_exhausted_false_with_resources() {
974        let budget = Budget::new().with_poll_quota(10).with_cost_quota(100);
975        assert!(!budget.is_exhausted());
976    }
977
978    // =========================================================================
979    // is_past_deadline Tests
980    // =========================================================================
981
982    #[test]
983    fn is_past_deadline_true_when_past() {
984        let budget = Budget::new().with_deadline(Time::from_secs(5));
985        let now = Time::from_secs(10);
986        assert!(budget.is_past_deadline(now));
987    }
988
989    #[test]
990    fn is_past_deadline_false_when_not_past() {
991        let budget = Budget::new().with_deadline(Time::from_secs(10));
992        let now = Time::from_secs(5);
993        assert!(!budget.is_past_deadline(now));
994    }
995
996    #[test]
997    fn is_past_deadline_true_at_exact_time() {
998        let deadline = Time::from_secs(5);
999        let budget = Budget::new().with_deadline(deadline);
1000        assert!(budget.is_past_deadline(deadline));
1001    }
1002
1003    #[test]
1004    fn is_past_deadline_false_when_no_deadline() {
1005        let budget = Budget::new();
1006        assert!(!budget.is_past_deadline(Time::from_secs(1_000_000)));
1007    }
1008
1009    // =========================================================================
1010    // consume_poll Tests
1011    // =========================================================================
1012
1013    #[test]
1014    fn consume_poll_decrements() {
1015        let mut budget = Budget::new().with_poll_quota(2);
1016
1017        assert_eq!(budget.consume_poll(), Some(2));
1018        assert_eq!(budget.poll_quota, 1);
1019
1020        assert_eq!(budget.consume_poll(), Some(1));
1021        assert_eq!(budget.poll_quota, 0);
1022
1023        assert_eq!(budget.consume_poll(), None);
1024        assert_eq!(budget.poll_quota, 0);
1025    }
1026
1027    #[test]
1028    fn consume_poll_returns_none_at_zero() {
1029        let mut budget = Budget::new().with_poll_quota(0);
1030        assert_eq!(budget.consume_poll(), None);
1031        assert_eq!(budget.poll_quota, 0);
1032    }
1033
1034    #[test]
1035    fn consume_poll_transitions_to_exhausted() {
1036        let mut budget = Budget::new().with_poll_quota(1);
1037        assert!(!budget.is_exhausted());
1038
1039        budget.consume_poll();
1040        assert!(budget.is_exhausted());
1041    }
1042
1043    // =========================================================================
1044    // Combine Tests (Product Semiring Semantics)
1045    // =========================================================================
1046
1047    #[test]
1048    fn combine_takes_tighter() {
1049        let a = Budget::new()
1050            .with_deadline(Time::from_secs(10))
1051            .with_poll_quota(100)
1052            .with_priority(50);
1053
1054        let b = Budget::new()
1055            .with_deadline(Time::from_secs(5))
1056            .with_poll_quota(200)
1057            .with_priority(100);
1058
1059        let combined = a.combine(b);
1060
1061        // Deadline: min
1062        assert_eq!(combined.deadline, Some(Time::from_secs(5)));
1063        // Poll quota: min
1064        assert_eq!(combined.poll_quota, 100);
1065        // Priority: max
1066        assert_eq!(combined.priority, 100);
1067    }
1068
1069    #[test]
1070    fn combine_deadline_none_with_some() {
1071        let a = Budget::new(); // No deadline
1072        let b = Budget::new().with_deadline(Time::from_secs(5));
1073
1074        // a.combine(b) should have b's deadline
1075        assert_eq!(a.combine(b).deadline, Some(Time::from_secs(5)));
1076        // b.combine(a) should also have b's deadline
1077        assert_eq!(b.combine(a).deadline, Some(Time::from_secs(5)));
1078    }
1079
1080    #[test]
1081    fn combine_deadline_none_with_none() {
1082        let a = Budget::new();
1083        let b = Budget::new();
1084        assert_eq!(a.combine(b).deadline, None);
1085    }
1086
1087    #[test]
1088    fn combine_cost_quota_none_with_some() {
1089        let a = Budget::new(); // cost_quota = None
1090        let b = Budget::new().with_cost_quota(100);
1091
1092        // Should take the defined quota
1093        assert_eq!(a.combine(b).cost_quota, Some(100));
1094        assert_eq!(b.combine(a).cost_quota, Some(100));
1095    }
1096
1097    #[test]
1098    fn combine_cost_quota_takes_min() {
1099        let a = Budget::new().with_cost_quota(50);
1100        let b = Budget::new().with_cost_quota(100);
1101
1102        assert_eq!(a.combine(b).cost_quota, Some(50));
1103        assert_eq!(b.combine(a).cost_quota, Some(50));
1104    }
1105
1106    #[test]
1107    fn combine_with_zero_absorbs() {
1108        let any_budget = Budget::new()
1109            .with_deadline(Time::from_secs(100))
1110            .with_poll_quota(1000)
1111            .with_cost_quota(10000)
1112            .with_priority(200);
1113
1114        let combined = any_budget.combine(Budget::ZERO);
1115
1116        // ZERO's deadline (Time::ZERO) is tighter
1117        assert_eq!(combined.deadline, Some(Time::ZERO));
1118        // ZERO's poll_quota (0) is tighter
1119        assert_eq!(combined.poll_quota, 0);
1120        // ZERO's cost_quota (Some(0)) is tighter
1121        assert_eq!(combined.cost_quota, Some(0));
1122        // Priority: max of 200 and 255 = 255
1123        assert_eq!(combined.priority, 255);
1124    }
1125
1126    #[test]
1127    fn combine_with_infinite_preserves() {
1128        let budget = Budget::new()
1129            .with_deadline(Time::from_secs(10))
1130            .with_poll_quota(100)
1131            .with_cost_quota(1000)
1132            .with_priority(50);
1133
1134        let combined = budget.combine(Budget::INFINITE);
1135
1136        // All values should be preserved (INFINITE doesn't constrain)
1137        assert_eq!(combined.deadline, Some(Time::from_secs(10)));
1138        assert_eq!(combined.poll_quota, 100);
1139        assert_eq!(combined.cost_quota, Some(1000));
1140        // Priority: max of 50 and 0 = 50
1141        assert_eq!(combined.priority, 50);
1142    }
1143
1144    // =========================================================================
1145    // Debug/Display Tests
1146    // =========================================================================
1147
1148    #[test]
1149    fn debug_shows_constrained_fields() {
1150        let budget = Budget::new()
1151            .with_deadline(Time::from_secs(10))
1152            .with_poll_quota(100)
1153            .with_cost_quota(500);
1154
1155        let debug = format!("{budget:?}");
1156
1157        // Debug should include constrained fields
1158        assert!(debug.contains("deadline"));
1159        assert!(debug.contains("poll_quota"));
1160        assert!(debug.contains("cost_quota"));
1161        assert!(debug.contains("priority"));
1162    }
1163
1164    #[test]
1165    fn debug_omits_unconstrained_fields() {
1166        let budget = Budget::INFINITE;
1167        let debug = format!("{budget:?}");
1168
1169        // INFINITE has no deadline, MAX poll_quota, and no cost_quota
1170        // Debug should omit deadline and poll_quota but include priority
1171        assert!(!debug.contains("deadline"));
1172        assert!(!debug.contains("poll_quota")); // u32::MAX is omitted
1173        assert!(debug.contains("priority"));
1174    }
1175
1176    // =========================================================================
1177    // Equality Tests
1178    // =========================================================================
1179
1180    #[test]
1181    fn equality_works() {
1182        let a = Budget::new()
1183            .with_deadline(Time::from_secs(10))
1184            .with_poll_quota(100);
1185
1186        let b = Budget::new()
1187            .with_deadline(Time::from_secs(10))
1188            .with_poll_quota(100);
1189
1190        assert_eq!(a, b);
1191    }
1192
1193    #[test]
1194    fn inequality_on_deadline() {
1195        let a = Budget::new().with_deadline(Time::from_secs(10));
1196        let b = Budget::new().with_deadline(Time::from_secs(20));
1197        assert_ne!(a, b);
1198    }
1199
1200    #[test]
1201    fn copy_semantics() {
1202        let a = Budget::new().with_poll_quota(100);
1203        let b = a; // Copy
1204        assert_eq!(a.poll_quota, b.poll_quota);
1205        // Modifying b doesn't affect a
1206        let mut c = a;
1207        c.poll_quota = 50;
1208        assert_eq!(a.poll_quota, 100);
1209        assert_eq!(c.poll_quota, 50);
1210    }
1211
1212    // =========================================================================
1213    // New Convenience Method Tests
1214    // =========================================================================
1215
1216    #[test]
1217    fn unlimited_returns_infinite() {
1218        assert_eq!(Budget::unlimited(), Budget::INFINITE);
1219    }
1220
1221    #[test]
1222    fn with_deadline_secs_constructor() {
1223        let budget = Budget::with_deadline_secs(30);
1224        assert_eq!(budget.deadline, Some(Time::from_secs(30)));
1225        assert_eq!(budget.poll_quota, u32::MAX);
1226        assert_eq!(budget.cost_quota, None);
1227        assert_eq!(budget.priority, 128);
1228    }
1229
1230    #[test]
1231    fn with_deadline_ns_constructor() {
1232        let budget = Budget::with_deadline_ns(30_000_000_000);
1233        assert_eq!(budget.deadline, Some(Time::from_nanos(30_000_000_000)));
1234    }
1235
1236    #[test]
1237    fn meet_is_alias_for_combine() {
1238        let a = Budget::new()
1239            .with_deadline(Time::from_secs(10))
1240            .with_poll_quota(100);
1241
1242        let b = Budget::new()
1243            .with_deadline(Time::from_secs(5))
1244            .with_poll_quota(200);
1245
1246        assert_eq!(a.meet(b), a.combine(b));
1247    }
1248
1249    // =========================================================================
1250    // consume_cost Tests
1251    // =========================================================================
1252
1253    #[test]
1254    fn consume_cost_basic() {
1255        let mut budget = Budget::new().with_cost_quota(100);
1256
1257        assert!(budget.consume_cost(30));
1258        assert_eq!(budget.cost_quota, Some(70));
1259
1260        assert!(budget.consume_cost(70));
1261        assert_eq!(budget.cost_quota, Some(0));
1262
1263        assert!(!budget.consume_cost(1));
1264        assert_eq!(budget.cost_quota, Some(0));
1265    }
1266
1267    #[test]
1268    fn consume_cost_unlimited() {
1269        let mut budget = Budget::new(); // No cost quota = unlimited
1270        assert!(budget.consume_cost(1_000_000));
1271        assert_eq!(budget.cost_quota, None);
1272    }
1273
1274    #[test]
1275    fn consume_cost_exact_amount() {
1276        let mut budget = Budget::new().with_cost_quota(50);
1277        assert!(budget.consume_cost(50));
1278        assert_eq!(budget.cost_quota, Some(0));
1279    }
1280
1281    #[test]
1282    fn consume_cost_transitions_to_exhausted() {
1283        let mut budget = Budget::new().with_cost_quota(10).with_poll_quota(u32::MAX);
1284        assert!(!budget.is_exhausted());
1285
1286        budget.consume_cost(10);
1287        assert!(budget.is_exhausted());
1288    }
1289
1290    // =========================================================================
1291    // Inspection Method Tests
1292    // =========================================================================
1293
1294    #[test]
1295    fn remaining_time_basic() {
1296        let budget = Budget::with_deadline_secs(30);
1297        let now = Time::from_secs(10);
1298
1299        let remaining = budget.remaining_time(now);
1300        assert_eq!(remaining, Some(Duration::from_secs(20)));
1301    }
1302
1303    #[test]
1304    fn remaining_time_no_deadline() {
1305        let budget = Budget::unlimited();
1306        assert_eq!(budget.remaining_time(Time::from_secs(1000)), None);
1307    }
1308
1309    #[test]
1310    fn remaining_time_past_deadline() {
1311        let budget = Budget::with_deadline_secs(10);
1312        assert_eq!(budget.remaining_time(Time::from_secs(15)), None);
1313    }
1314
1315    #[test]
1316    fn remaining_time_at_deadline() {
1317        let budget = Budget::with_deadline_secs(10);
1318        assert_eq!(budget.remaining_time(Time::from_secs(10)), None);
1319    }
1320
1321    #[test]
1322    fn remaining_polls_basic() {
1323        let budget = Budget::new().with_poll_quota(100);
1324        assert_eq!(budget.remaining_polls(), 100);
1325    }
1326
1327    #[test]
1328    fn remaining_polls_unlimited() {
1329        let budget = Budget::unlimited();
1330        assert_eq!(budget.remaining_polls(), u32::MAX);
1331    }
1332
1333    #[test]
1334    fn remaining_cost_basic() {
1335        let budget = Budget::new().with_cost_quota(1000);
1336        assert_eq!(budget.remaining_cost(), Some(1000));
1337    }
1338
1339    #[test]
1340    fn remaining_cost_unlimited() {
1341        let budget = Budget::unlimited();
1342        assert_eq!(budget.remaining_cost(), None);
1343    }
1344
1345    #[test]
1346    fn to_timeout_is_alias_for_remaining_time() {
1347        let budget = Budget::with_deadline_secs(30);
1348        let now = Time::from_secs(10);
1349
1350        assert_eq!(budget.to_timeout(now), budget.remaining_time(now));
1351    }
1352
1353    // =========================================================================
1354    // Min-Plus Network Calculus Tests
1355    // =========================================================================
1356
1357    #[test]
1358    fn min_plus_curve_validation_rejects_non_monotone() {
1359        let err = MinPlusCurve::new(vec![0, 2, 1], 0).expect_err("non-monotone samples");
1360        assert!(matches!(
1361            err,
1362            CurveError::NonMonotone {
1363                index: 2,
1364                prev: 2,
1365                next: 1
1366            }
1367        ));
1368    }
1369
1370    #[test]
1371    fn min_plus_convolution_basic() {
1372        let a = MinPlusCurve::new(vec![0, 1, 2], 1).expect("valid curve");
1373        let b = MinPlusCurve::new(vec![0, 0, 1], 1).expect("valid curve");
1374        let conv = a.min_plus_convolution(&b, 2);
1375        assert_eq!(conv.samples(), &[0, 0, 1]);
1376    }
1377
1378    #[test]
1379    fn min_plus_backlog_delay_demo() {
1380        let arrival = MinPlusCurve::from_token_bucket(5, 2, 10);
1381        let service = MinPlusCurve::from_rate_latency(3, 2, 10);
1382        let budget = CurveBudget { arrival, service };
1383
1384        let backlog = budget.backlog_bound(10);
1385        let delay = budget.delay_bound(10, 10);
1386
1387        assert_eq!(backlog, 9);
1388        assert_eq!(delay, Some(4));
1389    }
1390
1391    // =========================================================================
1392    // Budget Algebra Formal Lemmas (bd-hf5bz)
1393    //
1394    // These tests serve as mechanized proof artifacts for the budget algebra.
1395    // Each test corresponds to a named lemma that downstream proofs
1396    // (bd-3cq88, bd-2qmr4, bd-3fp4g, bd-ecp8u) can reference.
1397    //
1398    // Algebra summary:
1399    //   (Budget, meet, INFINITE) forms a bounded meet-semilattice where:
1400    //   - meet is the pointwise min on (deadline, poll_quota, cost_quota, priority).
1401    //   - INFINITE is the top element (identity for meet).
1402    //   - ZERO is the bottom element (absorbing for meet, modulo priority).
1403    //
1404    // Code mapping:
1405    //   meet        → Budget::meet / Budget::combine  (src/types/budget.rs)
1406    //   identity    → Budget::INFINITE
1407    //   absorbing   → Budget::ZERO
1408    //   consumption → Budget::consume_poll, Budget::consume_cost
1409    //
1410    // Min-plus / tropical structure:
1411    //   (DeadlineMicros, min, add, ∞, 0) forms a tropical semiring
1412    //   used in plan analysis for sequential/parallel budget composition.
1413    //   Code mapping → src/plan/analysis.rs :: DeadlineMicros
1414    //
1415    //   (MinPlusCurve, min_plus_convolution) forms a min-plus algebra
1416    //   for network calculus admission control.
1417    //   Code mapping → MinPlusCurve / CurveBudget (src/types/budget.rs)
1418    // =========================================================================
1419
1420    // -- Lemma 1: meet is commutative --
1421    // ∀ a b. a.meet(b) == b.meet(a)
1422
1423    #[test]
1424    fn lemma_meet_commutative() {
1425        let a = Budget::new()
1426            .with_deadline(Time::from_secs(10))
1427            .with_poll_quota(100)
1428            .with_cost_quota(500)
1429            .with_priority(50);
1430
1431        let b = Budget::new()
1432            .with_deadline(Time::from_secs(5))
1433            .with_poll_quota(200)
1434            .with_cost_quota(300)
1435            .with_priority(100);
1436
1437        assert_eq!(a.meet(b), b.meet(a));
1438    }
1439
1440    #[test]
1441    fn lemma_meet_commutative_with_none_deadline() {
1442        let a = Budget::new().with_poll_quota(100);
1443        let b = Budget::new()
1444            .with_deadline(Time::from_secs(5))
1445            .with_poll_quota(200);
1446        assert_eq!(a.meet(b), b.meet(a));
1447    }
1448
1449    #[test]
1450    fn lemma_meet_commutative_with_none_cost() {
1451        let a = Budget::new().with_cost_quota(100);
1452        let b = Budget::new(); // cost_quota = None
1453        assert_eq!(a.meet(b), b.meet(a));
1454    }
1455
1456    // -- Lemma 2: meet is associative --
1457    // ∀ a b c. a.meet(b.meet(c)) == a.meet(b).meet(c)
1458
1459    #[test]
1460    fn lemma_meet_associative() {
1461        let a = Budget::new()
1462            .with_deadline(Time::from_secs(10))
1463            .with_poll_quota(100)
1464            .with_cost_quota(500)
1465            .with_priority(50);
1466
1467        let b = Budget::new()
1468            .with_deadline(Time::from_secs(5))
1469            .with_poll_quota(200)
1470            .with_cost_quota(300)
1471            .with_priority(100);
1472
1473        let c = Budget::new()
1474            .with_deadline(Time::from_secs(8))
1475            .with_poll_quota(150)
1476            .with_cost_quota(400)
1477            .with_priority(75);
1478
1479        assert_eq!(a.meet(b.meet(c)), a.meet(b).meet(c));
1480    }
1481
1482    #[test]
1483    fn lemma_meet_associative_with_none_fields() {
1484        let a = Budget::new().with_deadline(Time::from_secs(10));
1485        let b = Budget::new().with_cost_quota(300);
1486        let c = Budget::new().with_poll_quota(50);
1487
1488        assert_eq!(a.meet(b.meet(c)), a.meet(b).meet(c));
1489    }
1490
1491    // -- Lemma 3: meet is idempotent --
1492    // ∀ a. a.meet(a) == a
1493
1494    #[test]
1495    fn lemma_meet_idempotent() {
1496        let a = Budget::new()
1497            .with_deadline(Time::from_secs(10))
1498            .with_poll_quota(100)
1499            .with_cost_quota(500)
1500            .with_priority(75);
1501        assert_eq!(a.meet(a), a);
1502    }
1503
1504    #[test]
1505    fn lemma_meet_idempotent_infinite() {
1506        assert_eq!(Budget::INFINITE.meet(Budget::INFINITE), Budget::INFINITE);
1507    }
1508
1509    #[test]
1510    fn lemma_meet_idempotent_zero() {
1511        assert_eq!(Budget::ZERO.meet(Budget::ZERO), Budget::ZERO);
1512    }
1513
1514    // -- Lemma 4: INFINITE is the identity for meet --
1515    // ∀ a. a.meet(INFINITE) == a
1516
1517    #[test]
1518    fn lemma_identity_left() {
1519        let a = Budget::new()
1520            .with_deadline(Time::from_secs(10))
1521            .with_poll_quota(100)
1522            .with_cost_quota(500)
1523            .with_priority(50);
1524
1525        // INFINITE has priority 0 (identity for max).
1526        // meet takes max priority.
1527        let result = Budget::INFINITE.meet(a);
1528        assert_eq!(result.deadline, a.deadline);
1529        assert_eq!(result.poll_quota, a.poll_quota);
1530        assert_eq!(result.cost_quota, a.cost_quota);
1531        assert_eq!(result.priority, a.priority);
1532    }
1533
1534    #[test]
1535    fn lemma_identity_right() {
1536        let a = Budget::new()
1537            .with_deadline(Time::from_secs(10))
1538            .with_poll_quota(100)
1539            .with_cost_quota(500)
1540            .with_priority(200);
1541
1542        let result = a.meet(Budget::INFINITE);
1543        assert_eq!(result.deadline, a.deadline);
1544        assert_eq!(result.poll_quota, a.poll_quota);
1545        assert_eq!(result.cost_quota, a.cost_quota);
1546    }
1547
1548    // -- Lemma 5: ZERO is absorbing for quotas --
1549    // ∀ a. a.meet(ZERO).poll_quota == 0
1550    // ∀ a. a.meet(ZERO).cost_quota == Some(0)
1551    // ∀ a. a.meet(ZERO).deadline == Some(Time::ZERO)
1552
1553    #[test]
1554    fn lemma_zero_absorbing_quotas() {
1555        let a = Budget::new()
1556            .with_deadline(Time::from_secs(100))
1557            .with_poll_quota(1000)
1558            .with_cost_quota(10000);
1559
1560        let result = a.meet(Budget::ZERO);
1561        assert_eq!(result.deadline, Some(Time::ZERO));
1562        assert_eq!(result.poll_quota, 0);
1563        assert_eq!(result.cost_quota, Some(0));
1564    }
1565
1566    #[test]
1567    fn lemma_zero_absorbing_symmetric() {
1568        let a = Budget::new()
1569            .with_deadline(Time::from_secs(100))
1570            .with_poll_quota(1000)
1571            .with_cost_quota(10000);
1572
1573        let left = a.meet(Budget::ZERO);
1574        let right = Budget::ZERO.meet(a);
1575        assert_eq!(left.deadline, right.deadline);
1576        assert_eq!(left.poll_quota, right.poll_quota);
1577        assert_eq!(left.cost_quota, right.cost_quota);
1578    }
1579
1580    // -- Lemma 6: meet is monotone (narrowing) --
1581    // ∀ a b. a.meet(b).poll_quota ≤ a.poll_quota
1582    // ∀ a b. a.meet(b).poll_quota ≤ b.poll_quota
1583    // (and analogously for cost_quota, deadline)
1584
1585    #[test]
1586    fn lemma_meet_monotone_poll() {
1587        let a = Budget::new().with_poll_quota(100);
1588        let b = Budget::new().with_poll_quota(200);
1589        let result = a.meet(b);
1590        assert!(result.poll_quota <= a.poll_quota);
1591        assert!(result.poll_quota <= b.poll_quota);
1592    }
1593
1594    #[test]
1595    fn lemma_meet_monotone_cost() {
1596        let a = Budget::new().with_cost_quota(100);
1597        let b = Budget::new().with_cost_quota(200);
1598        let result = a.meet(b);
1599        assert!(result.cost_quota.unwrap() <= a.cost_quota.unwrap());
1600        assert!(result.cost_quota.unwrap() <= b.cost_quota.unwrap());
1601    }
1602
1603    #[test]
1604    fn lemma_meet_monotone_deadline() {
1605        let a = Budget::new().with_deadline(Time::from_secs(10));
1606        let b = Budget::new().with_deadline(Time::from_secs(20));
1607        let result = a.meet(b);
1608        assert!(result.deadline.unwrap() <= a.deadline.unwrap());
1609        assert!(result.deadline.unwrap() <= b.deadline.unwrap());
1610    }
1611
1612    // -- Lemma 7: consume_poll strictly decreases quota (well-founded) --
1613    // ∀ b. b.poll_quota > 0 → consume_poll(&mut b) = Some(old) ∧ b.poll_quota < old
1614
1615    #[test]
1616    fn lemma_consume_poll_strictly_decreasing() {
1617        let mut budget = Budget::new().with_poll_quota(5);
1618        let mut prev = budget.poll_quota;
1619        while budget.poll_quota > 0 {
1620            let old = budget.consume_poll().expect("quota > 0");
1621            assert_eq!(old, prev);
1622            assert!(budget.poll_quota < prev);
1623            prev = budget.poll_quota;
1624        }
1625        // Exhausted: consume returns None
1626        assert_eq!(budget.consume_poll(), None);
1627    }
1628
1629    // -- Lemma 8: consume_cost strictly decreases quota (well-founded) --
1630    // ∀ b cost. b.cost_quota = Some(q) ∧ q ≥ cost → consume_cost succeeds ∧ new_q < q
1631
1632    #[test]
1633    fn lemma_consume_cost_strictly_decreasing() {
1634        let mut budget = Budget::new().with_cost_quota(100);
1635        let initial = budget.cost_quota.unwrap();
1636        assert!(budget.consume_cost(30));
1637        let after = budget.cost_quota.unwrap();
1638        assert!(after < initial);
1639        assert_eq!(after, 70);
1640    }
1641
1642    // -- Lemma 9: sufficient budget implies progress --
1643    // ∀ b. ¬b.is_exhausted() → (consume_poll succeeds ∨ cost_quota = None ∨ cost_quota > 0)
1644
1645    #[test]
1646    fn lemma_sufficient_budget_enables_progress() {
1647        let mut budget = Budget::new().with_poll_quota(10).with_cost_quota(100);
1648        assert!(!budget.is_exhausted());
1649
1650        // Can make progress via poll
1651        assert!(budget.consume_poll().is_some());
1652        // Can make progress via cost
1653        assert!(budget.consume_cost(1));
1654    }
1655
1656    #[test]
1657    fn lemma_exhausted_blocks_progress() {
1658        let mut budget = Budget::new().with_poll_quota(0);
1659        assert!(budget.is_exhausted());
1660        assert!(budget.consume_poll().is_none());
1661    }
1662
1663    // -- Lemma 10: budget consumption terminates --
1664    // Starting from finite quota q, after exactly q calls to consume_poll,
1665    // the budget is exhausted.
1666
1667    #[test]
1668    fn lemma_poll_termination() {
1669        let q = 7u32;
1670        let mut budget = Budget::new().with_poll_quota(q);
1671        for _ in 0..q {
1672            assert!(!budget.is_exhausted());
1673            budget.consume_poll().expect("should succeed");
1674        }
1675        assert!(budget.is_exhausted());
1676        assert_eq!(budget.poll_quota, 0);
1677    }
1678
1679    #[test]
1680    fn lemma_cost_termination() {
1681        let mut budget = Budget::new().with_cost_quota(100);
1682        // Consume in chunks of 25
1683        for _ in 0..4 {
1684            assert!(budget.consume_cost(25));
1685        }
1686        assert_eq!(budget.cost_quota, Some(0));
1687        assert!(!budget.consume_cost(1));
1688    }
1689
1690    // -- Lemma 11: min-plus curve convolution associativity --
1691    // (a ⊗ b) ⊗ c == a ⊗ (b ⊗ c)  over a finite horizon
1692
1693    #[test]
1694    fn lemma_convolution_associative() {
1695        let a = MinPlusCurve::new(vec![0, 1, 3], 2).expect("valid");
1696        let b = MinPlusCurve::new(vec![0, 2, 4], 2).expect("valid");
1697        let c = MinPlusCurve::new(vec![0, 1, 2], 1).expect("valid");
1698        let h = 4;
1699
1700        let ab_c = a.min_plus_convolution(&b, h).min_plus_convolution(&c, h);
1701        let a_bc = a.min_plus_convolution(&b.min_plus_convolution(&c, h), h);
1702
1703        for t in 0..=h {
1704            assert_eq!(
1705                ab_c.value_at(t),
1706                a_bc.value_at(t),
1707                "associativity failed at t={t}"
1708            );
1709        }
1710    }
1711
1712    // -- Lemma 12: min-plus convolution commutativity --
1713    // a ⊗ b == b ⊗ a  over a finite horizon
1714
1715    #[test]
1716    fn lemma_convolution_commutative() {
1717        let a = MinPlusCurve::new(vec![0, 1, 3], 2).expect("valid");
1718        let b = MinPlusCurve::new(vec![0, 2, 4], 2).expect("valid");
1719        let h = 4;
1720
1721        let ab = a.min_plus_convolution(&b, h);
1722        let ba = b.min_plus_convolution(&a, h);
1723
1724        for t in 0..=h {
1725            assert_eq!(
1726                ab.value_at(t),
1727                ba.value_at(t),
1728                "commutativity failed at t={t}"
1729            );
1730        }
1731    }
1732
1733    // -- Lemma 13: backlog bound monotonicity --
1734    // Increasing arrival or decreasing service cannot reduce backlog.
1735
1736    #[test]
1737    fn lemma_backlog_monotone_arrival() {
1738        let service = MinPlusCurve::from_rate_latency(3, 2, 10);
1739        let small_arrival = MinPlusCurve::from_token_bucket(2, 1, 10);
1740        let large_arrival = MinPlusCurve::from_token_bucket(5, 2, 10);
1741
1742        let small_backlog = backlog_bound(&small_arrival, &service, 10);
1743        let large_backlog = backlog_bound(&large_arrival, &service, 10);
1744
1745        assert!(large_backlog >= small_backlog);
1746    }
1747
1748    // -- Lemma 14: delay bound monotonicity --
1749    // Higher arrival burst leads to equal or worse delay bound.
1750
1751    #[test]
1752    fn lemma_delay_monotone_arrival() {
1753        let service = MinPlusCurve::from_rate_latency(3, 2, 10);
1754        let small_arrival = MinPlusCurve::from_token_bucket(2, 1, 10);
1755        let large_arrival = MinPlusCurve::from_token_bucket(5, 2, 10);
1756
1757        let small_delay = delay_bound(&small_arrival, &service, 10, 20).unwrap_or(0);
1758        let large_delay = delay_bound(&large_arrival, &service, 10, 20).unwrap_or(0);
1759
1760        assert!(large_delay >= small_delay);
1761    }
1762
1763    // -- Lemma 15: convolution tail rate is min of input rates --
1764    // For min-plus convolution: (f ⊗ g)(t) = inf_s [f(s) + g(t-s)]
1765    // the asymptotic growth rate is min(r_f, r_g), not r_f + r_g.
1766
1767    #[test]
1768    fn lemma_convolution_tail_rate_is_min() {
1769        let slow = MinPlusCurve::new(vec![0, 1], 1).expect("valid");
1770        let fast = MinPlusCurve::new(vec![0, 3], 3).expect("valid");
1771        let conv = slow.min_plus_convolution(&fast, 10);
1772
1773        // Tail rate must be min(1, 3) = 1
1774        assert_eq!(conv.tail_rate(), 1);
1775
1776        // Verify beyond-horizon extrapolation matches the brute-force minimum
1777        // at a point well past the computed samples.
1778        let t = 20;
1779        let mut brute = u64::MAX;
1780        for s in 0..=t {
1781            brute = brute.min(slow.value_at(s).saturating_add(fast.value_at(t - s)));
1782        }
1783        assert_eq!(conv.value_at(t), brute);
1784    }
1785
1786    // ── derive-trait coverage (wave 74) ──────────────────────────────────
1787
1788    #[test]
1789    fn budget_clone_copy() {
1790        let b = Budget::new().with_poll_quota(42);
1791        let b2 = b; // Copy
1792        let b3 = b;
1793        assert_eq!(b, b2);
1794        assert_eq!(b2, b3);
1795    }
1796
1797    #[test]
1798    fn curve_error_debug_clone_eq() {
1799        let e1 = CurveError::EmptySamples;
1800        let e2 = e1.clone();
1801        assert_eq!(e1, e2);
1802
1803        let e3 = CurveError::NonMonotone {
1804            index: 2,
1805            prev: 10,
1806            next: 5,
1807        };
1808        let e4 = e3.clone();
1809        assert_eq!(e3, e4);
1810        assert_ne!(e1, e3);
1811        let dbg = format!("{e3:?}");
1812        assert!(dbg.contains("NonMonotone"));
1813    }
1814
1815    #[test]
1816    fn min_plus_curve_debug_clone_eq() {
1817        let c1 = MinPlusCurve::new(vec![0, 1, 2], 1).unwrap();
1818        let c2 = c1.clone();
1819        assert_eq!(c1, c2);
1820        let dbg = format!("{c1:?}");
1821        assert!(dbg.contains("MinPlusCurve"));
1822    }
1823
1824    #[test]
1825    fn curve_budget_debug_clone_eq() {
1826        let arrival = MinPlusCurve::new(vec![0, 2, 4], 2).unwrap();
1827        let service = MinPlusCurve::new(vec![0, 3, 6], 3).unwrap();
1828        let cb = CurveBudget { arrival, service };
1829        let cb2 = cb.clone();
1830        assert_eq!(cb, cb2);
1831        let dbg = format!("{cb:?}");
1832        assert!(dbg.contains("CurveBudget"));
1833    }
1834
1835    #[test]
1836    fn budget_event_snapshot_scrubbed() {
1837        let mut budget = Budget::new()
1838            .with_deadline(Time::from_secs(12))
1839            .with_poll_quota(3)
1840            .with_cost_quota(40)
1841            .with_priority(180);
1842
1843        let before = budget;
1844        let poll_before = budget.consume_poll();
1845        let after_poll = budget;
1846        let cost_ok = budget.consume_cost(15);
1847        let after_cost = budget;
1848
1849        insta::assert_json_snapshot!(
1850            "budget_event_scrubbed",
1851            json!({
1852                "events": [
1853                    {
1854                        "event": "created",
1855                        "deadline": scrub_budget_event(before.deadline),
1856                        "poll_quota": before.poll_quota,
1857                        "cost_quota": before.cost_quota,
1858                        "priority": before.priority,
1859                    },
1860                    {
1861                        "event": "consume_poll",
1862                        "returned": poll_before,
1863                        "poll_quota_after": after_poll.poll_quota,
1864                    },
1865                    {
1866                        "event": "consume_cost",
1867                        "accepted": cost_ok,
1868                        "cost_quota_after": after_cost.cost_quota,
1869                        "exhausted": after_cost.is_exhausted(),
1870                    }
1871                ]
1872            })
1873        );
1874    }
1875}