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}