Skip to main content

boundgraph_core/
budget.rs

1//! Declared spending limits, as data.
2//!
3//! A [`Budget`] is a *declaration*, not a meter. It says what a node is allowed
4//! to consume; it does not track what has been consumed, reserve anything, or
5//! decide whether a child fits inside its parent. Those are feature 11's job
6//! (US-1101 onward) and the runtime's (US-0504). The type lives here because
7//! the Flow IR needs to carry a budget on every node long before anything can
8//! enforce one.
9//!
10//! # Why four units and not one
11//!
12//! Different owners bound different things. Finance bounds currency, SRE bounds
13//! wall time, a provider contract bounds call count, and a context engineer
14//! bounds tokens. Collapsing them into one number would force a conversion
15//! rate that nobody agrees on, so all four are carried and each is optional.
16//!
17//! # Why unbounded is spelled out
18//!
19//! [`Budget::UNBOUNDED`] exists and is the [`Default`], but it is a value you
20//! have to write down rather than an absence you can overlook — the same
21//! reasoning that makes [`GrantSet::EMPTY`](crate::GrantSet::EMPTY) the default
22//! grant. A reviewer reading a flow sees `budget: unbounded` and can object to
23//! it; they cannot object to a field that was never there.
24//!
25//! # How budgets compose
26//!
27//! Budgets nest, because nodes do, and the rule is *tightening*: a node's
28//! effective ceiling in a unit is the smallest bound declared anywhere on the
29//! path from the flow root down to it ([`Budget::clamped_to`]). A child that
30//! declares less than its parent is narrowing its own allowance, which is
31//! always fine. A child that declares more is claiming authority its parent
32//! does not have to give, which is not ([`Budget::fits_within`]).
33//!
34//! Two asymmetries in that rule are deliberate:
35//!
36//! - **A child may leave a unit unbounded that its parent bounds.** It is not
37//!   escaping anything — it inherits the parent's bound and spends against it,
38//!   which is exactly what the runtime's buckets do (US-0504): a node that
39//!   reserves nothing charges upward until it reaches something that has a
40//!   limit.
41//! - **A child may bound a unit its parent leaves unbounded.** Tightening
42//!   below an unbounded parent is still tightening.
43//!
44//! So the only thing that fails is a unit both of them bound, where the child
45//! asks for more. [`Budget::excesses`] reports every such unit at once rather
46//! than the first, because a flow that overruns on both tokens and money has
47//! two things wrong with it and a budget owner should see both.
48//!
49//! # Comparing units that are not numbers
50//!
51//! The four units have four types — a count, a decimal, a duration, a count —
52//! and comparing them needs one space to compare in. [`Budget::amount`]
53//! normalises each to a `u64`: currency to micro-dollars, time to
54//! milliseconds. That is the same space [`BoundGraphError::BudgetExceeded`] reports
55//! in and the same one the runtime meters in, so a figure never changes
56//! meaning as it moves between them.
57//!
58//! [`BoundGraphError::BudgetExceeded`]: crate::BoundGraphError::BudgetExceeded
59
60use std::fmt;
61use std::time::Duration;
62
63use rust_decimal::Decimal;
64use rust_decimal::prelude::ToPrimitive;
65use serde::{Deserialize, Serialize};
66
67/// Micro-dollars per dollar. Six places is more than any provider prices to.
68pub const MICROS_PER_USD: u32 = 1_000_000;
69
70/// A decimal amount of dollars as whole micro-dollars.
71///
72/// An amount that will not fit in a `u64` reads as zero rather than wrapping.
73/// Both answers are wrong, and this one is wrong in the direction that shows up
74/// as an unexplained budget rather than as silent free spending.
75pub fn micros_of_usd(usd: Decimal) -> u64 {
76    (usd * Decimal::from(MICROS_PER_USD))
77        .round()
78        .to_u64()
79        .unwrap_or(0)
80}
81
82/// Which of a budget's four limits is being talked about.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum Unit {
86    /// Tokens, prompt plus completion.
87    Tokens,
88    /// Spend, in micro-dollars.
89    Usd,
90    /// Wall-clock time, in milliseconds.
91    Wall,
92    /// Model calls.
93    Calls,
94}
95
96impl Unit {
97    /// Every unit, in a stable order.
98    pub const ALL: [Self; 4] = [Self::Tokens, Self::Usd, Self::Wall, Self::Calls];
99
100    /// The name [`BoundGraphError::BudgetExceeded`](crate::BoundGraphError::BudgetExceeded)
101    /// reports. These strings are API.
102    pub const fn name(self) -> &'static str {
103        match self {
104            Self::Tokens => "tokens",
105            Self::Usd => "usd",
106            Self::Wall => "wall",
107            Self::Calls => "calls",
108        }
109    }
110
111    /// The unit a [`name`](Self::name) refers to.
112    ///
113    /// The inverse of `name`, so a unit that crossed a process boundary as a
114    /// string arrives back as the same unit.
115    pub fn from_name(name: &str) -> Option<Self> {
116        Self::ALL.into_iter().find(|u| u.name() == name)
117    }
118
119    /// This unit's position in [`ALL`](Self::ALL).
120    ///
121    /// Public because the runtime stores an amount per unit in a fixed array
122    /// and needs the same ordering this crate uses — two orderings would be one
123    /// too many.
124    pub const fn index(self) -> usize {
125        match self {
126            Self::Tokens => 0,
127            Self::Usd => 1,
128            Self::Wall => 2,
129            Self::Calls => 3,
130        }
131    }
132
133    /// How an amount in this unit should be written for a person.
134    ///
135    /// The inverse of the normalisation [`Budget::amount`] applies, so a
136    /// diagnostic reports `$0.50` rather than `500000 usd`.
137    ///
138    /// Money is shown to at least two places and never fewer, because `$0.5`
139    /// reads as a typo where `$0.50` reads as an amount. Sub-cent precision is
140    /// kept where it exists — per-million token rates are quoted in millionths,
141    /// and rounding them away in a diagnostic about cost would be the wrong
142    /// place to lose them.
143    pub fn render(self, amount: u64) -> String {
144        match self {
145            Self::Tokens => format!("{amount} tokens"),
146            Self::Usd => {
147                let mut usd = (Decimal::from(amount) / Decimal::from(MICROS_PER_USD)).normalize();
148                if usd.scale() < 2 {
149                    usd.rescale(2);
150                }
151                format!("${usd}")
152            }
153            Self::Wall => format!("{:.3}s", amount as f64 / 1_000.0),
154            Self::Calls => format!("{amount} calls"),
155        }
156    }
157}
158
159impl fmt::Display for Unit {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.write_str(self.name())
162    }
163}
164
165/// A unit in which a budget asks for more than it may have.
166///
167/// Carries both figures, because "over budget" without them sends someone to
168/// work out by how much, and by how much is the number that decides what to do
169/// about it.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
171pub struct Excess {
172    /// Which limit.
173    pub unit: Unit,
174    /// What was asked for, normalised.
175    pub declared: u64,
176    /// What was available, normalised.
177    pub permitted: u64,
178}
179
180impl Excess {
181    /// How far over the limit this is.
182    pub const fn overage(&self) -> u64 {
183        self.declared.saturating_sub(self.permitted)
184    }
185}
186
187impl fmt::Display for Excess {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        write!(
190            f,
191            "{} against a limit of {}",
192            self.unit.render(self.declared),
193            self.unit.render(self.permitted)
194        )
195    }
196}
197
198/// What a node is permitted to spend, in whichever units were declared.
199///
200/// Any subset of the four may be set. A unit left as `None` is unbounded *in
201/// that unit* — it does not weaken the units that were declared.
202///
203/// # Example
204///
205/// ```
206/// use boundgraph_core::Budget;
207/// use std::time::Duration;
208///
209/// let b = Budget::UNBOUNDED
210///     .with_usd("0.50".parse().unwrap())
211///     .with_wall(Duration::from_secs(30));
212///
213/// assert!(!b.is_unbounded());
214/// assert_eq!(b.tokens, None);      // tokens were never bounded
215/// assert_eq!(b.declared_units(), ["usd", "wall"]);
216/// ```
217#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
218pub struct Budget {
219    /// Total tokens, prompt plus completion.
220    pub tokens: Option<u64>,
221    /// Spend in US dollars.
222    ///
223    /// A decimal, never a float: money that round-trips through `f64` stops
224    /// being auditable, and the ledger is meant to be audited.
225    pub usd: Option<Decimal>,
226    /// Wall-clock time.
227    pub wall: Option<Duration>,
228    /// Number of model calls.
229    pub calls: Option<u32>,
230}
231
232impl Budget {
233    /// No limit in any unit.
234    ///
235    /// The default, and deliberately something you have to name — see the
236    /// module documentation.
237    pub const UNBOUNDED: Self = Self {
238        tokens: None,
239        usd: None,
240        wall: None,
241        calls: None,
242    };
243
244    /// Bound the token count.
245    #[must_use]
246    pub fn with_tokens(mut self, tokens: u64) -> Self {
247        self.tokens = Some(tokens);
248        self
249    }
250
251    /// Bound the spend.
252    ///
253    /// The amount is normalised, so `1.50` and `1.5` are the same budget and
254    /// therefore encode to the same bytes and the same digest.
255    #[must_use]
256    pub fn with_usd(mut self, usd: Decimal) -> Self {
257        self.usd = Some(usd.normalize());
258        self
259    }
260
261    /// Bound the wall-clock time.
262    #[must_use]
263    pub fn with_wall(mut self, wall: Duration) -> Self {
264        self.wall = Some(wall);
265        self
266    }
267
268    /// Bound the number of model calls.
269    #[must_use]
270    pub fn with_calls(mut self, calls: u32) -> Self {
271        self.calls = Some(calls);
272        self
273    }
274
275    /// Whether nothing at all is bounded.
276    pub fn is_unbounded(&self) -> bool {
277        *self == Self::UNBOUNDED
278    }
279
280    /// What this budget bounds in one unit, normalised to an integer.
281    ///
282    /// Currency becomes micro-dollars and time becomes milliseconds — see the
283    /// module documentation for why there is one space to compare in. `None`
284    /// means this budget does not bound that unit, which is not the same as
285    /// bounding it at zero.
286    pub fn amount(&self, unit: Unit) -> Option<u64> {
287        match unit {
288            Unit::Tokens => self.tokens,
289            Unit::Usd => self.usd.map(micros_of_usd),
290            Unit::Wall => self
291                .wall
292                .map(|w| w.as_millis().min(u128::from(u64::MAX)) as u64),
293            Unit::Calls => self.calls.map(u64::from),
294        }
295    }
296
297    /// Which units this budget bounds, in a stable order.
298    pub fn bounded_units(&self) -> Vec<Unit> {
299        Unit::ALL
300            .into_iter()
301            .filter(|u| self.amount(*u).is_some())
302            .collect()
303    }
304
305    /// Every unit in which this budget asks for more than `parent` permits.
306    ///
307    /// Only units *both* budgets bound can be exceeded — see the module
308    /// documentation for why the two asymmetric cases are legal. Returns them
309    /// all rather than the first, in unit order.
310    pub fn excesses(&self, parent: &Self) -> Vec<Excess> {
311        Unit::ALL
312            .into_iter()
313            .filter_map(|unit| {
314                let declared = self.amount(unit)?;
315                let permitted = parent.amount(unit)?;
316                (declared > permitted).then_some(Excess {
317                    unit,
318                    declared,
319                    permitted,
320                })
321            })
322            .collect()
323    }
324
325    /// Whether this budget can be declared beneath `parent`.
326    ///
327    /// True when it claims no more than the parent has to give, in every unit
328    /// they both bound.
329    pub fn fits_within(&self, parent: &Self) -> bool {
330        self.excesses(parent).is_empty()
331    }
332
333    /// This budget as it is actually enforced beneath `parent`.
334    ///
335    /// The tighter of the two in every unit, which is the composition rule
336    /// itself: an effective ceiling is the smallest bound on the path down to
337    /// a node. Applying it to a budget that already
338    /// [`fits_within`](Self::fits_within) its parent returns that budget with
339    /// the parent's bounds filled in where it declared none.
340    ///
341    /// Idempotent, and associative down a chain: clamping to a parent and then
342    /// to a grandparent gives what clamping straight to the tightest of the
343    /// three would.
344    #[must_use]
345    pub fn clamped_to(&self, parent: &Self) -> Self {
346        Self {
347            tokens: tighter(self.tokens, parent.tokens),
348            // Compared as declared rather than through `amount`, so the
349            // surviving value keeps its own scale and the digest of a clamped
350            // budget stays the digest of whichever budget won.
351            usd: tighter(self.usd, parent.usd),
352            wall: tighter(self.wall, parent.wall),
353            calls: tighter(self.calls, parent.calls),
354        }
355    }
356
357    /// Which units this budget actually constrains, in a stable order.
358    ///
359    /// Useful for diagnostics: "this flow bounds `usd` but not `wall`" is a
360    /// reviewable statement.
361    pub fn declared_units(&self) -> Vec<&'static str> {
362        let mut units = Vec::with_capacity(4);
363        if self.tokens.is_some() {
364            units.push("tokens");
365        }
366        if self.usd.is_some() {
367            units.push("usd");
368        }
369        if self.wall.is_some() {
370            units.push("wall");
371        }
372        if self.calls.is_some() {
373            units.push("calls");
374        }
375        units
376    }
377}
378
379/// The smaller of two optional bounds, where absent means "no bound from this
380/// side" rather than zero.
381fn tighter<T: Ord>(child: Option<T>, parent: Option<T>) -> Option<T> {
382    match (child, parent) {
383        (Some(a), Some(b)) => Some(a.min(b)),
384        (Some(a), None) => Some(a),
385        (None, b) => b,
386    }
387}
388
389impl fmt::Display for Budget {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        if self.is_unbounded() {
392            return f.write_str("unbounded");
393        }
394        let mut first = true;
395        let mut part = |f: &mut fmt::Formatter<'_>, text: &str| {
396            let sep = if first { "" } else { ", " };
397            first = false;
398            write!(f, "{sep}{text}")
399        };
400        if let Some(t) = self.tokens {
401            part(f, &format!("{t} tokens"))?;
402        }
403        if let Some(u) = self.usd {
404            part(f, &format!("${u}"))?;
405        }
406        if let Some(w) = self.wall {
407            part(f, &format!("{:.3}s", w.as_secs_f64()))?;
408        }
409        if let Some(c) = self.calls {
410            part(f, &format!("{c} calls"))?;
411        }
412        Ok(())
413    }
414}
415
416impl fmt::Debug for Budget {
417    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418        write!(f, "Budget({self})")
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::canonical::Canonical;
426
427    #[test]
428    fn the_default_is_unbounded() {
429        assert!(Budget::default().is_unbounded());
430        assert_eq!(Budget::default(), Budget::UNBOUNDED);
431        assert!(Budget::UNBOUNDED.declared_units().is_empty());
432    }
433
434    #[test]
435    fn units_are_independent() {
436        let b = Budget::UNBOUNDED.with_tokens(1_000);
437        assert_eq!(b.tokens, Some(1_000));
438        assert_eq!(b.usd, None);
439        assert_eq!(b.wall, None);
440        assert_eq!(b.calls, None);
441        assert_eq!(b.declared_units(), ["tokens"]);
442    }
443
444    #[test]
445    fn all_four_units_can_be_declared_together() {
446        let b = Budget::UNBOUNDED
447            .with_tokens(10)
448            .with_usd(Decimal::new(41, 2))
449            .with_wall(Duration::from_millis(1_500))
450            .with_calls(3);
451        assert_eq!(b.declared_units(), ["tokens", "usd", "wall", "calls"]);
452        assert!(!b.is_unbounded());
453    }
454
455    /// `1.50` and `1.5` are the same amount of money, so they must be the same
456    /// budget — otherwise two equal flows would carry different digests.
457    #[test]
458    fn currency_scale_does_not_change_identity() {
459        let a = Budget::UNBOUNDED.with_usd("1.50".parse().unwrap());
460        let b = Budget::UNBOUNDED.with_usd("1.5".parse().unwrap());
461        assert_eq!(a, b);
462        assert_eq!(a.digest().unwrap(), b.digest().unwrap());
463    }
464
465    #[test]
466    fn round_trips_through_canonical_cbor() {
467        let b = Budget::UNBOUNDED
468            .with_tokens(64)
469            .with_usd("0.50".parse().unwrap())
470            .with_wall(Duration::from_secs(30))
471            .with_calls(2);
472        let bytes = crate::to_canonical_cbor(&b).unwrap();
473        let back: Budget = crate::from_canonical_cbor(&bytes).unwrap();
474        assert_eq!(b, back);
475    }
476
477    #[test]
478    fn display_names_every_declared_unit() {
479        assert_eq!(Budget::UNBOUNDED.to_string(), "unbounded");
480        let b = Budget::UNBOUNDED
481            .with_tokens(100)
482            .with_usd("0.41".parse().unwrap());
483        assert_eq!(b.to_string(), "100 tokens, $0.41");
484    }
485
486    // ── units ───────────────────────────────────────────────────────────────
487
488    /// Every unit round-trips through the name the error reports, so a unit
489    /// that crossed a process boundary as a string comes back as itself.
490    #[test]
491    fn unit_names_round_trip() {
492        for unit in Unit::ALL {
493            assert_eq!(Unit::from_name(unit.name()), Some(unit));
494        }
495        assert_eq!(Unit::from_name("furlongs"), None);
496    }
497
498    /// The ordering is fixed, because the runtime stores an amount per unit in
499    /// an array indexed by it.
500    #[test]
501    fn unit_indices_are_stable_and_distinct() {
502        let indices: Vec<_> = Unit::ALL.iter().map(|u| u.index()).collect();
503        assert_eq!(indices, [0, 1, 2, 3]);
504    }
505
506    /// A diagnostic reports money as money, not as the integer it was compared
507    /// as.
508    #[test]
509    fn amounts_render_in_the_unit_a_person_reads() {
510        assert_eq!(Unit::Tokens.render(1_500), "1500 tokens");
511        assert_eq!(Unit::Usd.render(500_000), "$0.50");
512        assert_eq!(Unit::Usd.render(2_000_000), "$2.00");
513        assert_eq!(
514            Unit::Usd.render(3),
515            "$0.000003",
516            "sub-cent precision survives"
517        );
518        assert_eq!(Unit::Wall.render(1_500), "1.500s");
519        assert_eq!(Unit::Calls.render(3), "3 calls");
520    }
521
522    // ── normalisation ───────────────────────────────────────────────────────
523
524    /// The criterion: currency uses a decimal type, never a float — and the
525    /// integer it normalises to is exact.
526    #[test]
527    fn currency_normalises_to_whole_micro_dollars() {
528        let b = Budget::UNBOUNDED.with_usd("0.50".parse().unwrap());
529        assert_eq!(b.amount(Unit::Usd), Some(500_000));
530
531        // The case a float gets wrong: 0.1 + 0.2 must be exactly 0.3.
532        let sum: Decimal = "0.1".parse::<Decimal>().unwrap() + "0.2".parse::<Decimal>().unwrap();
533        assert_eq!(
534            Budget::UNBOUNDED.with_usd(sum).amount(Unit::Usd),
535            Some(300_000)
536        );
537    }
538
539    #[test]
540    fn time_normalises_to_milliseconds() {
541        let b = Budget::UNBOUNDED.with_wall(Duration::from_millis(1_500));
542        assert_eq!(b.amount(Unit::Wall), Some(1_500));
543    }
544
545    /// An unbounded unit is not a zero bound. Confusing the two would turn
546    /// "spend what you like" into "spend nothing".
547    #[test]
548    fn an_unbounded_unit_is_not_a_zero_bound() {
549        let unbounded = Budget::UNBOUNDED;
550        let zero = Budget::UNBOUNDED.with_tokens(0);
551
552        assert_eq!(unbounded.amount(Unit::Tokens), None);
553        assert_eq!(zero.amount(Unit::Tokens), Some(0));
554        assert_ne!(unbounded, zero);
555    }
556
557    #[test]
558    fn bounded_units_reports_only_what_is_declared() {
559        let b = Budget::UNBOUNDED.with_tokens(1).with_calls(2);
560        assert_eq!(b.bounded_units(), [Unit::Tokens, Unit::Calls]);
561        assert!(Budget::UNBOUNDED.bounded_units().is_empty());
562    }
563
564    // ── composition ─────────────────────────────────────────────────────────
565
566    /// The criterion: budgets compose hierarchically, with a child never
567    /// exceeding its parent.
568    #[test]
569    fn a_child_asking_for_more_than_its_parent_does_not_fit() {
570        let parent = Budget::UNBOUNDED.with_tokens(1_000);
571        let ok = Budget::UNBOUNDED.with_tokens(400);
572        let greedy = Budget::UNBOUNDED.with_tokens(1_400);
573
574        assert!(ok.fits_within(&parent));
575        assert!(!greedy.fits_within(&parent));
576
577        let excesses = greedy.excesses(&parent);
578        assert_eq!(excesses.len(), 1);
579        assert_eq!(excesses[0].unit, Unit::Tokens);
580        assert_eq!(excesses[0].declared, 1_400);
581        assert_eq!(excesses[0].permitted, 1_000);
582        assert_eq!(excesses[0].overage(), 400);
583    }
584
585    /// Asking for exactly the parent's allowance is not an excess. The whole
586    /// allowance is a legal thing to claim.
587    #[test]
588    fn equalling_the_parent_fits() {
589        let parent = Budget::UNBOUNDED.with_tokens(1_000);
590        assert!(Budget::UNBOUNDED.with_tokens(1_000).fits_within(&parent));
591    }
592
593    /// A child that leaves a unit unbounded is inheriting the parent's bound,
594    /// not escaping it — which is exactly what the runtime's buckets do.
595    #[test]
596    fn a_child_may_leave_a_unit_its_parent_bounds_unbounded() {
597        let parent = Budget::UNBOUNDED.with_tokens(1_000).with_calls(5);
598        let child = Budget::UNBOUNDED.with_tokens(100);
599
600        assert!(child.fits_within(&parent));
601        assert_eq!(child.clamped_to(&parent).calls, Some(5), "it inherits");
602    }
603
604    /// And a child may bound a unit its parent does not. Tightening below an
605    /// unbounded parent is still tightening.
606    #[test]
607    fn a_child_may_bound_a_unit_its_parent_leaves_open() {
608        let parent = Budget::UNBOUNDED.with_tokens(1_000);
609        let child = Budget::UNBOUNDED.with_usd("0.10".parse().unwrap());
610
611        assert!(child.fits_within(&parent));
612        assert_eq!(
613            child.clamped_to(&parent).usd,
614            Some("0.10".parse().unwrap()),
615            "the child's own bound survives"
616        );
617    }
618
619    /// Every offending unit is reported, not just the first: a flow over budget
620    /// on both tokens and money has two problems.
621    #[test]
622    fn every_exceeded_unit_is_reported() {
623        let parent = Budget::UNBOUNDED
624            .with_tokens(100)
625            .with_usd("0.10".parse().unwrap())
626            .with_calls(10);
627        let child = Budget::UNBOUNDED
628            .with_tokens(200)
629            .with_usd("0.50".parse().unwrap())
630            .with_calls(1);
631
632        let units: Vec<_> = child.excesses(&parent).iter().map(|e| e.unit).collect();
633        assert_eq!(
634            units,
635            [Unit::Tokens, Unit::Usd],
636            "in unit order, and calls fits"
637        );
638    }
639
640    /// Clamping takes the tighter of the two in every unit — the composition
641    /// rule itself.
642    #[test]
643    fn clamping_takes_the_tighter_bound_in_every_unit() {
644        let parent = Budget::UNBOUNDED.with_tokens(1_000).with_calls(2);
645        let child = Budget::UNBOUNDED.with_tokens(4_000).with_calls(1);
646
647        let effective = child.clamped_to(&parent);
648        assert_eq!(effective.tokens, Some(1_000), "the parent's is tighter");
649        assert_eq!(effective.calls, Some(1), "and here the child's is");
650    }
651
652    /// Clamping is idempotent: an effective budget is already effective.
653    #[test]
654    fn clamping_twice_changes_nothing() {
655        let parent = Budget::UNBOUNDED.with_tokens(1_000);
656        let child = Budget::UNBOUNDED.with_tokens(4_000).with_calls(3);
657
658        let once = child.clamped_to(&parent);
659        assert_eq!(once.clamped_to(&parent), once);
660    }
661
662    /// And it composes down a chain: clamping through a parent to a grandparent
663    /// gives what clamping straight to the tightest of the three would.
664    #[test]
665    fn clamping_composes_down_a_chain() {
666        let grandparent = Budget::UNBOUNDED.with_tokens(500);
667        let parent = Budget::UNBOUNDED.with_tokens(2_000);
668        let child = Budget::UNBOUNDED.with_tokens(4_000);
669
670        let stepwise = child.clamped_to(&parent).clamped_to(&grandparent);
671        assert_eq!(stepwise.tokens, Some(500));
672        assert_eq!(stepwise, child.clamped_to(&grandparent));
673    }
674
675    /// Anything fits within an unbounded parent, and clamping to one is a
676    /// no-op.
677    #[test]
678    fn an_unbounded_parent_constrains_nothing() {
679        let child = Budget::UNBOUNDED.with_tokens(9_000);
680        assert!(child.fits_within(&Budget::UNBOUNDED));
681        assert_eq!(child.clamped_to(&Budget::UNBOUNDED), child);
682    }
683
684    /// An excess prints both figures in the unit a person reads, because "over
685    /// budget" without them is not actionable.
686    #[test]
687    fn an_excess_names_both_figures() {
688        let parent = Budget::UNBOUNDED.with_usd("0.10".parse().unwrap());
689        let child = Budget::UNBOUNDED.with_usd("0.50".parse().unwrap());
690
691        let excess = child.excesses(&parent).pop().unwrap();
692        let printed = excess.to_string();
693        assert!(printed.contains("$0.5"), "{printed}");
694        assert!(printed.contains("$0.1"), "{printed}");
695    }
696}