microvms_domain/cost.rs
1// SPDX-License-Identifier: Apache-2.0
2//! What a run cost, and what a plan will cost, with every figure labelled.
3//!
4//! MicroVMs has no standalone pricing page. The rates appear only inside worked
5//! examples on the Lambda pricing page, which is why this module exists as data
6//! rather than as a paragraph someone re-derives: a consumer building a sandbox
7//! product should be able to ask "what did that run cost" without reading a pricing
8//! page sideways.
9//!
10//! Two rules hold everywhere below. The Python client's `cost.py` enforced both at
11//! runtime — a `TypeError` from a keyword-only field, a `NotImplemented` from
12//! `__add__`. Here they are the shape of the types, so the enforcement happens before
13//! the program runs.
14//!
15//! **Seconds are measured, dollars are estimated.** [`DurationP`] is an enum whose
16//! every variant names its provenance, so there is no unlabelled constructor to
17//! call — not one that raises, one that does not exist (COST-1). And there is no
18//! type here for an actual dollar amount, only [`EstimatedUsd`], which says so on
19//! every render. Only Cost Explorer knows the bill. That asymmetry is the point: we
20//! can time a suspension to the millisecond and still only infer its price.
21//!
22//! **Unknown is not zero.** [`Amount::Unpriced`] carries a reason and is a distinct
23//! variant, so a consumer matching on an amount has to handle it (COST-3). AWS does
24//! not publish whether the server-side image build is billed as compute, so a report
25//! that summed it as $0.00 would be lying in the direction that flatters us. A
26//! [`Total`] over any unpriced line is a *different variant*, [`Total::AtLeast`],
27//! which cannot be read without seeing the reasons beside the floor (COST-4).
28//!
29//! # What this port strengthens
30//!
31//! Three closures move up the strength ladder from the Python original.
32//!
33//! * **No negative duration.** `cost.py` validated `seconds >= 0` in
34//! `__post_init__`, because an inverted clock renders as a credit on the report.
35//! [`DurationP`] wraps [`std::time::Duration`], which cannot be negative, so the
36//! check is gone rather than moved: the only place a sign can go wrong is
37//! [`seconds_of`], the one f64 boundary, and it is fallible.
38//! * **No coercion to float.** `cost.py` omitted `__float__` and relied on the
39//! reader not writing `float(x.amount)`. [`EstimatedUsd`] has a private field, no
40//! `From`/`Into<f64>`, no `Deref`, and no `Serialize` that emits a number — the
41//! coercion is not a discouraged spelling, it is absent (COST-2).
42//! * **No x86 rate, by construction.** `pricing.py` refused to substitute the x86
43//! compute rate inside its fetch path, which meant a hand-built `RateTable` could
44//! still carry one. Here [`RateTable`]'s rate fields are private and there are
45//! exactly two ways to obtain one — the pinned [`pinned_rates`], and
46//! [`RateTable::from_catalog`], which rejects a catalog whose ARM line is missing
47//! (COST-9). No value of the type exists that was built from an x86 figure.
48//!
49//! # The table carries its own staleness
50//!
51//! [`pinned_rates`] is pinned to a retrieval date, and a table older than
52//! [`STALE_AFTER_DAYS`] attaches a warning to every report computed from it
53//! (COST-7), because a silently stale price is the same failure class as a silently
54//! stale schema. The warning is a fallback rather than the defence — it can only say
55//! that nobody has looked. [`RateCatalog`] is what looks.
56//!
57//! Every rate here is recorded in `docs/PLATFORM.md`, "What actually costs money".
58//! One of them, [`RateTable::storage_gb_month`], is derived: the API quotes snapshot
59//! storage per GB-hour and this table holds per GB-month, so it is the hourly figure
60//! times [`HOURS_PER_MONTH`]. Billing reads [`SizeClass::baseline_gb`] and never the
61//! peak (COST-5).
62
63use std::fmt;
64use std::time::Duration;
65
66use rust_decimal::Decimal;
67use rust_decimal::prelude::{FromStr, ToPrimitive};
68use rust_decimal_macros::dec;
69
70use crate::error::Error;
71use crate::region::Region;
72use crate::sizing::SizeClass;
73
74/// AWS bills storage per GB-month and defines a month as 730 hours, so a
75/// partial-month hold is that fraction of the monthly rate.
76///
77/// Spelled out because 30-day and calendar-month conventions both give
78/// plausible-looking answers that disagree with the worked examples in
79/// `docs/PLATFORM.md` by a few percent. `730 * 3600`.
80pub const SECONDS_PER_MONTH: Decimal = dec!(2628000);
81
82/// Hours in the month AWS's GB-month rate assumes, derived from
83/// [`SECONDS_PER_MONTH`] rather than written again.
84///
85/// The second 730 is where the drift got in: the pinned storage rate read `0.08`
86/// until 2026-08-07, a round number that was 1.37% low, because two places in the
87/// code held two conventions for the same month.
88pub const HOURS_PER_MONTH: Decimal = dec!(730);
89
90/// How old a rate table may get before it warns (COST-7).
91///
92/// Ninety days is the same order as the interval at which AWS has historically
93/// restructured Lambda pricing, and the cost of the warning when nothing changed is
94/// one line of output.
95pub const STALE_AFTER_DAYS: i64 = 90;
96
97/// Seconds in a day, for the one place a [`Duration`] is quoted in days.
98///
99/// Not a billing convention — AWS's month is [`SECONDS_PER_MONTH`] and nothing here
100/// derives a month from this. It exists so [`RateTable::minimum_retention_days`] reads
101/// its own unit off a name rather than off a literal beside a message.
102const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
103
104/// Snapshot storage bills at least this long however briefly the snapshot exists
105/// (COST-8).
106///
107/// One week. Why a create-and-destroy suite's floor is its image rather than its
108/// compute: a 2 GB image deleted after sixty seconds still bills about four cents.
109pub const MINIMUM_RETENTION: Duration = Duration::from_secs(7 * SECONDS_PER_DAY);
110
111// ── the two float boundaries (COST-6) ────────────────────────────────────────
112
113/// Seconds as an exact [`Decimal`], from the one representation that cannot be
114/// negative.
115///
116/// **This is a conversion site, and it is exact.** A [`Duration`] is a whole
117/// seconds count plus a nanosecond remainder, both integers, so this is integer
118/// arithmetic in decimal rather than a float being reinterpreted — the nanosecond
119/// division is by a power of ten and loses nothing.
120pub fn seconds_of(duration: Duration) -> Decimal {
121 // Nanoseconds are < 1e9 and Decimal holds 28 significant digits, so neither
122 // term can overflow and the sum is exact.
123 Decimal::from(duration.as_secs()) + Decimal::from(duration.subsec_nanos()) / dec!(1000000000)
124}
125
126/// A float from a caller, converted into the money path exactly once (COST-6).
127///
128/// **This is the only place an f64 becomes a [`Decimal`].** It goes through the
129/// float's decimal string rather than its binary value, because
130/// `Decimal::try_from(0.1f64)` carries the binary error into every downstream
131/// figure. Fallible rather than lossy: `NaN`, an infinity, and a magnitude past
132/// 28 digits have no decimal reading, and a money figure derived from one of them
133/// would be a number nobody could reconcile.
134///
135/// Named for gigabytes because that is the one quantity a caller supplies as a
136/// float — every duration arrives as a [`Duration`], which [`seconds_of`] converts
137/// exactly.
138pub fn gb_decimal(gb: f64) -> Result<Decimal, Error> {
139 if !gb.is_finite() || gb < 0.0 {
140 return Err(Error::invalid_arg(format!(
141 "{gb} GB is not a quantity that can be priced: a size must be finite and \
142 non-negative, and a negative one would render as a credit on the report"
143 )));
144 }
145 Decimal::from_str(&gb.to_string()).map_err(|source| {
146 Error::invalid_arg(format!(
147 "{gb} GB cannot be represented exactly in decimal, and money arithmetic here is \
148 decimal end to end so that a figure can be reconciled against the rate table"
149 ))
150 .with_source(source)
151 })
152}
153
154/// Seconds from a float for a figure on a report, refused when the float cannot be a duration.
155///
156/// The complement of [`gb_decimal`] for the API boundary where a report's seconds arrive as a
157/// number: a [`DurationP`], or the hold a residency comparison prices. It's
158/// [`crate::duration::of_secs_f64`] with the reason a negative span matters here in the
159/// refusal, which is why [`DurationP`] itself needs no validation: an inverted clock is refused
160/// here or it never becomes a duration. A wait or a window converts through
161/// `duration::of_secs_f64`, whose refusal claims no report.
162pub fn duration_of_secs_f64(seconds: f64) -> Result<Duration, Error> {
163 crate::duration::of_secs_f64_because(
164 seconds,
165 ", and a negative one would silently produce a credit on the report",
166 )
167}
168
169// ── display precision ────────────────────────────────────────────────────────
170
171/// Enough places to see a sub-cent figure, few enough to read a monthly one.
172///
173/// A create-and-destroy test suite's compute cost lives in the sixth decimal place
174/// while a warm pool's monthly bill lives in the second, and one fixed precision
175/// cannot show both without either lying or shouting. Half-even at the boundary,
176/// matching Python's `Decimal.quantize` default, so the two clients render the same
177/// figure the same way.
178fn render_amount(amount: Decimal) -> String {
179 let places = if amount.abs() >= Decimal::ONE { 2 } else { 6 };
180 let mut rendered = amount.round_dp(places);
181 // Fixed places rather than significant digits: a trailing zero is what says the
182 // figure was rounded to the cent rather than happening to land there.
183 rendered.rescale(places);
184 rendered.to_string()
185}
186
187/// A consumed quantity, rounded only for display.
188///
189/// Division by 730 hours yields 28 significant digits, and a line item nobody can
190/// scan is a line item nobody checks the rate table against — which is the one job
191/// the quantity column has.
192fn render_quantity(quantity: Decimal) -> String {
193 quantity.round_dp(6).normalize().to_string()
194}
195
196// ── dates, via jiff ───────────────────────────────────────────────────────────
197
198/// A calendar day, which is the only temporal resolution a rate table has.
199///
200/// A thin wrapper over [`jiff::civil::Date`] so the crate's public surface (and the
201/// py/js bindings behind it) keeps its own type. The wrapped crate replaced a
202/// hand-rolled proleptic-Gregorian day-number implementation (issue #91); the pinned
203/// tests that held that implementation to Python's `datetime` now hold jiff to the
204/// same three known values.
205#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
206pub struct CalendarDate(jiff::civil::Date);
207
208impl CalendarDate {
209 /// A date from its parts, for the literals in this file.
210 ///
211 /// `const` so [`pinned_rates`] can pin its retrieval date. jiff's `constant`
212 /// refuses an invalid literal at compile time when const-evaluated, which is
213 /// strictly better than the old non-validating constructor; anything arriving
214 /// at runtime goes through [`CalendarDate::try_from_ymd`].
215 pub const fn from_ymd(year: i32, month: u32, day: u32) -> CalendarDate {
216 CalendarDate(jiff::civil::Date::constant(
217 year as i16,
218 month as i8,
219 day as i8,
220 ))
221 }
222
223 /// A date from parts that came from outside this crate.
224 ///
225 /// The S2 boundary: a `--today` flag or a JSON field is three integers, and
226 /// `2026-02-30` would otherwise produce a day number for March 2nd and an age
227 /// two days out. The range pre-checks keep the narrowing casts from wrapping
228 /// (`month = 257` must not arrive at jiff as `1`).
229 pub fn try_from_ymd(year: i32, month: u32, day: u32) -> Result<CalendarDate, Error> {
230 let refusal = || {
231 Error::invalid_arg(format!(
232 "{year:04}-{month:02}-{day:02} is not a calendar day, so it cannot date a rate \
233 table: month must be 1-12 and day must be within that month"
234 ))
235 };
236 if !(1..=12).contains(&month) || !(1..=31).contains(&day) || i16::try_from(year).is_err() {
237 return Err(refusal());
238 }
239 jiff::civil::Date::new(year as i16, month as i8, day as i8)
240 .map(CalendarDate)
241 .map_err(|_| refusal())
242 }
243
244 /// The UTC civil day `secs` seconds after the Unix epoch falls on.
245 ///
246 /// UTC rather than local, and no tzdb: a rate table's age is measured in
247 /// ninety-day units, so the zone can only ever move the answer by a day. The time
248 /// is the caller's: this crate doesn't read the clock (ARCH-6), and
249 /// `microvms_core::prelude::CalendarDateExt::today_utc` is the one that does.
250 ///
251 /// Refuses a time past 9999-12-31, the last day the calendar holds, rather than
252 /// clamping it: the likeliest such value is a millisecond count passed as seconds,
253 /// and an age computed from a clamped or defaulted date would be wrong with no sign.
254 pub fn from_unix_secs(secs: u64) -> Result<CalendarDate, Error> {
255 // Integer division floors, and the epoch is midnight, so this is the UTC
256 // civil day without any rounding question.
257 i64::try_from(secs / 86_400)
258 .ok()
259 .and_then(Self::from_day_number)
260 .ok_or_else(|| {
261 Error::invalid_arg(format!(
262 "{secs} seconds after the Unix epoch is past 9999-12-31, the last day a \
263 rate table can be dated. A time in milliseconds looks like this: pass \
264 whole seconds"
265 ))
266 })
267 }
268
269 pub fn year(self) -> i32 {
270 i32::from(self.0.year())
271 }
272
273 pub fn month(self) -> u32 {
274 self.0.month() as u32
275 }
276
277 pub fn day(self) -> u32 {
278 self.0.day() as u32
279 }
280
281 /// Days since 1970-01-01, negative before it.
282 pub fn day_number(self) -> i64 {
283 i64::from(self.0.since(EPOCH).expect("dates are in range").get_days())
284 }
285
286 /// The inverse of [`CalendarDate::day_number`], for [`CalendarDate::from_unix_secs`].
287 /// `None` outside the calendar's range, which `Span::days` would panic on.
288 fn from_day_number(day_number: i64) -> Option<CalendarDate> {
289 let span = jiff::Span::new().try_days(day_number).ok()?;
290 EPOCH.checked_add(span).ok().map(CalendarDate)
291 }
292
293 /// Days from `earlier` to `self`, negative when `self` is the earlier one.
294 pub fn days_since(self, earlier: CalendarDate) -> i64 {
295 self.day_number() - earlier.day_number()
296 }
297}
298
299/// 1970-01-01, the zero of [`CalendarDate::day_number`]'s count.
300const EPOCH: jiff::civil::Date = jiff::civil::Date::constant(1970, 1, 1);
301
302/// ISO 8601, because that is what the Python client's `retrieved.isoformat()` puts
303/// in the report header and the two have to be diffable.
304impl fmt::Display for CalendarDate {
305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306 write!(
307 f,
308 "{:04}-{:02}-{:02}",
309 self.year(),
310 self.month(),
311 self.day()
312 )
313 }
314}
315
316// ── provenance (COST-1) ──────────────────────────────────────────────────────
317
318/// Where a duration came from. There is no third option and no default.
319///
320/// `Measured` means a clock ran. `Projected` means a caller supplied a
321/// hypothetical, which is also what an estimate's durations are and what the
322/// documented one-week storage minimum is — nobody timed that week either.
323#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
324pub enum Provenance {
325 Measured,
326 Projected,
327}
328
329impl Provenance {
330 /// The wire spelling, identical to the Python `StrEnum` member.
331 pub fn as_str(self) -> &'static str {
332 match self {
333 Provenance::Measured => "measured",
334 Provenance::Projected => "projected",
335 }
336 }
337}
338
339impl fmt::Display for Provenance {
340 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341 f.write_str(self.as_str())
342 }
343}
344
345/// Seconds, plus how we know them (COST-1).
346///
347/// The labelling rule is the enum: every way of building one of these names a
348/// provenance, because there is no other variant to reach and no
349/// `From<Duration>`/`Default` to fall into. `cost.py` achieved the same thing with
350/// a field that had no default, so `Duration(3600)` raised `TypeError` — the whole
351/// rule collapses the moment an unlabelled duration can be written, because the
352/// label that goes missing is always the weaker one, and here it cannot be written.
353///
354/// Wrapping [`std::time::Duration`] also retires the Python's negativity check: a
355/// negative duration is not a value this type can hold, so an inverted clock is
356/// caught at [`duration_of_secs_f64`] or never enters at all.
357///
358/// # Provenance cannot be omitted (COST-1)
359///
360/// "No unlabelled constructor" is a claim about what does not exist, so it is checked
361/// by programs that do not build:
362///
363/// Each carries its expected error code, because a bare `compile_fail` passes for any
364/// build failure at all — a typo in the test would leave the guard green.
365///
366/// ```compile_fail,E0277
367/// # use microvms_domain::cost::DurationP;
368/// # use std::time::Duration;
369/// // There is no `From<Duration> for DurationP`, so a bare span cannot become a
370/// // labelled one by coercion.
371/// let unlabelled: DurationP = Duration::from_secs(3600).into();
372/// ```
373///
374/// ```compile_fail,E0599
375/// # use microvms_domain::cost::DurationP;
376/// // No `Default` either. A default would have to pick a provenance, and the one it
377/// // would pick is the stronger claim.
378/// let unlabelled = DurationP::default();
379/// ```
380///
381/// The positive case, for contrast — naming the provenance is the only way in, and it
382/// is one word:
383///
384/// ```
385/// # use microvms_domain::cost::{DurationP, Provenance};
386/// # use std::time::Duration;
387/// let timed = DurationP::Measured(Duration::from_secs(3600));
388/// assert_eq!(timed.provenance(), Provenance::Measured);
389/// ```
390#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
391pub enum DurationP {
392 /// A clock ran and this is what it read.
393 Measured(Duration),
394 /// A hypothetical: an estimate's input, or a documented minimum nobody timed.
395 Projected(Duration),
396}
397
398impl DurationP {
399 /// A timed phase, from seconds a caller measured as a float.
400 ///
401 /// Fallible because the float is: see [`duration_of_secs_f64`].
402 pub fn measured_secs_f64(seconds: f64) -> Result<DurationP, Error> {
403 Ok(DurationP::Measured(duration_of_secs_f64(seconds)?))
404 }
405
406 /// A hypothetical phase, from seconds a caller supplied as a float.
407 pub fn projected_secs_f64(seconds: f64) -> Result<DurationP, Error> {
408 Ok(DurationP::Projected(duration_of_secs_f64(seconds)?))
409 }
410
411 /// The span, without its label. Named so a call site that drops the provenance
412 /// is visible as one.
413 pub fn duration(self) -> Duration {
414 match self {
415 DurationP::Measured(duration) | DurationP::Projected(duration) => duration,
416 }
417 }
418
419 /// How this duration is known.
420 pub fn provenance(self) -> Provenance {
421 match self {
422 DurationP::Measured(_) => Provenance::Measured,
423 DurationP::Projected(_) => Provenance::Projected,
424 }
425 }
426
427 /// The span as exact decimal seconds, for the money path.
428 pub fn seconds(self) -> Decimal {
429 seconds_of(self.duration())
430 }
431
432 /// The span as an f64, for a JSON envelope.
433 ///
434 /// **Lossy, and named so**, exactly like
435 /// [`ResidencyComparison::break_even_seconds_f64`]. The exact answer is
436 /// [`DurationP::seconds`]; this exists because `cli.py:743` emits a line item's
437 /// `duration.seconds` as a JSON *number* and the two clients' envelopes have to be
438 /// substitutable. Seconds, not dollars — no money figure has an f64 accessor
439 /// (COST-2), and that asymmetry is the whole rule: a consumer doing arithmetic on
440 /// a duration should not have to branch on which client produced the envelope,
441 /// while a consumer doing arithmetic on a dollar figure should be reading the
442 /// string and told so.
443 ///
444 /// (cli.py line numbers resolve at `git show 'c4d396e^:clients/python/src/microvms_agentd/cli.py'` — the retired oracle.)
445 pub fn seconds_f64(self) -> f64 {
446 self.duration().as_secs_f64()
447 }
448
449 /// True only for a timed span. Read by [`CostReport::fully_measured`].
450 pub fn is_measured(self) -> bool {
451 matches!(self, DurationP::Measured(_))
452 }
453}
454
455/// The Python's `f"{seconds:g}s ({provenance})"`, so a report line reads the same in
456/// either client.
457impl fmt::Display for DurationP {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 write!(f, "{}s ({})", self.seconds().normalize(), self.provenance())
460 }
461}
462
463// ── dollars, which are always estimates (COST-2) ─────────────────────────────
464
465/// Dollars derived from published rates. Not the bill.
466///
467/// There is deliberately no `ActualUsd` alongside this: a client that never sees an
468/// invoice cannot produce an actual. And there is deliberately **no way to get an
469/// f64 out** (COST-2) — no `From<EstimatedUsd> for f64`, no `Into`, no `Deref`, no
470/// `as_f64`. The Python original could only omit `__float__` and hope; the cheapest
471/// way to launder an estimate into a bill is `f"${float(x):.2f}"`, and the Rust
472/// equivalent does not compile.
473///
474/// [`EstimatedUsd::amount`] returns a [`Decimal`], which is one visible step and
475/// keeps the type name at the call site. A caller who genuinely needs a float
476/// reaches for `Decimal::to_f64`, which is two named steps and a `Option` — and
477/// which is a decision a reviewer can see.
478///
479/// [`Add`](std::ops::Add) is implemented only against itself, so summing an amount
480/// that might be [`Amount::Unpriced`] is a type error rather than a silent zero.
481///
482/// # The coercion does not compile (COST-2)
483///
484/// The requirement is about an impl that is *absent*, so the check has to be a
485/// program that fails to build. `compile_fail` doctests are that check, and they are
486/// run by `cargo test` like any other:
487///
488/// The error code is pinned on each one, because a bare `compile_fail` passes for
489/// *any* build failure — including a typo in the test itself, which would leave the
490/// guard green while measuring nothing.
491///
492/// ```compile_fail,E0277
493/// # use microvms_domain::cost::EstimatedUsd;
494/// # use rust_decimal_macros::dec;
495/// let estimate = EstimatedUsd::new(dec!(1.23));
496/// // No `From<EstimatedUsd> for f64`, so no `Into` either. This is the line the
497/// // Python client could only ask a reader not to write.
498/// let laundered: f64 = estimate.into();
499/// ```
500///
501/// ```compile_fail,E0614
502/// # use microvms_domain::cost::EstimatedUsd;
503/// # use rust_decimal_macros::dec;
504/// let estimate = EstimatedUsd::new(dec!(1.23));
505/// // No `Deref` and no public field: the figure comes out through `amount()`, which
506/// // names the type at the call site.
507/// let bare = *estimate;
508/// ```
509///
510/// ```compile_fail,E0308
511/// # use microvms_domain::cost::{Amount, EstimatedUsd};
512/// # use rust_decimal_macros::dec;
513/// let estimate = EstimatedUsd::new(dec!(1.23));
514/// let unknown = Amount::unpriced("no rate is published");
515/// // `Add` is implemented only against `EstimatedUsd`, so the Python's runtime
516/// // `TypeError` from `NotImplemented` is a compile error here (COST-4).
517/// let wrong = estimate + unknown;
518/// ```
519#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
520pub struct EstimatedUsd(Decimal);
521
522impl EstimatedUsd {
523 /// Zero dollars, which is a legitimate *priced* figure — a zero-length hold, a
524 /// zero-cycle comparison — and is not the same claim as [`Amount::Unpriced`].
525 pub const ZERO: EstimatedUsd = EstimatedUsd(Decimal::ZERO);
526
527 /// An estimate from a decimal figure already in the money path.
528 ///
529 /// Takes a [`Decimal`] and not an f64, so this constructor cannot be the second
530 /// float boundary: [`gb_decimal`] and [`seconds_of`] are the only two, which is
531 /// what makes COST-6's "exactly once" checkable by reading them.
532 pub fn new(amount: Decimal) -> EstimatedUsd {
533 EstimatedUsd(amount)
534 }
535
536 /// The figure, in decimal.
537 ///
538 /// The one visible step out. Deliberately not `Into<Decimal>`: an explicit call
539 /// keeps `EstimatedUsd` in the reader's view at the point the label is dropped.
540 pub fn amount(self) -> Decimal {
541 self.0
542 }
543
544 /// The figure rendered at display precision, without the estimate label.
545 ///
546 /// For a caller assembling its own sentence. [`fmt::Display`] is what carries
547 /// the label, and it is what a report renders through.
548 pub fn amount_string(self) -> String {
549 render_amount(self.0)
550 }
551
552 /// A dollar figure from a caller's decimal string — a budget, typically.
553 ///
554 /// A string and not an f64, so this is not a third float boundary beside
555 /// [`gb_decimal`] and [`seconds_of`]: the caller typed a decimal, and parsing
556 /// the text they typed keeps `0.1` meaning a tenth rather than the nearest
557 /// binary float. Negative is refused here rather than compared later, because
558 /// every total this could be compared against is non-negative and a negative
559 /// budget can only ever be a sign error the comparison would launder into
560 /// "always breached".
561 pub fn parse(text: &str) -> Result<EstimatedUsd, Error> {
562 let amount = Decimal::from_str(text.trim()).map_err(|source| {
563 Error::invalid_arg(format!(
564 "{text:?} is not a dollar figure: money arithmetic here is decimal end to \
565 end, so the value must read as a plain decimal like 1.50"
566 ))
567 .with_source(source)
568 })?;
569 if amount.is_sign_negative() {
570 return Err(Error::invalid_arg(format!(
571 "a budget cannot be negative: {text}"
572 )));
573 }
574 Ok(EstimatedUsd(amount))
575 }
576}
577
578impl std::ops::Add for EstimatedUsd {
579 type Output = EstimatedUsd;
580
581 /// Only an estimate adds to an estimate.
582 ///
583 /// There is no `Add<Amount>` and no `Add<Unpriced>`, so the Python's
584 /// `NotImplemented` — a runtime `TypeError` — becomes a compile error. This is
585 /// the last line of defence for COST-4: even a consumer that ignores
586 /// [`CostReport::is_complete`] cannot sum an unknown into a dollar figure.
587 fn add(self, other: EstimatedUsd) -> EstimatedUsd {
588 EstimatedUsd(self.0 + other.0)
589 }
590}
591
592impl std::iter::Sum for EstimatedUsd {
593 fn sum<I: Iterator<Item = EstimatedUsd>>(iter: I) -> EstimatedUsd {
594 iter.fold(EstimatedUsd::ZERO, std::ops::Add::add)
595 }
596}
597
598/// A figure copied out of a terminal loses its docstring, so the label travels in
599/// `Display` rather than sitting in the type name alone.
600impl fmt::Display for EstimatedUsd {
601 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
602 write!(f, "~${} (estimated)", render_amount(self.0))
603 }
604}
605
606/// What a line item's cost can be (COST-3).
607///
608/// Any consumer that matches on this has to handle the unknown case, which is the
609/// property the whole module rests on. [`Amount::Unpriced`] is distinct from
610/// `Amount::Estimated(EstimatedUsd::ZERO)` on purpose: zero is a claim about the
611/// bill, unpriced is a claim about the documentation.
612#[derive(Clone, Debug, Eq, PartialEq)]
613pub enum Amount {
614 Estimated(EstimatedUsd),
615 /// A quantity we can measure but cannot price, because no rate is published.
616 ///
617 /// The reason is a field and not optional, so the line item can say *why* rather
618 /// than just that it cannot. [`BUILD_UNPRICED_REASON`] is the one this module
619 /// raises.
620 Unpriced {
621 reason: String,
622 },
623}
624
625impl Amount {
626 /// A priced amount from a decimal figure.
627 pub fn estimated(amount: Decimal) -> Amount {
628 Amount::Estimated(EstimatedUsd::new(amount))
629 }
630
631 /// An unpriced amount, with the reason that goes on the line item.
632 pub fn unpriced(reason: impl Into<String>) -> Amount {
633 Amount::Unpriced {
634 reason: reason.into(),
635 }
636 }
637
638 /// The estimate, when there is one.
639 ///
640 /// Returns `Option` rather than defaulting to zero, which is the same decision
641 /// as `cli.py`'s JSON omitting the `usd` key entirely for an unpriced line: a
642 /// null gets summed as zero by anything permissive, and that is the one
643 /// arithmetic this module refuses to enable.
644 pub fn estimate(&self) -> Option<EstimatedUsd> {
645 match self {
646 Amount::Estimated(usd) => Some(*usd),
647 Amount::Unpriced { .. } => None,
648 }
649 }
650
651 /// The reason this could not be priced, when it could not.
652 pub fn unpriced_reason(&self) -> Option<&str> {
653 match self {
654 Amount::Estimated(_) => None,
655 Amount::Unpriced { reason } => Some(reason),
656 }
657 }
658
659 /// The stable tag `cli.py` puts in the JSON envelope's `amount.kind`.
660 pub fn kind(&self) -> &'static str {
661 match self {
662 Amount::Estimated(_) => "estimated-usd",
663 Amount::Unpriced { .. } => "unpriced",
664 }
665 }
666}
667
668impl fmt::Display for Amount {
669 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670 match self {
671 Amount::Estimated(usd) => write!(f, "{usd}"),
672 Amount::Unpriced { reason } => write!(f, "unpriced — {reason}"),
673 }
674 }
675}
676
677/// One line item a total could not price: which phase it belonged to, and why.
678///
679/// The phase travels beside the reason because the two answer different questions and
680/// a reader needs both. `cost.py:339-343` renders the *phase names* in the total's
681/// parenthetical — `plus 1 unpriced (image-build)` — because a reason is a paragraph
682/// and a total is one line, while [`Total::unpriced_reasons`] still hands over the
683/// paragraphs for the consumer that wants them. Carrying only the reason, which is
684/// what this type replaced, made the phase unavailable at render time and the Rust
685/// total printed the paragraph.
686#[derive(Clone, Debug, Eq, PartialEq)]
687pub struct UnpricedLine {
688 /// The phase the line belonged to. What the total's parenthetical names.
689 pub phase: CostPhase,
690 /// Why no figure could be attached to it.
691 pub reason: String,
692}
693
694/// A report's total, which is a lower bound whenever anything is unpriced (COST-4).
695///
696/// Two variants rather than a struct with a flag, and that is the upgrade over the
697/// Python's `Total(priced, unpriced=())`: there a consumer could read `.priced` and
698/// never look at `.is_lower_bound`, so the honesty rule depended on the caller
699/// checking. Here the floor is *inside* [`Total::AtLeast`], beside the reasons, so
700/// reaching it means seeing them. A plain sum cannot express "everything we could
701/// price, plus a build AWS will not tell us the price of", and the natural way to
702/// force it to — dropping the unpriced line — is exactly the lie this module exists
703/// not to tell.
704#[derive(Clone, Debug, Eq, PartialEq)]
705pub enum Total {
706 /// Every line item was priced, so this is the whole estimate.
707 Exact(EstimatedUsd),
708 /// At least this much, plus line items with no published rate.
709 AtLeast {
710 /// The sum of the priced lines. Not the total — the total is unknowable.
711 floor: EstimatedUsd,
712 /// The lines that could not be priced, in report order.
713 ///
714 /// Non-empty by construction: [`Total::of`] answers [`Total::Exact`] when
715 /// there is nothing to name, so an `AtLeast` with an empty list — a lower
716 /// bound that will not say what it is missing — is not a value this type
717 /// holds.
718 unpriced: Vec<UnpricedLine>,
719 },
720}
721
722impl Total {
723 /// The total over a report's amounts, each labelled with the phase it came from.
724 ///
725 /// The one place a total is computed, so COST-4 holds for every consumer rather
726 /// than for the ones that remembered. An unpriced amount routes the whole answer
727 /// to [`Total::AtLeast`]; it is never skipped and never summed as zero.
728 ///
729 /// Takes `(phase, amount)` rather than a bare amount so the phase cannot go
730 /// missing on the way in: the rendered total names phases, and a `Total` built
731 /// without them could only fall back to printing reasons.
732 pub fn of<'a>(amounts: impl IntoIterator<Item = (CostPhase, &'a Amount)>) -> Total {
733 let mut floor = EstimatedUsd::ZERO;
734 let mut unpriced: Vec<UnpricedLine> = Vec::new();
735 for (phase, amount) in amounts {
736 match amount {
737 Amount::Estimated(usd) => floor = floor + *usd,
738 Amount::Unpriced { reason } => unpriced.push(UnpricedLine {
739 phase,
740 reason: reason.clone(),
741 }),
742 }
743 }
744 if unpriced.is_empty() {
745 Total::Exact(floor)
746 } else {
747 Total::AtLeast { floor, unpriced }
748 }
749 }
750
751 /// Everything that could be priced.
752 ///
753 /// Named `floor` rather than `total` on purpose: for [`Total::AtLeast`] it is a
754 /// lower bound, and a consumer reading a field called `total` would have no
755 /// reason to check which variant it came from.
756 pub fn floor(&self) -> EstimatedUsd {
757 match self {
758 Total::Exact(usd) => *usd,
759 Total::AtLeast { floor, .. } => *floor,
760 }
761 }
762
763 /// True when line items with no published rate are missing from the floor.
764 pub fn is_lower_bound(&self) -> bool {
765 matches!(self, Total::AtLeast { .. })
766 }
767
768 /// The lines that could not be priced. Empty for [`Total::Exact`].
769 pub fn unpriced_lines(&self) -> &[UnpricedLine] {
770 match self {
771 Total::Exact(_) => &[],
772 Total::AtLeast { unpriced, .. } => unpriced,
773 }
774 }
775
776 /// The reasons the unpriced lines could not be priced, in report order. Empty for
777 /// [`Total::Exact`].
778 ///
779 /// Kept beside [`Total::unpriced_lines`] because the reason is what a consumer
780 /// surfacing the *why* wants, while the rendered total names phases.
781 pub fn unpriced_reasons(&self) -> Vec<String> {
782 self.unpriced_lines()
783 .iter()
784 .map(|line| line.reason.clone())
785 .collect()
786 }
787
788 /// The distinct phase names of the unpriced lines, sorted — what the rendered
789 /// total names.
790 ///
791 /// Sorted and deduplicated to match `cost.py:342`'s
792 /// `", ".join(sorted({item.phase.value for item in self.unpriced}))`: two unpriced
793 /// lines in one phase name it once, and the order does not depend on report order.
794 pub fn unpriced_phase_names(&self) -> Vec<&'static str> {
795 let mut names: Vec<&'static str> = self
796 .unpriced_lines()
797 .iter()
798 .map(|line| line.phase.as_str())
799 .collect();
800 names.sort_unstable();
801 names.dedup();
802 names
803 }
804}
805
806/// "at least" is the whole point, so it leads.
807///
808/// The parenthetical names **phases**, not reasons, which is `cost.py:343` verbatim. A
809/// reason is a sentence explaining what AWS does not publish, and joining several of
810/// them turns a one-line total into a paragraph the two clients cannot both print.
811impl fmt::Display for Total {
812 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
813 match self {
814 Total::Exact(usd) => write!(f, "{usd}"),
815 Total::AtLeast { floor, unpriced } => write!(
816 f,
817 "at least {floor}, plus {} unpriced ({})",
818 unpriced.len(),
819 self.unpriced_phase_names().join(", ")
820 ),
821 }
822 }
823}
824
825// ── the rate table ───────────────────────────────────────────────────────────
826
827/// us-east-1 rates, pinned to when they were read and where from.
828///
829/// The three booleans and the absent increment are documented *facts* rather than
830/// settings, and they are fields because each one is a mistake someone would
831/// otherwise make in arithmetic: blending vCPU into a GB-second, adding a per-invoke
832/// charge by analogy with Lambda Functions, rounding a 200 ms exec up to a 1-second
833/// increment, or subtracting a free tier that does not exist.
834///
835/// # Why the rate fields are private (COST-9)
836///
837/// The Python's `RateTable` was a plain frozen dataclass, so anyone could construct
838/// one — which is how `pricing.py`'s ARM refusal ended up guarding only the fetch
839/// path. Here the five rates are private and there are exactly two ways to obtain a
840/// table: the pinned [`pinned_rates`], and [`RateTable::from_catalog`], which refuses a
841/// catalog missing its ARM compute line rather than substituting the x86 one. So
842/// "compute is priced from the ARM rate" is a property of the type rather than of a
843/// code path that can be bypassed.
844#[derive(Clone, Debug, Eq, PartialEq)]
845pub struct RateTable {
846 region: Region,
847 source_url: String,
848 retrieved: CalendarDate,
849 vcpu_second: Decimal,
850 gb_second: Decimal,
851 storage_gb_month: Decimal,
852 snapshot_read_gb: Decimal,
853 snapshot_write_gb: Decimal,
854 minimum_retention: Duration,
855}
856
857impl RateTable {
858 /// The region these rates price.
859 ///
860 /// Not a cosmetic label: eu-west-1 is 5.3% over us-east-1 on compute and 19% on
861 /// snapshot storage, and ap-northeast-1 is 16.4% and 20%. A caller in Tokyo
862 /// reading the us-east-1 table understates their snapshot write bill by 22.6%,
863 /// which is not a rounding difference and would never show up as staleness.
864 pub fn region(&self) -> &Region {
865 &self.region
866 }
867
868 /// Where these figures came from, as an instruction a reader can act on.
869 pub fn source_url(&self) -> &str {
870 &self.source_url
871 }
872
873 /// When they were read.
874 pub fn retrieved(&self) -> CalendarDate {
875 self.retrieved
876 }
877
878 /// Per vCPU-second, billed separately from memory.
879 pub fn vcpu_second(&self) -> Decimal {
880 self.vcpu_second
881 }
882
883 /// Per GB-second of *baseline* memory, billed separately from vCPU.
884 pub fn gb_second(&self) -> Decimal {
885 self.gb_second
886 }
887
888 /// Per GB-month of snapshot storage.
889 ///
890 /// The one derived figure: the Pricing API quotes this per GB-hour, and this
891 /// table holds per GB-month. See [`HOURS_PER_MONTH`].
892 pub fn storage_gb_month(&self) -> Decimal {
893 self.storage_gb_month
894 }
895
896 /// Per GB read on a launch or a resume.
897 pub fn snapshot_read_gb(&self) -> Decimal {
898 self.snapshot_read_gb
899 }
900
901 /// Per GB written on a suspend. Roughly 2.5x the read rate, so a cycle costed
902 /// with one rate twice is wrong in whichever direction it picked.
903 pub fn snapshot_write_gb(&self) -> Decimal {
904 self.snapshot_write_gb
905 }
906
907 /// The documented storage minimum (COST-8).
908 ///
909 /// On the rate row itself, so it applies to anything stored there.
910 pub fn minimum_retention(&self) -> Duration {
911 self.minimum_retention
912 }
913
914 /// The same minimum in whole days, which is the unit the note on a floored line
915 /// item quotes.
916 ///
917 /// Here rather than as an `as_secs() / 86_400` at the one call site, because that
918 /// spelling is a second place the seconds-to-days convention lives: the field is
919 /// the only thing that knows how long the window is, and a division written beside
920 /// the message would keep saying "7-day" after a rate row moved to a fortnight.
921 /// Truncating rather than rounding is deliberate — a floor quoted as longer than it
922 /// is would overstate the charge a reader is being told about.
923 pub fn minimum_retention_days(&self) -> u64 {
924 self.minimum_retention.as_secs() / SECONDS_PER_DAY
925 }
926
927 /// MicroVMs bills per second with no per-request charge.
928 ///
929 /// A method returning a constant rather than a field, because it is a documented
930 /// fact and not a table entry someone could edit into a different claim. The
931 /// Lambda free tier is Functions-only and no MicroVMs free tier is published.
932 pub fn per_request(&self) -> Decimal {
933 Decimal::ZERO
934 }
935
936 /// vCPU and memory are two line items, as the pricing page prices them.
937 ///
938 /// A blended GB-second figure cannot be reconciled against a Cost Explorer
939 /// breakdown that keeps them apart.
940 pub fn bills_vcpu_and_memory_separately(&self) -> bool {
941 true
942 }
943
944 /// No published free tier.
945 pub fn free_tier(&self) -> bool {
946 false
947 }
948
949 /// The minimum billing increment, which is **not published**.
950 ///
951 /// `None` means not published, not "one second". Nothing here rounds a duration
952 /// up, because inventing an increment would overcharge every short exec in a
953 /// report and there is no source for one.
954 pub fn minimum_billing_increment(&self) -> Option<Duration> {
955 None
956 }
957
958 /// How many days ago these rates were read.
959 pub fn age_days(&self, today: CalendarDate) -> i64 {
960 today.days_since(self.retrieved)
961 }
962
963 /// Whether the table is past [`STALE_AFTER_DAYS`] (COST-7).
964 pub fn is_stale(&self, today: CalendarDate) -> bool {
965 self.age_days(today) > STALE_AFTER_DAYS
966 }
967
968 /// The warning text, or `None` when the table is fresh.
969 ///
970 /// Text rather than a bool because the warning has to survive into whatever
971 /// renders it, carrying the retrieval date and the URL — a figure copied out of a
972 /// terminal loses everything else.
973 pub fn staleness(&self, today: CalendarDate) -> Option<String> {
974 if !self.is_stale(today) {
975 return None;
976 }
977 Some(format!(
978 "rate table for {} was retrieved {}, {} days ago (stale after {STALE_AFTER_DAYS}) — \
979 re-read {} before trusting these figures",
980 self.region,
981 self.retrieved,
982 self.age_days(today),
983 self.source_url,
984 ))
985 }
986}
987
988/// Read 2026-08-07, us-east-1, and recorded in `docs/PLATFORM.md` under "What
989/// actually costs money".
990///
991/// Four rates appear on the Lambda pricing page as written; `storage_gb_month` is
992/// *derived*, because the AWS Pricing API quotes snapshot storage per GB-hour and
993/// this table holds per GB-month. Change any of them and change that document in the
994/// same commit.
995///
996/// A drift check against the Pricing API — not [`STALE_AFTER_DAYS`] — is what
997/// actually tells you whether a rate moved; the staleness warning only ever said
998/// that nobody had looked. See [`RateCatalog`].
999///
1000/// Only the ARM figures are correct here and it is not a preference: MicroVMs are
1001/// ARM64-only, the x86 compute rates in the same catalog are 17.9% higher, and this
1002/// table used to hold the right ones by luck rather than by construction (COST-9).
1003///
1004/// A function rather than a `static`, because [`Region`] and the URL own heap
1005/// allocations. Cheap — five decimal copies and one small string — and called once
1006/// per report.
1007pub fn pinned_rates() -> RateTable {
1008 RateTable {
1009 region: Region::UsEast1,
1010 source_url: "https://aws.amazon.com/lambda/pricing/".to_string(),
1011 retrieved: CalendarDate::from_ymd(2026, 8, 7),
1012 vcpu_second: dec!(0.0000276944),
1013 gb_second: dec!(0.0000036667),
1014 // $0.0001111111 per GB-hour x 730 hours. Was 0.08 — a plausible-looking
1015 // round number that understated every stored GB by 1.37%, which is the whole
1016 // argument for deriving it from the API figure rather than reading a page.
1017 storage_gb_month: dec!(0.0811111030),
1018 snapshot_read_gb: dec!(0.00155),
1019 snapshot_write_gb: dec!(0.0038),
1020 minimum_retention: MINIMUM_RETENTION,
1021 }
1022}
1023
1024// ── the catalog, and the ARM-only rule (COST-9) ───────────────────────────────
1025
1026/// One rate the catalog has to carry, and what it fills.
1027///
1028/// The Python held this as `pricing.MICROVM_LINES`, a tuple of `RateLine` records
1029/// naming a `group`, a `usagetype`, a `unit`, and — for the two compute lines — an
1030/// `x86_group` probed only when the ARM line was missing. Same shape here, as a
1031/// closed enum: the catalog has five lines this client reads, and a sixth is not a
1032/// value that exists.
1033#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1034pub enum CatalogLine {
1035 VcpuSecond,
1036 MemoryGbSecond,
1037 /// Quoted per GB-**hour** by the API. Converted once, in
1038 /// [`RateTable::from_catalog`].
1039 SnapshotStorageGbHour,
1040 SnapshotReadGb,
1041 SnapshotWriteGb,
1042}
1043
1044impl CatalogLine {
1045 /// Every line, in the order the Python's `MICROVM_LINES` declares them.
1046 pub const ALL: [CatalogLine; 5] = [
1047 CatalogLine::VcpuSecond,
1048 CatalogLine::MemoryGbSecond,
1049 CatalogLine::SnapshotStorageGbHour,
1050 CatalogLine::SnapshotReadGb,
1051 CatalogLine::SnapshotWriteGb,
1052 ];
1053
1054 /// The region-independent `group` attribute, which is what a fetch filters on.
1055 ///
1056 /// `usagetype` carries a location prefix in every region except us-east-1
1057 /// (`Lambda-MicroVM-vCPU-Second-ARM` is `USW2-Lambda-MicroVM-vCPU-Second-ARM` in
1058 /// us-west-2), so comparing raw usage types across regions matches nothing and
1059 /// yields a table of holes rather than an error. `group` does not.
1060 pub fn group(self) -> &'static str {
1061 match self {
1062 CatalogLine::VcpuSecond => "AWS-Lambda-MicroVM-vCPU-Second-ARM",
1063 CatalogLine::MemoryGbSecond => "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
1064 CatalogLine::SnapshotStorageGbHour => "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
1065 CatalogLine::SnapshotReadGb => "AWS-Lambda-MicroVM-Snapshot-Read-GB",
1066 CatalogLine::SnapshotWriteGb => "AWS-Lambda-MicroVM-Snapshot-Write-GB",
1067 }
1068 }
1069
1070 /// The [`RateTable`] field this fills, as the Python's `RateLine.field` spells
1071 /// it — so a drift report names the same thing in both clients.
1072 pub fn field(self) -> &'static str {
1073 match self {
1074 CatalogLine::VcpuSecond => "vcpu_second",
1075 CatalogLine::MemoryGbSecond => "gb_second",
1076 CatalogLine::SnapshotStorageGbHour => "storage_gb_month",
1077 CatalogLine::SnapshotReadGb => "snapshot_read_gb",
1078 CatalogLine::SnapshotWriteGb => "snapshot_write_gb",
1079 }
1080 }
1081
1082 /// The unit the API must report for this line.
1083 ///
1084 /// Checked rather than assumed. It is the only signal available if AWS restates
1085 /// storage per GB-month: the number would change by 730x and every arithmetic
1086 /// check downstream would still pass, because they all read the same table.
1087 pub fn unit(self) -> &'static str {
1088 match self {
1089 CatalogLine::VcpuSecond => "vCPU-Seconds",
1090 CatalogLine::MemoryGbSecond => "GB-Seconds",
1091 CatalogLine::SnapshotStorageGbHour => "GB-Hours",
1092 CatalogLine::SnapshotReadGb | CatalogLine::SnapshotWriteGb => "GB",
1093 }
1094 }
1095
1096 /// The x86 sibling, for the two compute lines that have one (COST-9).
1097 ///
1098 /// Named so the refusal can say what it is refusing. MicroVMs are ARM64-only —
1099 /// the `Architecture` shape is `enum: ['ARM_64']` with no other member — so the
1100 /// x86 rate can never apply, and it is 17.9% higher. Snapshot lines have no
1101 /// architecture variant, so there is nothing to refuse.
1102 pub fn x86_group(self) -> Option<&'static str> {
1103 match self {
1104 CatalogLine::VcpuSecond => Some("AWS-Lambda-MicroVM-vCPU-Second"),
1105 CatalogLine::MemoryGbSecond => Some("AWS-Lambda-MicroVM-Memory-GB-Second"),
1106 CatalogLine::SnapshotStorageGbHour
1107 | CatalogLine::SnapshotReadGb
1108 | CatalogLine::SnapshotWriteGb => None,
1109 }
1110 }
1111
1112 /// True for storage, quoted per GB-hour where [`RateTable`] holds per GB-month.
1113 pub fn is_per_hour(self) -> bool {
1114 matches!(self, CatalogLine::SnapshotStorageGbHour)
1115 }
1116}
1117
1118/// A priced line item as the catalog states it: a group, a unit, and one USD figure.
1119///
1120/// The USD figure is a [`Decimal`] and not an f64 because the API quotes it as a
1121/// string with ten significant digits — parsing it as a float and converting back
1122/// would lose exactly the precision the string was carrying.
1123#[derive(Clone, Debug, Eq, PartialEq)]
1124pub struct CatalogEntry {
1125 /// The region-independent `group`. Compared against [`CatalogLine::group`].
1126 pub group: String,
1127 /// The unit the API reported. Compared against [`CatalogLine::unit`].
1128 pub unit: String,
1129 /// The rate, in USD per that unit.
1130 pub usd: Decimal,
1131}
1132
1133/// What a rate source offers for one region, before it becomes a [`RateTable`].
1134///
1135/// This is the parse boundary COST-9 lives at. A transport — the Pricing API, a
1136/// recorded fixture, a `mise run live:rates` dump — assembles one of these, and
1137/// [`RateTable::from_catalog`] is the only door from here into a priced report. So
1138/// the ARM refusal cannot be bypassed by building a table directly, which is what it
1139/// could be in the Python.
1140///
1141/// Deliberately holds *every* entry the source offered, including the x86 compute
1142/// rates. A catalog that filtered them out could not tell "the ARM line is gone and
1143/// the x86 one is right there" from "the whole catalog is empty", and those are
1144/// different repairs: work out what AWS renamed, versus check the region.
1145#[derive(Clone, Debug, Default, Eq, PartialEq)]
1146pub struct RateCatalog {
1147 entries: Vec<CatalogEntry>,
1148}
1149
1150impl RateCatalog {
1151 /// An empty catalog, to add entries to.
1152 pub fn new() -> RateCatalog {
1153 RateCatalog {
1154 entries: Vec::new(),
1155 }
1156 }
1157
1158 /// Records one priced line.
1159 #[must_use]
1160 pub fn with_entry(
1161 mut self,
1162 group: impl Into<String>,
1163 unit: impl Into<String>,
1164 usd: Decimal,
1165 ) -> RateCatalog {
1166 self.entries.push(CatalogEntry {
1167 group: group.into(),
1168 unit: unit.into(),
1169 usd,
1170 });
1171 self
1172 }
1173
1174 /// Every entry for a group. Normally exactly one.
1175 fn matching(&self, group: &str) -> Vec<&CatalogEntry> {
1176 self.entries
1177 .iter()
1178 .filter(|entry| entry.group == group)
1179 .collect()
1180 }
1181
1182 /// Whether the catalog offered nothing at all.
1183 pub fn is_empty(&self) -> bool {
1184 self.entries.is_empty()
1185 }
1186}
1187
1188impl RateTable {
1189 /// A table for one region, built from a catalog — or a refusal (COST-9).
1190 ///
1191 /// All or nothing. A partial table would price a run at less than it costs, and
1192 /// the caller has no way to see which field was quietly left at a stale value.
1193 ///
1194 /// Four ways this refuses, each a way the catalog could change and still parse:
1195 /// a missing ARM compute line whose x86 sibling is present (the substitution that
1196 /// would look entirely healthy and inflate every estimate by 17.9%), a missing
1197 /// line with no sibling, a restated unit, and two products where there was one.
1198 pub fn from_catalog(
1199 region: Region,
1200 source_url: impl Into<String>,
1201 retrieved: CalendarDate,
1202 catalog: &RateCatalog,
1203 ) -> Result<RateTable, Error> {
1204 let mut rates: Vec<Decimal> = Vec::with_capacity(CatalogLine::ALL.len());
1205 for line in CatalogLine::ALL {
1206 let matches = catalog.matching(line.group());
1207 let entry = match matches.as_slice() {
1208 [] => return Err(missing_line_error(®ion, line, catalog)),
1209 [only] => *only,
1210 many => {
1211 return Err(Error::invalid_arg(format!(
1212 "{} in {region} returned {} products where one group in one region has \
1213 always been one product; the catalog gained a dimension this client \
1214 does not know how to choose between, and picking one would silently \
1215 select a rate",
1216 line.group(),
1217 many.len(),
1218 )));
1219 }
1220 };
1221 if entry.unit != line.unit() {
1222 return Err(Error::invalid_arg(format!(
1223 "{} in {region} is now quoted per {:?}, not {:?}; the conversion into the \
1224 rate table no longer holds and every downstream figure would still look \
1225 plausible",
1226 line.group(),
1227 entry.unit,
1228 line.unit(),
1229 )));
1230 }
1231 // The API quotes storage per GB-hour. Converting here rather than at
1232 // every use site keeps `RateTable` one convention deep, which is the
1233 // whole reason `SECONDS_PER_MONTH` exists.
1234 rates.push(if line.is_per_hour() {
1235 entry.usd * HOURS_PER_MONTH
1236 } else {
1237 entry.usd
1238 });
1239 }
1240 Ok(RateTable {
1241 region,
1242 source_url: source_url.into(),
1243 retrieved,
1244 vcpu_second: rates[0],
1245 gb_second: rates[1],
1246 storage_gb_month: rates[2],
1247 snapshot_read_gb: rates[3],
1248 snapshot_write_gb: rates[4],
1249 // The catalog prices line items and says nothing about a one-week storage
1250 // minimum, a per-request charge, a billing increment, or a free tier. So
1251 // a fetched table is authoritative on *rates* and still hand-read on
1252 // *rules*, and dropping the retention floor here would understate a
1253 // create-and-destroy suite by four orders of magnitude.
1254 minimum_retention: MINIMUM_RETENTION,
1255 })
1256 }
1257}
1258
1259/// The error for a line the catalog does not have (COST-9).
1260///
1261/// Split out because the ARM case needs to say more than "missing": the x86 sibling
1262/// is right there, 17.9% higher, the same shape, and it parses. The tempting fix is
1263/// to use it, and every estimate would inflate while nothing said so — so the message
1264/// names the rate it is refusing and the magnitude of the error that substituting it
1265/// would introduce.
1266fn missing_line_error(region: &Region, line: CatalogLine, catalog: &RateCatalog) -> Error {
1267 if catalog.is_empty() {
1268 return Error::invalid_arg(format!(
1269 "the rate catalog has no MicroVM line items for {region} at all, so {} cannot be \
1270 filled. Measured 2026-08-07, pricing coverage and service availability coincide: a \
1271 region that does not price MicroVMs does not run them either, and answers \
1272 lambda-microvms with AccessDeniedException and a null message that reads exactly \
1273 like an IAM problem it is not. Check the region before auditing a policy",
1274 line.field(),
1275 ));
1276 }
1277 let Some(x86_group) = line.x86_group() else {
1278 return Error::invalid_arg(format!(
1279 "{region} prices MicroVMs but has no {} line item, so {} cannot be filled",
1280 line.group(),
1281 line.field(),
1282 ));
1283 };
1284 match catalog.matching(x86_group).first() {
1285 Some(sibling) => Error::invalid_arg(format!(
1286 "{region} has no {}, only the x86 rate {x86_group} at ${}. MicroVMs are ARM64-only \
1287 — the Architecture shape is enum ['ARM_64'] with no other member — so the x86 rate \
1288 can never apply and is not substituted for {}; doing so would overstate every \
1289 compute figure by roughly 18%",
1290 line.group(),
1291 sibling.usd,
1292 line.field(),
1293 )),
1294 None => Error::invalid_arg(format!(
1295 "{region} has neither {} nor {x86_group}, so {} cannot be filled",
1296 line.group(),
1297 line.field(),
1298 )),
1299 }
1300}
1301
1302// ── phases and billing lines ─────────────────────────────────────────────────
1303
1304/// The line items AWS bills, spelled as separately as AWS bills them.
1305///
1306/// vCPU and memory are two entries rather than one blended GB-second because that is
1307/// how the pricing page prices them, and a blended figure cannot be reconciled
1308/// against a Cost Explorer breakdown that keeps them apart.
1309#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1310pub enum BillingLine {
1311 Vcpu,
1312 Memory,
1313 SnapshotStorage,
1314 SnapshotRead,
1315 SnapshotWrite,
1316}
1317
1318impl BillingLine {
1319 /// The wire spelling, identical to the Python `StrEnum` member.
1320 pub fn as_str(self) -> &'static str {
1321 match self {
1322 BillingLine::Vcpu => "vcpu",
1323 BillingLine::Memory => "memory",
1324 BillingLine::SnapshotStorage => "snapshot-storage",
1325 BillingLine::SnapshotRead => "snapshot-read",
1326 BillingLine::SnapshotWrite => "snapshot-write",
1327 }
1328 }
1329}
1330
1331impl fmt::Display for BillingLine {
1332 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1333 f.write_str(self.as_str())
1334 }
1335}
1336
1337/// The lifecycle a sandbox goes through, as the phases that cost money.
1338#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1339pub enum CostPhase {
1340 ImageBuild,
1341 ImageStorage,
1342 Launch,
1343 Running,
1344 Suspended,
1345 Suspend,
1346 Resume,
1347}
1348
1349impl CostPhase {
1350 /// Every phase, in lifecycle order.
1351 ///
1352 /// Public because a caller that has to *judge* a phase string needs the list to
1353 /// refuse with, and the two bindings each grew their own copy of this array before
1354 /// it existed here — a parallel table that would have gone stale the first time a
1355 /// phase was added. [`CostPhase::from_str`] is the reader; the round-trip test below
1356 /// is what stops a variant being added to the enum and forgotten here.
1357 pub const ALL: [CostPhase; 7] = [
1358 CostPhase::ImageBuild,
1359 CostPhase::ImageStorage,
1360 CostPhase::Launch,
1361 CostPhase::Running,
1362 CostPhase::Suspended,
1363 CostPhase::Suspend,
1364 CostPhase::Resume,
1365 ];
1366
1367 /// The wire spelling, identical to the Python `StrEnum` member.
1368 pub fn as_str(self) -> &'static str {
1369 match self {
1370 CostPhase::ImageBuild => "image-build",
1371 CostPhase::ImageStorage => "image-storage",
1372 CostPhase::Launch => "launch",
1373 CostPhase::Running => "running",
1374 CostPhase::Suspended => "suspended",
1375 CostPhase::Suspend => "suspend",
1376 CostPhase::Resume => "resume",
1377 }
1378 }
1379}
1380
1381impl FromStr for CostPhase {
1382 type Err = Error;
1383
1384 /// A phase from its wire spelling, refusing anything else with the whole list.
1385 ///
1386 /// Here rather than in each caller because the callers are *bindings*: a Python or
1387 /// JS `report.by_phase("running")` has a bare string where Rust has a variant, so
1388 /// something has to judge it. Both bindings did, each with its own seven-element
1389 /// array — two parallel tables over a closed set, which is one table too many and
1390 /// two too many to keep in step with the enum. This is the single reader, and the
1391 /// list it offers on a refusal is [`CostPhase::ALL`] rather than a written-out
1392 /// sentence, so a phase added to the enum appears in the message without an edit.
1393 ///
1394 /// Note the asymmetry with [`Region::from_str`](crate::Region): a region's refusal
1395 /// exists because an unlisted region is a *plausible* value this client has no
1396 /// evidence for, and there is an opt-in. A phase has no opt-in and needs none — the
1397 /// set is closed by the billing model, so an unrecognized spelling is a typo and
1398 /// nothing more.
1399 fn from_str(phase: &str) -> Result<CostPhase, Error> {
1400 CostPhase::ALL
1401 .into_iter()
1402 .find(|candidate| candidate.as_str() == phase)
1403 .ok_or_else(|| {
1404 let offered = CostPhase::ALL
1405 .iter()
1406 .map(|candidate| candidate.as_str())
1407 .collect::<Vec<_>>()
1408 .join(", ");
1409 Error::invalid_arg(format!(
1410 "{phase:?} is not a cost phase; the phases are {offered}"
1411 ))
1412 })
1413 }
1414}
1415
1416/// `pad` rather than `write_str`, because a phase is a column.
1417///
1418/// `write_str` ignores the formatter's width entirely, so `{:<14}` on a phase silently
1419/// produced a ragged table — every plain-text line's dollar figure started at a
1420/// different offset, and the fix looks like it is already there in the format string.
1421/// [`fmt::Formatter::pad`] is the one spelling that honours width, precision, and
1422/// alignment; `write!(f, "{}", ...)` here would recurse straight back into this
1423/// impl and drop the width again.
1424impl fmt::Display for CostPhase {
1425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1426 f.pad(self.as_str())
1427 }
1428}
1429
1430/// One phase's one billing line: what was consumed, and what that costs.
1431///
1432/// `quantity` and `unit` are kept beside `amount` so a reader can check the
1433/// arithmetic against the rate table instead of trusting the total, which is the only
1434/// defence against a rate that drifts out of date without anyone noticing.
1435#[derive(Clone, Debug, Eq, PartialEq)]
1436pub struct LineItem {
1437 pub phase: CostPhase,
1438 /// `None` only for a phase with no published rate to attribute it to.
1439 pub line: Option<BillingLine>,
1440 pub quantity: Decimal,
1441 pub unit: String,
1442 pub amount: Amount,
1443 pub duration: Option<DurationP>,
1444 pub note: String,
1445}
1446
1447impl LineItem {
1448 /// The quantity at display precision, for a CLI column.
1449 pub fn quantity_string(&self) -> String {
1450 render_quantity(self.quantity)
1451 }
1452}
1453
1454impl fmt::Display for LineItem {
1455 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1456 let consumed = format!("{} {}", self.quantity_string(), self.unit);
1457 let head = format!("{:<14} {:<26} {}", self.phase, consumed, self.amount);
1458 if self.note.is_empty() {
1459 f.write_str(&head)
1460 } else {
1461 write!(f, "{head} [{}]", self.note)
1462 }
1463 }
1464}
1465
1466// ── the report ───────────────────────────────────────────────────────────────
1467
1468/// Per-phase attribution for one sandbox, measured or projected.
1469///
1470/// Holds the rate table it was computed against rather than reaching for the pinned
1471/// default, so a report stays readable — and reproducible — after [`pinned_rates`] is
1472/// updated.
1473#[derive(Clone, Debug, Eq, PartialEq)]
1474pub struct CostReport {
1475 label: String,
1476 size: SizeClass,
1477 rates: RateTable,
1478 items: Vec<LineItem>,
1479 staleness: Option<String>,
1480}
1481
1482impl CostReport {
1483 /// What this report is of, for a header.
1484 pub fn label(&self) -> &str {
1485 &self.label
1486 }
1487
1488 /// The class every compute figure here was billed at (COST-5).
1489 pub fn size(&self) -> SizeClass {
1490 self.size
1491 }
1492
1493 /// The table these figures were computed against.
1494 pub fn rates(&self) -> &RateTable {
1495 &self.rates
1496 }
1497
1498 /// Every line item, in lifecycle order.
1499 pub fn items(&self) -> &[LineItem] {
1500 &self.items
1501 }
1502
1503 /// The staleness warning, when the table was stale at computation time (COST-7).
1504 ///
1505 /// Carried on the report because the warning has to survive into whatever renders
1506 /// it: a library caller with a log filter and a CLI that only wrote stderr would
1507 /// each lose it on their own.
1508 pub fn staleness(&self) -> Option<&str> {
1509 self.staleness.as_deref()
1510 }
1511
1512 /// The line items with a published rate.
1513 pub fn priced(&self) -> impl Iterator<Item = &LineItem> {
1514 self.items
1515 .iter()
1516 .filter(|item| item.amount.estimate().is_some())
1517 }
1518
1519 /// The line items with no published rate.
1520 pub fn unpriced(&self) -> impl Iterator<Item = &LineItem> {
1521 self.items
1522 .iter()
1523 .filter(|item| item.amount.unpriced_reason().is_some())
1524 }
1525
1526 /// The total, which is a lower bound whenever anything is unpriced (COST-4).
1527 pub fn total(&self) -> Total {
1528 Total::of(self.items.iter().map(|item| (item.phase, &item.amount)))
1529 }
1530
1531 /// False whenever any phase has no published rate. See [`Total`].
1532 pub fn is_complete(&self) -> bool {
1533 self.unpriced().next().is_none()
1534 }
1535
1536 /// True only if every duration was timed. An estimate is never this (COST-10).
1537 pub fn fully_measured(&self) -> bool {
1538 let mut durations = self
1539 .items
1540 .iter()
1541 .filter_map(|item| item.duration)
1542 .peekable();
1543 durations.peek().is_some() && durations.all(DurationP::is_measured)
1544 }
1545
1546 /// The line items belonging to one phase.
1547 pub fn by_phase(&self, phase: CostPhase) -> impl Iterator<Item = &LineItem> {
1548 self.items.iter().filter(move |item| item.phase == phase)
1549 }
1550
1551 /// Plain text for a CLI. Leads with what the dollars are, not the dollars.
1552 ///
1553 /// The header is not decoration: a figure copied out of a terminal loses its
1554 /// docstring, so the estimate label and the retrieval date travel with it.
1555 pub fn render(&self) -> String {
1556 let mut lines = vec![
1557 format!("{} — {}", self.label, self.size),
1558 format!(
1559 "dollars are estimates derived from published {} rates (retrieved {}); only Cost \
1560 Explorer knows the bill",
1561 self.rates.region, self.rates.retrieved,
1562 ),
1563 ];
1564 if let Some(warning) = &self.staleness {
1565 lines.push(format!("WARNING: {warning}"));
1566 }
1567 lines.extend(self.items.iter().map(|item| format!(" {item}")));
1568 lines.push(format!("total: {}", self.total()));
1569 lines.join("\n")
1570 }
1571}
1572
1573// ── the line builders ────────────────────────────────────────────────────────
1574
1575/// Compute for one phase, as two line items (COST-5).
1576///
1577/// Both figures read `baseline_*`. Our line bills the minimum floor: the baseline is
1578/// always paid while the VM runs. The peak is provisioned from the start — the guest
1579/// reports it and can use all of it at any moment — and usage above the baseline is
1580/// real and billed by AWS by consumption, per second. This client cannot observe that
1581/// consumption, so it is left out rather than guessed at — our figure is the floor of
1582/// the real bill, which is what the report's own header ("only Cost Explorer knows
1583/// the bill") warns about. See [`crate::sizing`], whose 2 GB class reports 8 GB in
1584/// the guest: reading the peak would overstate the memory line exactly 4x.
1585fn compute_lines(
1586 size: SizeClass,
1587 duration: DurationP,
1588 rates: &RateTable,
1589 phase: CostPhase,
1590) -> Result<[LineItem; 2], Error> {
1591 let seconds = duration.seconds();
1592 // `baseline_gb` and `baseline_vcpu` are the accessors whose doc comments say they
1593 // are the billed figures; `peak_gb`/`peak_vcpu` are deliberately not reachable
1594 // from here. Both cross the float boundary through `gb_decimal` (COST-6).
1595 let vcpu = gb_decimal(size.baseline_vcpu())?;
1596 let memory = gb_decimal(size.baseline_gb())?;
1597 Ok([
1598 LineItem {
1599 phase,
1600 line: Some(BillingLine::Vcpu),
1601 quantity: vcpu * seconds,
1602 unit: "vCPU-seconds".to_string(),
1603 amount: Amount::estimated(vcpu * seconds * rates.vcpu_second()),
1604 duration: Some(duration),
1605 note: format!("{vcpu} vCPU baseline"),
1606 },
1607 LineItem {
1608 phase,
1609 line: Some(BillingLine::Memory),
1610 quantity: memory * seconds,
1611 unit: "GB-seconds".to_string(),
1612 amount: Amount::estimated(memory * seconds * rates.gb_second()),
1613 duration: Some(duration),
1614 note: format!("{memory} GB baseline, billed separately from vCPU"),
1615 },
1616 ])
1617}
1618
1619/// Snapshot storage for a hold, with the documented minimum retention applied
1620/// (COST-8).
1621///
1622/// The minimum is on the rate row itself, so it applies to anything stored there.
1623/// `docs/PLATFORM.md` demonstrates it only for images — a 2 GB image deleted after
1624/// sixty seconds still bills about four cents — and says nothing about a suspend
1625/// snapshot released early, so the note names which case is documented and which is
1626/// the rate row read at face value. Not applying it would understate the one line item
1627/// that dominates a create-and-destroy suite, by four orders of magnitude.
1628fn storage_line(
1629 phase: CostPhase,
1630 gb: Decimal,
1631 held: DurationP,
1632 rates: &RateTable,
1633) -> Result<LineItem, Error> {
1634 let floor = seconds_of(rates.minimum_retention());
1635 let held_seconds = held.seconds();
1636 let billed = held_seconds.max(floor);
1637 let quantity = gb * billed / SECONDS_PER_MONTH;
1638 let mut note = format!("{} GB held {held}", gb.normalize());
1639 if billed > held_seconds {
1640 // The day count comes off the rate row rather than out of a division here: the
1641 // field is the only thing that knows how long the window is, and `as_secs() /
1642 // 86_400` beside the message is a second convention that would keep saying
1643 // "7-day" after the row moved.
1644 note.push_str(&format!(
1645 "; billed {}-day minimum retention ({floor}s) instead",
1646 rates.minimum_retention_days(),
1647 ));
1648 }
1649 Ok(LineItem {
1650 phase,
1651 line: Some(BillingLine::SnapshotStorage),
1652 quantity,
1653 unit: "GB-months".to_string(),
1654 amount: Amount::estimated(quantity * rates.storage_gb_month()),
1655 duration: Some(held),
1656 note,
1657 })
1658}
1659
1660/// A snapshot write or read, billed per GB moved with no time component.
1661fn transfer_line(
1662 phase: CostPhase,
1663 line: BillingLine,
1664 gb: Decimal,
1665 count: u32,
1666 rates: &RateTable,
1667 note_suffix: &str,
1668) -> LineItem {
1669 let rate = if line == BillingLine::SnapshotWrite {
1670 rates.snapshot_write_gb()
1671 } else {
1672 rates.snapshot_read_gb()
1673 };
1674 let quantity = gb * Decimal::from(count);
1675 LineItem {
1676 phase,
1677 line: Some(line),
1678 quantity,
1679 unit: "GB".to_string(),
1680 amount: Amount::estimated(quantity * rate),
1681 duration: None,
1682 note: format!("{count} x {} GB{note_suffix}", gb.normalize()),
1683 }
1684}
1685
1686/// Why the image build has no price.
1687///
1688/// Not a caveat in a doc comment: it is the [`Amount::Unpriced`] reason that appears
1689/// on the line item and in every total that contains it. The build starts a real
1690/// MicroVM to run the Dockerfile, so it plausibly is billed, but AWS does not say and
1691/// we have not measured it.
1692pub const BUILD_UNPRICED_REASON: &str = "AWS does not publish whether the server-side image build is billed as compute; the build \
1693 runs a real MicroVM, so treating it as free would understate the run";
1694
1695/// The image build: a real phase with a real duration and no published rate (COST-3).
1696///
1697/// An untimed build still gets a line. Omitting it would leave the report looking
1698/// complete, and the report being visibly incomplete is the whole point of the phase
1699/// appearing at all.
1700fn build_line(duration: Option<DurationP>) -> LineItem {
1701 LineItem {
1702 phase: CostPhase::ImageBuild,
1703 line: None,
1704 quantity: duration.map_or(Decimal::ZERO, DurationP::seconds),
1705 unit: if duration.is_some() {
1706 "seconds".to_string()
1707 } else {
1708 "seconds (untimed)".to_string()
1709 },
1710 amount: Amount::unpriced(BUILD_UNPRICED_REASON),
1711 duration,
1712 note: "unknown, not zero — see docs/PLATFORM.md, 'Not published'".to_string(),
1713 }
1714}
1715
1716// ── the reports ──────────────────────────────────────────────────────────────
1717
1718/// What to attribute cost to, for one sandbox's lifecycle.
1719///
1720/// A struct rather than eleven parameters, because the Python's keyword-only
1721/// signature has no direct Rust equivalent and eleven positional arguments is how a
1722/// caller passes `image_gb` where `snapshot_gb` was meant. `Default` gives the same
1723/// "pass only the phases that happened" ergonomics as the Python's defaults.
1724///
1725/// Every duration is a [`DurationP`], so a report built from timed phases and one
1726/// built from a plan are the same shape and are told apart by their own contents
1727/// rather than by which function produced them.
1728#[derive(Clone, Debug, Default, PartialEq)]
1729pub struct RunUsage {
1730 /// Wall-clock time in RUNNING. Bills at baseline whether or not anything is
1731 /// executing — unlike AgentCore Runtime there is no free I/O wait, which is why
1732 /// suspension rather than idleness is the lever.
1733 pub running: Option<DurationP>,
1734 /// Time held suspended. Pays storage only: a suspended VM is frozen.
1735 pub suspended: Option<DurationP>,
1736 /// How long the image build took, if it was timed. Always unpriced (COST-3).
1737 pub image_build: Option<DurationP>,
1738 /// The image's size. Passing this adds both an image-storage line *and* the
1739 /// unpriced build line: an image that has storage cost was built, and the build's
1740 /// price is unknown, so a create-and-destroy report is never complete. A caller
1741 /// reusing an existing image passes `None` and pays neither.
1742 pub image_gb: Option<f64>,
1743 /// How long the image was retained. Defaults to the documented minimum, marked
1744 /// [`Provenance::Projected`] — nobody timed that week either.
1745 pub image_retained: Option<DurationP>,
1746 /// Each cycle pays a snapshot write plus a read.
1747 pub suspend_resume_cycles: u32,
1748 /// The suspend snapshot's size. Defaults to the baseline memory footprint, which
1749 /// is what `docs/PLATFORM.md`'s own worked figures use ("a suspended 2 GB VM pays
1750 /// about $0.16 a month" is 2 GB at the storage rate). Whether a suspend snapshot
1751 /// is baseline-sized or peak-sized is not documented; override it if you have
1752 /// measured otherwise.
1753 pub snapshot_gb: Option<f64>,
1754 /// Whether a launch happened. A launch reads a snapshot.
1755 pub launched: bool,
1756}
1757
1758impl RunUsage {
1759 /// A usage record for a sandbox that launched, with no phase attributed yet.
1760 ///
1761 /// `launched: true` matches the Python's default, and it is the right default:
1762 /// almost every report is of a VM that ran, and the comparison path that does not
1763 /// launch says so explicitly.
1764 pub fn launched() -> RunUsage {
1765 RunUsage {
1766 launched: true,
1767 ..RunUsage::default()
1768 }
1769 }
1770
1771 /// Whether this usage implies a launch: running time, or an image of non-zero size.
1772 ///
1773 /// The rule every surface applies when its caller doesn't say (#255). A launch reads a
1774 /// snapshot, so claiming one that didn't happen adds a transfer line. A zero-sized image
1775 /// isn't one: `Some(0.0)` claims no snapshot read the run never paid for, which is the
1776 /// retired Python CLI's `bool(running_sec or image_gb)`.
1777 pub fn infer_launched(&self) -> bool {
1778 self.running
1779 .is_some_and(|running| !running.duration().is_zero())
1780 || implies_an_image(self.image_gb)
1781 }
1782}
1783
1784/// Whether `image_gb` names an image that was built: set, and not zero.
1785fn implies_an_image(image_gb: Option<f64>) -> bool {
1786 image_gb.is_some_and(|gb| gb != 0.0)
1787}
1788
1789/// The label a run's report carries when its caller names none: the CLI's `cost` and both
1790/// bindings' `run_report`.
1791pub const DEFAULT_RUN_LABEL: &str = "run";
1792
1793/// The label an estimate carries when its caller names none: the CLI's `cost --estimate` and
1794/// both bindings' `estimate_run` (#255).
1795///
1796/// Not "plan": the label is the report's own name for itself, a consumer reads it to tell a
1797/// plan from a receipt, and the surfaces' reports have to be substitutable.
1798pub const DEFAULT_ESTIMATE_LABEL: &str = "estimate";
1799
1800/// Per-phase attribution for one sandbox's lifecycle.
1801///
1802/// `today` decides staleness (COST-7) and is a parameter rather than a clock read, so
1803/// a report is a pure function of its inputs and a test does not have to travel in
1804/// time. A binary passes `CalendarDate::today_utc()` from `microvms_core::prelude`.
1805pub fn run_report(
1806 size: SizeClass,
1807 usage: &RunUsage,
1808 rates: &RateTable,
1809 today: CalendarDate,
1810 label: impl Into<String>,
1811) -> Result<CostReport, Error> {
1812 let snapshot = match usage.snapshot_gb {
1813 Some(gb) => gb_decimal(gb)?,
1814 None => gb_decimal(size.baseline_gb())?,
1815 };
1816 let image_gb = usage.image_gb.map(gb_decimal).transpose()?;
1817 let mut items: Vec<LineItem> = Vec::new();
1818
1819 if let Some(image_gb) = image_gb {
1820 items.push(build_line(usage.image_build));
1821 let held = usage
1822 .image_retained
1823 .unwrap_or(DurationP::Projected(rates.minimum_retention()));
1824 items.push(storage_line(
1825 CostPhase::ImageStorage,
1826 image_gb,
1827 held,
1828 rates,
1829 )?);
1830 } else if usage.image_build.is_some() {
1831 items.push(build_line(usage.image_build));
1832 }
1833
1834 if usage.launched {
1835 // A launch reads a snapshot at the same per-GB rate as a resume. Which
1836 // snapshot's size that read covers is not documented, so it uses the image
1837 // when the caller named one and the memory footprint otherwise.
1838 let read_gb = image_gb.unwrap_or(snapshot);
1839 items.push(transfer_line(
1840 CostPhase::Launch,
1841 BillingLine::SnapshotRead,
1842 read_gb,
1843 1,
1844 rates,
1845 "; 'launch or resume' shares one rate, and which snapshot a launch reads is \
1846 undocumented",
1847 ));
1848 }
1849
1850 if let Some(running) = usage.running {
1851 items.extend(compute_lines(size, running, rates, CostPhase::Running)?);
1852 }
1853
1854 if let Some(suspended) = usage.suspended {
1855 // No compute line at all: a suspended VM is frozen, so it pays storage only.
1856 // Not a compute line multiplied by zero, which would reappear the moment
1857 // someone changed how a duration is derived.
1858 items.push(storage_line(
1859 CostPhase::Suspended,
1860 snapshot,
1861 suspended,
1862 rates,
1863 )?);
1864 }
1865
1866 if usage.suspend_resume_cycles > 0 {
1867 items.push(transfer_line(
1868 CostPhase::Suspend,
1869 BillingLine::SnapshotWrite,
1870 snapshot,
1871 usage.suspend_resume_cycles,
1872 rates,
1873 "",
1874 ));
1875 items.push(transfer_line(
1876 CostPhase::Resume,
1877 BillingLine::SnapshotRead,
1878 snapshot,
1879 usage.suspend_resume_cycles,
1880 rates,
1881 "",
1882 ));
1883 }
1884
1885 Ok(CostReport {
1886 label: label.into(),
1887 size,
1888 rates: rates.clone(),
1889 items,
1890 staleness: rates.staleness(today),
1891 })
1892}
1893
1894/// A plan's phases, in plain seconds, before anything is spent.
1895///
1896/// Separate from [`RunUsage`] on purpose (COST-10): its fields are `f64` seconds
1897/// rather than [`DurationP`], so there is no field an accidentally-`Measured`
1898/// duration could be written into. The wrapping happens in [`estimate_run`], in one
1899/// place, and every one of them is [`Provenance::Projected`].
1900#[derive(Clone, Copy, Debug, Default, PartialEq)]
1901pub struct PlanUsage {
1902 pub running_seconds: f64,
1903 pub suspended_seconds: f64,
1904 pub image_gb: Option<f64>,
1905 pub image_retained_seconds: Option<f64>,
1906 pub suspend_resume_cycles: u32,
1907 pub snapshot_gb: Option<f64>,
1908 pub launched: bool,
1909}
1910
1911impl PlanUsage {
1912 /// A plan for a sandbox that launches, with no phase attributed yet.
1913 pub fn launched() -> PlanUsage {
1914 PlanUsage {
1915 launched: true,
1916 ..PlanUsage::default()
1917 }
1918 }
1919
1920 /// Whether this plan implies a launch: running time, or an image of non-zero size.
1921 ///
1922 /// [`RunUsage::infer_launched`]'s rule over seconds: suspended time alone is a VM that
1923 /// was already running, so it reads no launch snapshot.
1924 pub fn infer_launched(&self) -> bool {
1925 self.running_seconds > 0.0 || implies_an_image(self.image_gb)
1926 }
1927}
1928
1929/// What a plan will cost, before spending anything (COST-10).
1930///
1931/// Takes plain seconds and marks every one of them [`Provenance::Projected`], so the
1932/// resulting report can never claim to be measured. That is the difference between
1933/// this and [`run_report`]: not the arithmetic, which is shared — it delegates — but
1934/// what the durations admit about themselves.
1935pub fn estimate_run(
1936 size: SizeClass,
1937 plan: &PlanUsage,
1938 rates: &RateTable,
1939 today: CalendarDate,
1940 label: impl Into<String>,
1941) -> Result<CostReport, Error> {
1942 // Zero seconds means "this phase did not happen", matching the Python's
1943 // `if running_seconds else None`. A zero-length phase priced at zero would put a
1944 // line on the report claiming a measurement nobody took.
1945 let projected = |seconds: f64| -> Result<Option<DurationP>, Error> {
1946 if seconds == 0.0 {
1947 return Ok(None);
1948 }
1949 Ok(Some(DurationP::projected_secs_f64(seconds)?))
1950 };
1951 let usage = RunUsage {
1952 running: projected(plan.running_seconds)?,
1953 suspended: projected(plan.suspended_seconds)?,
1954 // No `image_build` duration: a plan has not built anything, so there is
1955 // nothing to have timed. The unpriced build line still appears whenever
1956 // `image_gb` is set, because the image will have to be built.
1957 image_build: None,
1958 image_gb: plan.image_gb,
1959 image_retained: plan
1960 .image_retained_seconds
1961 .map(DurationP::projected_secs_f64)
1962 .transpose()?,
1963 suspend_resume_cycles: plan.suspend_resume_cycles,
1964 snapshot_gb: plan.snapshot_gb,
1965 launched: plan.launched,
1966 };
1967 run_report(size, &usage, rates, today, label)
1968}
1969
1970// ── running versus suspended ─────────────────────────────────────────────────
1971
1972/// Running versus suspended for the same VM over the same wall time.
1973///
1974/// The gap is roughly two orders of magnitude over a month, which is the entire
1975/// argument for a warm suspended pool. `cycles` is here so the argument stays honest:
1976/// each suspend/resume pays a snapshot write plus a read, and a pool that churns
1977/// spends more on transitions than it saves on residency. The conclusion to draw is
1978/// "avoid churn", not "avoid residency".
1979///
1980/// Both sides exclude image build and storage. They are the same for either choice,
1981/// and leaving them in would dilute the ratio the comparison exists to show — and drag
1982/// an unpriced build into a figure whose whole job is to be comparable.
1983#[derive(Clone, Debug, Eq, PartialEq)]
1984pub struct ResidencyComparison {
1985 size: SizeClass,
1986 hold: DurationP,
1987 cycles: u32,
1988 running: CostReport,
1989 suspended: CostReport,
1990 rates: RateTable,
1991}
1992
1993impl ResidencyComparison {
1994 pub fn size(&self) -> SizeClass {
1995 self.size
1996 }
1997
1998 /// The wall time both sides cover. Always [`Provenance::Projected`]: a comparison
1999 /// is a hypothetical about a hold nobody has taken yet.
2000 pub fn hold(&self) -> DurationP {
2001 self.hold
2002 }
2003
2004 pub fn cycles(&self) -> u32 {
2005 self.cycles
2006 }
2007
2008 /// The report for leaving the VM running.
2009 pub fn running(&self) -> &CostReport {
2010 &self.running
2011 }
2012
2013 /// The report for suspending it, transitions included.
2014 pub fn suspended(&self) -> &CostReport {
2015 &self.suspended
2016 }
2017
2018 /// How many times more the running VM costs.
2019 ///
2020 /// Zero-safe by construction: snapshot storage always has the minimum-retention
2021 /// floor (COST-8), so the denominator is never zero. Both totals are
2022 /// [`Total::Exact`] because both sides exclude the unpriced build, which is what
2023 /// makes reading their floors sound here.
2024 pub fn ratio(&self) -> Decimal {
2025 self.running.total().floor().amount() / self.suspended.total().floor().amount()
2026 }
2027
2028 /// One suspend/resume: a snapshot write plus a read, per GB.
2029 ///
2030 /// Without it the honest conclusion inverts — "suspend constantly" reads as free.
2031 pub fn per_cycle(&self) -> Result<EstimatedUsd, Error> {
2032 let gb = gb_decimal(self.size.baseline_gb())?;
2033 Ok(EstimatedUsd::new(
2034 gb * (self.rates.snapshot_write_gb() + self.rates.snapshot_read_gb()),
2035 ))
2036 }
2037
2038 /// How long a VM must stay suspended for the cycle to pay for itself.
2039 ///
2040 /// Below this, suspending and resuming costs more than having left the VM running
2041 /// — which is the number a pool scheduler actually needs, and the one a bare "100x
2042 /// cheaper" headline hides.
2043 ///
2044 /// Returns a [`Decimal`] where the Python returned a `float`, so the seconds
2045 /// figure stays in the same representation as the money it was derived from. See
2046 /// [`ResidencyComparison::break_even_seconds_f64`] for the lossy step, which is
2047 /// named as one.
2048 pub fn break_even_seconds(&self) -> Result<Decimal, Error> {
2049 let vcpu = gb_decimal(self.size.baseline_vcpu())?;
2050 let gb = gb_decimal(self.size.baseline_gb())?;
2051 let running_per_sec = vcpu * self.rates.vcpu_second() + gb * self.rates.gb_second();
2052 let storage_per_sec = gb * self.rates.storage_gb_month() / SECONDS_PER_MONTH;
2053 let floor_sec = seconds_of(self.rates.minimum_retention());
2054 let churn = self.per_cycle()?.amount();
2055 // Inside the minimum-retention window the storage charge is a constant, so the
2056 // equation is linear in the hold; past it storage grows with the hold and the
2057 // slope changes. Solve the constant branch first and only take the other if the
2058 // answer falls outside the window.
2059 let candidate = (churn + floor_sec * storage_per_sec) / running_per_sec;
2060 if candidate > floor_sec {
2061 return Ok(churn / (running_per_sec - storage_per_sec));
2062 }
2063 Ok(candidate)
2064 }
2065
2066 /// The break-even hold as an f64, for a JSON envelope.
2067 ///
2068 /// **Lossy, and named so.** The exact answer is
2069 /// [`ResidencyComparison::break_even_seconds`]; this exists because `cli.py` emits
2070 /// `breakEvenSeconds` as a JSON number and the two clients have to agree on it.
2071 /// Seconds, not dollars — no money figure has an f64 accessor (COST-2).
2072 pub fn break_even_seconds_f64(&self) -> Result<f64, Error> {
2073 let exact = self.break_even_seconds()?;
2074 exact.to_f64().ok_or_else(|| {
2075 Error::invalid_arg(format!(
2076 "the break-even hold of {exact}s does not fit in an f64, so it cannot be reported \
2077 as a JSON number"
2078 ))
2079 })
2080 }
2081
2082 /// Plain text for a CLI, ending with the conclusion the numbers support.
2083 pub fn render(&self) -> Result<String, Error> {
2084 Ok([
2085 format!("{} held {}", self.size, self.hold),
2086 format!(" running: {}", self.running.total()),
2087 format!(
2088 " suspended: {} ({} cycle(s) included)",
2089 self.suspended.total(),
2090 self.cycles,
2091 ),
2092 format!(
2093 " ratio: {}x cheaper suspended",
2094 self.ratio().round_dp(1)
2095 ),
2096 format!(
2097 " per cycle: {} — break-even hold {}s, so avoid churn rather than residency",
2098 self.per_cycle()?,
2099 self.break_even_seconds()?.round_dp(0),
2100 ),
2101 ]
2102 .join("\n"))
2103 }
2104}
2105
2106/// The suspend/resume cycles [`compare_residency`] prices when the caller names none: one,
2107/// the fewest that make a suspension more than a termination.
2108///
2109/// One rather than [`DEFAULT_REPORT_CYCLES`]'s zero: the comparison is a hypothetical whose
2110/// per-cycle figure is its point, and pricing no cycle would show suspension's transitions as
2111/// free.
2112pub const DEFAULT_RESIDENCY_CYCLES: u32 = 1;
2113
2114/// The suspend/resume cycles a report counts when the caller names none: zero, the
2115/// `suspend_resume_cycles` of [`RunUsage::default`] and [`PlanUsage::default`], and what the
2116/// CLI's `cost` and `cost --estimate` and both bindings' `run_report` and `estimate_run` apply.
2117///
2118/// Zero because a report claims only what its caller says happened (#280). A cycle charges a
2119/// snapshot write and read, so a default of one would bill every run for a suspension it never
2120/// had. [`compare_residency`] defaults to [`DEFAULT_RESIDENCY_CYCLES`] instead.
2121pub const DEFAULT_REPORT_CYCLES: u32 = 0;
2122
2123/// The warm-pool argument, with its own counter-argument attached.
2124///
2125/// `cycles` should be at least 1: a suspension that is never resumed is a
2126/// termination, and pricing it as free transitions is how a pool design that churns
2127/// every few seconds looks affordable on paper. Zero is accepted rather than refused
2128/// because a caller comparing pure residency has a legitimate question, and the
2129/// per-cycle figure is on the comparison either way.
2130pub fn compare_residency(
2131 size: SizeClass,
2132 hold: Duration,
2133 cycles: u32,
2134 rates: &RateTable,
2135 today: CalendarDate,
2136) -> Result<ResidencyComparison, Error> {
2137 let hold = DurationP::Projected(hold);
2138 let running = run_report(
2139 size,
2140 &RunUsage {
2141 running: Some(hold),
2142 launched: false,
2143 ..RunUsage::default()
2144 },
2145 rates,
2146 today,
2147 "left running",
2148 )?;
2149 let suspended = run_report(
2150 size,
2151 &RunUsage {
2152 suspended: Some(hold),
2153 suspend_resume_cycles: cycles,
2154 launched: false,
2155 ..RunUsage::default()
2156 },
2157 rates,
2158 today,
2159 "suspended",
2160 )?;
2161 Ok(ResidencyComparison {
2162 size,
2163 hold,
2164 cycles,
2165 running,
2166 suspended,
2167 rates: rates.clone(),
2168 })
2169}
2170
2171// ── the budget gate (#77, #270) ──────────────────────────────────────────────
2172
2173/// What a breached budget does: report it, or refuse the plan. There is no default.
2174///
2175/// The total a budget is compared against is a lower bound whenever a line is unpriced
2176/// (COST-4), so a breach found from one has already been exceeded by an unknown margin.
2177/// Whether that warrants a warning or a refusal depends on what the caller is gating (a
2178/// dashboard wants the report either way, a CI gate wants the refusal), and a default would
2179/// quietly make that judgement for them. So [`Budget::new`] takes one, and so does every
2180/// surface: `cost --max-cost` requires `--on-breach`.
2181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2182pub enum OnBreach {
2183 /// Report as usual, with the breach as a warning.
2184 Warn,
2185 /// Report as usual, then refuse: the budget the caller set does not hold.
2186 Abort,
2187}
2188
2189impl OnBreach {
2190 /// Every judgement, for a surface that publishes the closed set.
2191 pub const ALL: [OnBreach; 2] = [OnBreach::Warn, OnBreach::Abort];
2192
2193 /// `"warn"` or `"abort"`, the spelling every surface takes and reports.
2194 pub fn as_str(self) -> &'static str {
2195 match self {
2196 OnBreach::Warn => "warn",
2197 OnBreach::Abort => "abort",
2198 }
2199 }
2200}
2201
2202impl FromStr for OnBreach {
2203 type Err = Error;
2204
2205 fn from_str(text: &str) -> Result<OnBreach, Error> {
2206 OnBreach::ALL
2207 .into_iter()
2208 .find(|judgement| judgement.as_str() == text)
2209 .ok_or_else(|| {
2210 Error::invalid_arg(format!(
2211 "{text:?} is not a judgement on a breach: it's \"warn\" or \"abort\", and \
2212 there is no default, because a breach of a lower-bound total has already \
2213 been exceeded by an unknown margin"
2214 ))
2215 })
2216 }
2217}
2218
2219/// A ceiling on a report's total, and what a breach of it does.
2220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2221pub struct Budget {
2222 /// The ceiling, as the caller wrote it ([`EstimatedUsd::parse`] keeps its scale).
2223 pub max: EstimatedUsd,
2224 /// What a breach does; see [`OnBreach`].
2225 pub on_breach: OnBreach,
2226}
2227
2228impl Budget {
2229 /// A budget of `max`, whose breach does `on_breach`.
2230 pub fn new(max: EstimatedUsd, on_breach: OnBreach) -> Budget {
2231 Budget { max, on_breach }
2232 }
2233
2234 /// The verdict on `report`: its total's floor against the ceiling.
2235 ///
2236 /// The floor is the whole estimate when every line priced and a lower bound otherwise,
2237 /// and the verdict says which: a breach of a lower bound is a breach by at least the
2238 /// overage, and staying under one proves nothing about the true figure. An empty report
2239 /// is an exact zero, not a lower bound: nothing on it is unpriced.
2240 pub fn check(&self, report: &CostReport) -> BudgetVerdict {
2241 let total = report.total();
2242 BudgetVerdict {
2243 budget: *self,
2244 floor: total.floor(),
2245 unpriced: total.unpriced_phase_names(),
2246 }
2247 }
2248}
2249
2250/// A report's total judged against a [`Budget`].
2251#[derive(Clone, Debug, Eq, PartialEq)]
2252pub struct BudgetVerdict {
2253 budget: Budget,
2254 floor: EstimatedUsd,
2255 unpriced: Vec<&'static str>,
2256}
2257
2258impl BudgetVerdict {
2259 /// The budget judged against.
2260 pub fn budget(&self) -> Budget {
2261 self.budget
2262 }
2263
2264 /// The total's floor: the whole estimate when [`Self::is_lower_bound`] is false.
2265 pub fn floor(&self) -> EstimatedUsd {
2266 self.floor
2267 }
2268
2269 /// Whether the floor is a lower bound, because a line on the report is unpriced.
2270 pub fn is_lower_bound(&self) -> bool {
2271 !self.unpriced.is_empty()
2272 }
2273
2274 /// The phases whose lines are unpriced, sorted: what the floor leaves out.
2275 pub fn unpriced_phases(&self) -> &[&'static str] {
2276 &self.unpriced
2277 }
2278
2279 /// Whether the floor is over the ceiling. At the ceiling is within it.
2280 pub fn breached(&self) -> bool {
2281 self.floor > self.budget.max
2282 }
2283
2284 /// Whether the caller's judgement refuses this report: breached, with
2285 /// [`OnBreach::Abort`].
2286 pub fn aborts(&self) -> bool {
2287 self.breached() && self.budget.on_breach == OnBreach::Abort
2288 }
2289
2290 /// How far the floor is over the ceiling, when it is: at least this much for a lower
2291 /// bound.
2292 pub fn overage(&self) -> Option<EstimatedUsd> {
2293 self.breached()
2294 .then(|| EstimatedUsd::new(self.floor.amount() - self.budget.max.amount()))
2295 }
2296
2297 /// `"exact"` or `"lower-bound"`: what the floor is.
2298 pub fn basis(&self) -> &'static str {
2299 if self.is_lower_bound() {
2300 "lower-bound"
2301 } else {
2302 "exact"
2303 }
2304 }
2305
2306 /// One line for a human: the figure, the ceiling, and what an unpriced line leaves open.
2307 ///
2308 /// An under-budget verdict from a lower bound says so, because "within budget" without
2309 /// that half would be the report lying by omission, the figure COST-4 keeps honest.
2310 pub fn render(&self) -> String {
2311 let floor = self.floor.amount_string();
2312 let caveat = if self.unpriced.is_empty() {
2313 String::new()
2314 } else {
2315 format!(
2316 ", and {} line(s) are unpriced ({}), so the true figure is larger by an \
2317 unknown margin",
2318 self.unpriced.len(),
2319 self.unpriced.join(", ")
2320 )
2321 };
2322 match self.overage() {
2323 Some(overage) => format!(
2324 "budget: the {} total ${floor} exceeds --max-cost ${} by {}${}{caveat}",
2325 if self.unpriced.is_empty() {
2326 "estimated"
2327 } else {
2328 "priced-floor"
2329 },
2330 self.budget.max.amount_string(),
2331 if self.unpriced.is_empty() {
2332 ""
2333 } else {
2334 "at least "
2335 },
2336 overage.amount_string(),
2337 ),
2338 None if self.unpriced.is_empty() => format!(
2339 "budget: the estimated total ${floor} is within --max-cost ${}",
2340 self.max_figure()
2341 ),
2342 None => format!(
2343 "budget: the priced floor ${floor} is within --max-cost ${}, but that floor is \
2344 a lower bound{caveat}",
2345 self.max_figure()
2346 ),
2347 }
2348 }
2349
2350 /// The dense line: `budget`, the ceiling, the basis, `breached` or `within`, and the
2351 /// overage or `-`.
2352 pub fn render_dense(&self) -> String {
2353 format!(
2354 "budget\t{}\t{}\t{}\t{}",
2355 self.max_figure(),
2356 self.basis(),
2357 if self.breached() {
2358 "breached"
2359 } else {
2360 "within"
2361 },
2362 self.overage()
2363 .map_or_else(|| "-".to_string(), EstimatedUsd::amount_string),
2364 )
2365 }
2366
2367 /// The verdict as JSON: `maxUsd`, `onBreach`, `basis`, `breached` and
2368 /// `overageAtLeastUsd`, dollars as strings.
2369 pub fn to_json(&self) -> serde_json::Value {
2370 serde_json::json!({
2371 "maxUsd": self.max_figure(),
2372 "onBreach": self.budget.on_breach.as_str(),
2373 "basis": self.basis(),
2374 "breached": self.breached(),
2375 "overageAtLeastUsd": self.overage().map(EstimatedUsd::amount_string),
2376 })
2377 }
2378
2379 /// The ceiling at the scale the caller typed it (`1.50` stays `1.50`), where
2380 /// [`EstimatedUsd::amount_string`] would restate it at report precision.
2381 fn max_figure(&self) -> String {
2382 self.budget.max.amount().to_string()
2383 }
2384}
2385
2386// ── the report as JSON ───────────────────────────────────────────────────────
2387//
2388// One shape for every surface: the CLI's `cost --json` report, Python's `to_dict` and
2389// TypeScript's `toJson` all come from these (#255). They used to be written three times and had
2390// drifted: only the CLI's carried `size.headroomMib`.
2391//
2392// Two rules make the shape load-bearing rather than conventional.
2393//
2394// An unpriced line has **no `usd` key at all**, not a null: a null is summed as zero by
2395// anything permissive, and a consumer summing the column would produce an invoice that
2396// flatters us. It's the same decision `Amount::estimate` makes by returning `Option` instead of
2397// defaulting to zero.
2398//
2399// Every **dollar** figure crosses as a string, never a number: `Decimal` to `f64` is a lossy
2400// step for a figure whose exactness is the point of the type. Every **seconds** figure crosses
2401// as a number (`breakEvenSeconds`, a line's `duration.seconds`, a comparison's `holdSeconds`),
2402// through an accessor named `_f64` so the lossy step is visible. The split is which consumer is
2403// protected: a caller summing dollars must not do float arithmetic on money, and a caller
2404// comparing a duration with a timeout must not find it quoted.
2405//
2406// A dollar string prints at the scale the arithmetic produced (`Decimal::to_string`), so its
2407// trailing zeros can differ from the retired Python client's for the same figure. The values
2408// are equal, and a consumer comparing two reports parses both sides as decimals. Rescaling to
2409// fixed places would round a figure whose exactness is why it's a string; the fixed-places
2410// rendering the human column wants is `render_amount`. A **rate** is the exception, asserted
2411// byte for byte by `every_rate_byte_matches_the_python_literal`, because a rate is a
2412// transcription and a derived figure isn't.
2413
2414impl LineItem {
2415 /// The line as JSON: `phase`, `line`, `quantity`, `unit`, `amount`, `duration`, `note`.
2416 ///
2417 /// The keys are the retired Python client's `_line_to_dict`, because the live suite reads
2418 /// them and every surface's output has to be substitutable. `amount` is
2419 /// `{"kind": "estimated-usd", "usd": "<decimal>"}` or `{"kind": "unpriced", "reason":
2420 /// "..."}`, with no `usd` key.
2421 pub fn to_json(&self) -> serde_json::Value {
2422 let amount = match &self.amount {
2423 Amount::Estimated(usd) => serde_json::json!({
2424 "kind": "estimated-usd",
2425 "usd": usd.amount().to_string(),
2426 }),
2427 Amount::Unpriced { reason } => serde_json::json!({
2428 "kind": "unpriced",
2429 "reason": reason,
2430 }),
2431 };
2432 serde_json::json!({
2433 "phase": self.phase.as_str(),
2434 "line": self.line.map(|line| line.as_str()),
2435 "quantity": self.quantity.to_string(),
2436 "unit": self.unit,
2437 "amount": amount,
2438 "duration": self.duration.map(|duration| serde_json::json!({
2439 // A number, for the reason the section comment gives.
2440 "seconds": duration.seconds_f64(),
2441 "provenance": duration.provenance().as_str(),
2442 })),
2443 "note": self.note,
2444 })
2445 }
2446}
2447
2448impl CostReport {
2449 /// The report as JSON, keeping every label it carries.
2450 ///
2451 /// The keys are the retired Python client's `report_to_dict` verbatim, plus one added
2452 /// since (#99): `size.headroomMib`. A new key is safe where a rename isn't, because the
2453 /// live suite asserts presence, never absence.
2454 pub fn to_json(&self) -> serde_json::Value {
2455 let size = self.size;
2456 let total = self.total();
2457 serde_json::json!({
2458 "label": self.label,
2459 "size": {
2460 "baselineMib": size.baseline_mib(),
2461 "baselineVcpu": size.baseline_vcpu(),
2462 "peakMib": size.peak_mib(),
2463 "peakVcpu": size.peak_vcpu(),
2464 // Static, from the table row: the headroom is provisioned from the start, so it
2465 // is a property of the class, read through the accessor for TRAP-13's reason
2466 // (the 4x regularity is AWS's, not ours).
2467 "headroomMib": size.headroom_mib(),
2468 // `Display` names both numbers or neither: naming only the peak invites
2469 // budgeting for memory nobody is billed for.
2470 "describe": size.to_string(),
2471 },
2472 "rates": {
2473 "region": self.rates.region().as_str(),
2474 "retrieved": self.rates.retrieved().to_string(),
2475 "sourceUrl": self.rates.source_url(),
2476 },
2477 // Not "cost": these are estimates derived from published rates, and the field name
2478 // is the only place that distinction survives a copy-paste.
2479 "estimated": true,
2480 "fullyMeasured": self.fully_measured(),
2481 "complete": self.is_complete(),
2482 "staleness": self.staleness(),
2483 "items": self.items.iter().map(LineItem::to_json).collect::<Vec<_>>(),
2484 "total": {
2485 // The floor, as a string. `AtLeast`'s floor and `Exact`'s figure are the same
2486 // field on purpose: a consumer that ignores `isLowerBound` gets a number that
2487 // is never an over-statement.
2488 "priced": total.floor().amount().to_string(),
2489 "isLowerBound": total.is_lower_bound(),
2490 "render": total.to_string(),
2491 },
2492 })
2493 }
2494}
2495
2496impl ResidencyComparison {
2497 /// The comparison as JSON: both reports in [`CostReport::to_json`]'s shape, with the
2498 /// per-cycle cost and the break-even hold that keep the argument honest.
2499 pub fn to_json(&self) -> Result<serde_json::Value, Error> {
2500 Ok(serde_json::json!({
2501 "holdSeconds": self.hold.seconds_f64(),
2502 "cycles": self.cycles,
2503 "running": self.running.to_json(),
2504 "suspended": self.suspended.to_json(),
2505 "ratio": self.ratio().to_string(),
2506 "perCycleUsd": self.per_cycle()?.amount().to_string(),
2507 // The one f64 figure that isn't a line's seconds, through the accessor named for
2508 // its lossy step.
2509 "breakEvenSeconds": self.break_even_seconds_f64()?,
2510 "render": self.render()?,
2511 }))
2512 }
2513}
2514
2515#[cfg(test)]
2516mod tests {
2517 use proptest::prelude::*;
2518
2519 use super::*;
2520 use crate::error::ErrorKind;
2521
2522 /// A month as AWS's GB-month rate defines it. Every "per month" figure in
2523 /// `docs/PLATFORM.md` is this many seconds, not 30 days.
2524 const MONTH: Duration = Duration::from_secs(730 * 3600);
2525
2526 /// The epoch is day zero, each day starts at a multiple of 86,400 seconds, and the
2527 /// last second of a day stays on that day.
2528 #[test]
2529 fn a_unix_time_floors_to_its_utc_day() {
2530 let day = |secs| {
2531 CalendarDate::from_unix_secs(secs)
2532 .expect("in range")
2533 .to_string()
2534 };
2535 assert_eq!(day(0), "1970-01-01");
2536 assert_eq!(day(86_399), "1970-01-01");
2537 assert_eq!(day(86_400), "1970-01-02");
2538 // 2026-09-25T00:00:00Z, and the second before it.
2539 assert_eq!(day(1_790_294_400), "2026-09-25");
2540 assert_eq!(day(1_790_294_399), "2026-09-24");
2541 assert_eq!(
2542 CalendarDate::from_unix_secs(0)
2543 .expect("in range")
2544 .day_number(),
2545 0
2546 );
2547 // The calendar's last second, 9999-12-31T23:59:59Z, and the one after it.
2548 assert_eq!(day(253_402_300_799), "9999-12-31");
2549 }
2550
2551 /// Past the calendar's end is a refusal, never a panic or a quiet epoch: the same
2552 /// 2026-09-25 in milliseconds, the first second of year 10000, and the largest input.
2553 #[test]
2554 fn a_unix_time_past_the_calendar_is_refused() {
2555 for secs in [1_790_294_400_000, 253_402_300_800, u64::MAX] {
2556 let error = CalendarDate::from_unix_secs(secs).expect_err("past 9999-12-31");
2557 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{secs}");
2558 assert!(error.to_string().contains("milliseconds"), "{error}");
2559 }
2560 }
2561
2562 /// A date the pinned table is fresh on, so a staleness warning in a test that is
2563 /// not about staleness is a failure rather than noise.
2564 fn fresh_day() -> CalendarDate {
2565 CalendarDate::from_ymd(2026, 8, 8)
2566 }
2567
2568 /// The pinned table, which every arithmetic test computes against.
2569 fn rates() -> RateTable {
2570 pinned_rates()
2571 }
2572
2573 /// A budget parses from the text the caller typed, exactly.
2574 ///
2575 /// `0.1` as text is a tenth; `0.1f64` is not. The assertion against `dec!` is
2576 /// what pins that the parse goes through the string rather than through a float
2577 /// on the way.
2578 #[test]
2579 fn a_budget_parses_from_the_text_the_caller_typed() {
2580 assert_eq!(
2581 EstimatedUsd::parse("1.50").expect("a plain decimal"),
2582 EstimatedUsd::new(dec!(1.50))
2583 );
2584 assert_eq!(
2585 EstimatedUsd::parse(" 0.1 ").expect("whitespace is not meaning"),
2586 EstimatedUsd::new(dec!(0.1))
2587 );
2588 assert_eq!(
2589 EstimatedUsd::parse("0").expect("zero is a legitimate budget"),
2590 EstimatedUsd::ZERO
2591 );
2592 }
2593
2594 /// Non-decimal text and a negative figure are both refused as invalid arguments.
2595 ///
2596 /// Negative is refused at the parse rather than at a comparison, because a
2597 /// negative budget compared against any non-negative total reads as "always
2598 /// breached" — a sign error laundered into a confident verdict.
2599 #[test]
2600 fn a_budget_that_does_not_read_as_money_is_refused() {
2601 // `1.5e3` is absent on purpose: `Decimal::from_str` reads scientific
2602 // notation exactly, so it is a legitimate spelling rather than a refusal.
2603 for text in ["abc", "", "$1.50", "-0.01"] {
2604 let error = EstimatedUsd::parse(text).expect_err(text);
2605 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{text}");
2606 }
2607 }
2608
2609 /// One phase's amount, asserting it was priced at all.
2610 fn priced(report: &CostReport, phase: CostPhase) -> Decimal {
2611 let items: Vec<&LineItem> = report.by_phase(phase).collect();
2612 assert_eq!(items.len(), 1, "{phase} should have exactly one line item");
2613 items[0]
2614 .amount
2615 .estimate()
2616 .unwrap_or_else(|| panic!("{phase} should be priced"))
2617 .amount()
2618 }
2619
2620 /// A report of one running phase, which is the shape most arithmetic tests want.
2621 fn running_report(size: SizeClass, duration: DurationP) -> CostReport {
2622 run_report(
2623 size,
2624 &RunUsage {
2625 running: Some(duration),
2626 launched: false,
2627 ..RunUsage::default()
2628 },
2629 &rates(),
2630 fresh_day(),
2631 "run",
2632 )
2633 .expect("a running phase at a documented size prices")
2634 }
2635
2636 // -- the rate table --------------------------------------------------------
2637
2638 /// Every rate as a literal, transcribed from `docs/PLATFORM.md`, "What actually
2639 /// costs money", us-east-1. Asserted literally rather than computed, because the
2640 /// whole value of pinning a rate table is that it can be diffed against the page
2641 /// it came from — and against the Python client's own copy of these literals in
2642 /// `test_cost.py`.
2643 ///
2644 /// `storage_gb_month` is the one derived value: the Pricing API quotes snapshot
2645 /// storage per GB-hour, and $0.0001111111 x 730 is $0.0811111030. It read 0.08
2646 /// until 2026-08-07, which was 1.37% low.
2647 #[test]
2648 fn every_rate_byte_matches_the_python_literal() {
2649 let rates = rates();
2650 assert_eq!(rates.region(), &Region::UsEast1);
2651 assert_eq!(rates.vcpu_second(), dec!(0.0000276944));
2652 assert_eq!(rates.gb_second(), dec!(0.0000036667));
2653 assert_eq!(rates.storage_gb_month(), dec!(0.0811111030));
2654 assert_eq!(rates.snapshot_read_gb(), dec!(0.00155));
2655 assert_eq!(rates.snapshot_write_gb(), dec!(0.0038));
2656 assert_eq!(rates.minimum_retention(), Duration::from_secs(604_800));
2657 assert_eq!(rates.retrieved(), CalendarDate::from_ymd(2026, 8, 7));
2658 assert!(rates.source_url().contains("aws.amazon.com"));
2659 // Scale as well as value: `0.0038` and `0.00380000` are numerically equal to
2660 // `Decimal` but not the same transcription, and the point of this test is that
2661 // it is a transcription check.
2662 assert_eq!(rates.snapshot_write_gb().to_string(), "0.0038");
2663 assert_eq!(rates.storage_gb_month().to_string(), "0.0811111030");
2664 }
2665
2666 /// The storage rate is the hourly figure times a 730-hour month, asserted through
2667 /// [`HOURS_PER_MONTH`] rather than against a literal — the defect it replaced was
2668 /// a second month convention, not a typo.
2669 #[test]
2670 fn the_storage_rate_is_the_hourly_figure_times_a_seven_hundred_thirty_hour_month() {
2671 assert_eq!(SECONDS_PER_MONTH / dec!(3600), HOURS_PER_MONTH);
2672 assert_eq!(HOURS_PER_MONTH, dec!(730));
2673 assert_eq!(rates().storage_gb_month(), dec!(0.0001111111) * dec!(730));
2674 assert_ne!(
2675 rates().storage_gb_month(),
2676 dec!(0.08),
2677 "the old hand-read value was 1.37% low"
2678 );
2679 }
2680
2681 /// Four arithmetic mistakes someone would otherwise make by analogy with Lambda
2682 /// Functions: one blended GB-second, a per-invoke charge, a rounded-up billing
2683 /// increment, and a free tier. `None` for the increment is "not published" —
2684 /// distinct from one second, which would overcharge every sub-second exec.
2685 #[test]
2686 fn the_documented_billing_facts_are_data_not_prose() {
2687 let rates = rates();
2688 assert!(rates.bills_vcpu_and_memory_separately());
2689 assert_eq!(rates.per_request(), Decimal::ZERO);
2690 assert!(!rates.free_tier());
2691 assert_eq!(rates.minimum_billing_increment(), None);
2692 }
2693
2694 // -- staleness (COST-7) ----------------------------------------------------
2695
2696 /// COST-7. A silently stale price is the same failure class as a silently stale
2697 /// schema, which this repo already fails CI on.
2698 #[test]
2699 fn a_rate_table_past_ninety_days_attaches_a_warning_to_the_report() {
2700 let rates = rates();
2701 let just_past = CalendarDate::from_ymd(2026, 11, 6);
2702 assert_eq!(just_past.days_since(rates.retrieved()), 91);
2703 assert!(rates.is_stale(just_past));
2704
2705 let report = run_report(
2706 SizeClass::Mib2048,
2707 &RunUsage {
2708 running: Some(DurationP::Measured(Duration::from_secs(60))),
2709 launched: false,
2710 ..RunUsage::default()
2711 },
2712 &rates,
2713 just_past,
2714 "run",
2715 )
2716 .expect("a stale table still prices");
2717 let warning = report
2718 .staleness()
2719 .expect("a report from a stale table carries the warning");
2720 assert!(warning.contains("91 days ago"), "{warning}");
2721 assert!(warning.contains("stale after 90"), "{warning}");
2722 // The URL travels with it: a figure copied out of a terminal loses everything
2723 // else, so the warning has to say what to re-read.
2724 assert!(warning.contains(rates.source_url()), "{warning}");
2725 }
2726
2727 /// The complement. A warning that fires on a fresh table is a warning everyone
2728 /// learns to filter, and then the stale case goes unseen too. Ninety days exactly
2729 /// is fresh — the Python's comparison is `>`, not `>=`.
2730 #[test]
2731 fn a_fresh_rate_table_is_silent_and_ninety_days_exactly_is_still_fresh() {
2732 let rates = rates();
2733 let ninety = CalendarDate::from_ymd(2026, 11, 5);
2734 assert_eq!(ninety.days_since(rates.retrieved()), 90);
2735 assert!(!rates.is_stale(ninety));
2736 assert_eq!(rates.staleness(ninety), None);
2737
2738 let report = running_report(
2739 SizeClass::Mib2048,
2740 DurationP::Measured(Duration::from_secs(60)),
2741 );
2742 assert_eq!(report.staleness(), None);
2743 }
2744
2745 // -- dates -----------------------------------------------------------------
2746
2747 /// The day-number algorithm against three known values, since every age in this
2748 /// module is a subtraction of two of them. A hand-rolled date is only acceptable
2749 /// if it is pinned.
2750 #[test]
2751 fn the_day_number_agrees_with_the_gregorian_calendar() {
2752 assert_eq!(CalendarDate::from_ymd(1970, 1, 1).day_number(), 0);
2753 // Both verified against Python's `datetime.date` in this session.
2754 assert_eq!(CalendarDate::from_ymd(2026, 8, 7).day_number(), 20_672);
2755 assert_eq!(CalendarDate::from_ymd(2024, 2, 29).day_number(), 19_782);
2756 assert_eq!(CalendarDate::from_ymd(1969, 12, 31).day_number(), -1);
2757 // A leap year's February really has 29 days in the arithmetic, not just in
2758 // the validator.
2759 assert_eq!(
2760 CalendarDate::from_ymd(2024, 3, 1).days_since(CalendarDate::from_ymd(2024, 2, 28)),
2761 2
2762 );
2763 assert_eq!(
2764 CalendarDate::from_ymd(2023, 3, 1).days_since(CalendarDate::from_ymd(2023, 2, 28)),
2765 1
2766 );
2767 }
2768
2769 /// The pinned literals really are calendar days. [`CalendarDate::from_ymd`] is
2770 /// `const` and therefore cannot validate, so this is where that is made good.
2771 #[test]
2772 fn the_pinned_dates_are_real_calendar_days() {
2773 for (year, month, day) in [(2026, 8, 7), (2026, 8, 8), (2026, 11, 5), (2026, 11, 6)] {
2774 CalendarDate::try_from_ymd(year, month, day)
2775 .unwrap_or_else(|err| panic!("{year}-{month}-{day} should be a real day: {err}"));
2776 }
2777 }
2778
2779 /// The S2 boundary refuses what the `const` constructor cannot: `2026-02-30`
2780 /// would otherwise produce March 2nd's day number and an age two days out.
2781 #[test]
2782 fn a_date_that_is_not_a_calendar_day_is_refused() {
2783 for (year, month, day) in [
2784 (2026, 2, 30),
2785 (2026, 13, 1),
2786 (2026, 0, 1),
2787 (2026, 1, 0),
2788 (2026, 1, 32),
2789 (2023, 2, 29),
2790 ] {
2791 let err = CalendarDate::try_from_ymd(year, month, day)
2792 .expect_err("only calendar days date a rate table");
2793 assert_eq!(err.kind(), ErrorKind::InvalidArg, "{year}-{month}-{day}");
2794 }
2795 assert!(
2796 CalendarDate::try_from_ymd(2024, 2, 29).is_ok(),
2797 "a leap day"
2798 );
2799 }
2800
2801 /// `Display` is ISO 8601 because that is what the Python's `retrieved.isoformat()`
2802 /// puts in the report header, and the two clients' output has to be diffable.
2803 #[test]
2804 fn a_date_renders_as_iso_eight_six_oh_one() {
2805 assert_eq!(CalendarDate::from_ymd(2026, 8, 7).to_string(), "2026-08-07");
2806 assert_eq!(
2807 CalendarDate::from_ymd(2026, 12, 31).to_string(),
2808 "2026-12-31"
2809 );
2810 }
2811
2812 // -- measured versus estimated (COST-1, COST-2) -----------------------------
2813
2814 /// COST-1, the runtime half. The compile-fail half is the doctests on
2815 /// [`DurationP`] — this is the part a `match` can see: the label is on the value,
2816 /// so a consumer can refuse to print an estimate as a receipt.
2817 #[test]
2818 fn a_duration_carries_how_it_is_known() {
2819 let timed = DurationP::Measured(Duration::from_secs(60));
2820 let planned = DurationP::Projected(Duration::from_secs(60));
2821 assert_eq!(timed.provenance(), Provenance::Measured);
2822 assert_eq!(planned.provenance(), Provenance::Projected);
2823 assert!(timed.is_measured());
2824 assert!(!planned.is_measured());
2825 // Same span, different claim. The two are not equal, so a set or a comparison
2826 // cannot silently treat a projection as a timing.
2827 assert_ne!(timed, planned);
2828 assert_eq!(timed.duration(), planned.duration());
2829 assert_eq!(timed.to_string(), "60s (measured)");
2830 assert_eq!(planned.to_string(), "60s (projected)");
2831 }
2832
2833 /// An inverted clock would silently render as a credit on the report. The Python
2834 /// checked this in `__post_init__`; here the check is at the f64 boundary, because
2835 /// past it a negative duration is not representable.
2836 #[test]
2837 fn a_negative_or_non_finite_duration_is_refused_at_the_float_boundary() {
2838 for seconds in [-1.0, -0.001, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2839 let err = DurationP::measured_secs_f64(seconds)
2840 .expect_err("only a finite non-negative float is a duration");
2841 assert_eq!(err.kind(), ErrorKind::InvalidArg, "{seconds}");
2842 assert!(
2843 err.to_string().contains("credit on the report"),
2844 "must name why: {err}"
2845 );
2846 }
2847 assert_eq!(
2848 DurationP::measured_secs_f64(60.5)
2849 .expect("a plain float is a duration")
2850 .seconds(),
2851 dec!(60.5)
2852 );
2853 }
2854
2855 /// COST-2's runtime half: the label is in `Display`, because a figure copied out of
2856 /// a terminal loses its type name.
2857 #[test]
2858 fn every_dollar_figure_renders_as_an_estimate() {
2859 assert_eq!(
2860 EstimatedUsd::new(dec!(1.23)).to_string(),
2861 "~$1.23 (estimated)"
2862 );
2863 let report = running_report(SizeClass::Mib2048, DurationP::Measured(MONTH));
2864 let text = report.render();
2865 assert!(text.contains("estimates derived from published"), "{text}");
2866 assert!(text.contains("only Cost Explorer knows the bill"), "{text}");
2867 // The retrieval date travels too, so a pasted figure can be dated.
2868 assert!(text.contains("2026-08-07"), "{text}");
2869 }
2870
2871 /// The asymmetry is the point: we can time a phase exactly and still only infer its
2872 /// price, so there is no `ActualUsd` for a measured duration to produce.
2873 #[test]
2874 fn a_measured_second_still_yields_an_estimated_dollar() {
2875 let report = running_report(
2876 SizeClass::Mib2048,
2877 DurationP::Measured(Duration::from_secs(3600)),
2878 );
2879 assert!(report.fully_measured());
2880 assert!(
2881 report
2882 .items()
2883 .iter()
2884 .all(|item| item.amount.estimate().is_some())
2885 );
2886 assert!(report.total().to_string().contains("estimated"));
2887 }
2888
2889 /// The distinction survives into the report rather than stopping at the duration,
2890 /// which is what lets a consumer refuse to print an estimate as a receipt.
2891 #[test]
2892 fn an_estimate_is_never_fully_measured_and_a_timed_run_is() {
2893 let projected = estimate_run(
2894 SizeClass::Mib2048,
2895 &PlanUsage {
2896 running_seconds: 3600.0,
2897 ..PlanUsage::default()
2898 },
2899 &rates(),
2900 fresh_day(),
2901 "estimate",
2902 )
2903 .expect("a plan prices");
2904 assert!(!projected.fully_measured());
2905 assert!(
2906 running_report(
2907 SizeClass::Mib2048,
2908 DurationP::Measured(Duration::from_secs(3600))
2909 )
2910 .fully_measured()
2911 );
2912 }
2913
2914 /// Display precision: sub-cent figures need six places and monthly ones need two,
2915 /// and one fixed precision cannot show both without lying or shouting. Half-even at
2916 /// the boundary, matching Python's `quantize` default, so the two clients render
2917 /// the same figure identically.
2918 #[test]
2919 fn a_sub_cent_figure_and_a_monthly_one_each_render_readably() {
2920 assert_eq!(
2921 EstimatedUsd::new(dec!(0.0000276944)).amount_string(),
2922 "0.000028"
2923 );
2924 assert_eq!(EstimatedUsd::new(dec!(92.0512345)).amount_string(), "92.05");
2925 // Exactly one dollar crosses to two places: `>= 1`, as the Python has it.
2926 assert_eq!(EstimatedUsd::new(Decimal::ONE).amount_string(), "1.00");
2927 assert_eq!(
2928 EstimatedUsd::new(dec!(0.999999)).amount_string(),
2929 "0.999999"
2930 );
2931 // Half-even, not half-up: 0.125 rounds down to 0.12 and 0.135 rounds up to
2932 // 0.14. `Decimal::rescale` would answer 0.13 for the first.
2933 assert_eq!(EstimatedUsd::new(dec!(1.125)).amount_string(), "1.12");
2934 assert_eq!(EstimatedUsd::new(dec!(1.135)).amount_string(), "1.14");
2935 // Trailing zeros are kept: they say the figure was rounded to the cent rather
2936 // than happening to land there.
2937 assert_eq!(EstimatedUsd::new(dec!(2)).amount_string(), "2.00");
2938 }
2939
2940 // -- unknown is not zero (COST-3, COST-4) ----------------------------------
2941
2942 /// COST-3. AWS does not publish whether the server-side build is billed as
2943 /// compute. The build starts a real MicroVM, so reporting $0.00 would understate
2944 /// the run in the direction that flatters us.
2945 #[test]
2946 fn the_image_build_is_unpriced_rather_than_free() {
2947 let report = run_report(
2948 SizeClass::Mib2048,
2949 &RunUsage {
2950 image_gb: Some(2.0),
2951 image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2952 ..RunUsage::launched()
2953 },
2954 &rates(),
2955 fresh_day(),
2956 "run",
2957 )
2958 .expect("a build prices as unpriced");
2959 let build: Vec<&LineItem> = report.by_phase(CostPhase::ImageBuild).collect();
2960 assert_eq!(build.len(), 1);
2961 let reason = build[0]
2962 .amount
2963 .unpriced_reason()
2964 .expect("the build has no published rate");
2965 assert!(reason.contains("does not publish"), "{reason}");
2966 assert_eq!(
2967 build[0].line, None,
2968 "no billing line exists to attribute it to"
2969 );
2970 // The distinction this requirement is about: not zero dollars.
2971 assert_eq!(build[0].amount.estimate(), None);
2972 assert_ne!(
2973 build[0].amount,
2974 Amount::Estimated(EstimatedUsd::ZERO),
2975 "unpriced is a claim about the documentation, zero is a claim about the bill"
2976 );
2977 assert_eq!(build[0].amount.kind(), "unpriced");
2978 }
2979
2980 /// COST-4. A plain sum cannot express "everything priceable, plus a build whose
2981 /// price AWS withholds", and the natural way to force it to is to drop the line.
2982 /// The `AtLeast` variant is what makes dropping it unrepresentable.
2983 #[test]
2984 fn an_unpriced_line_makes_the_total_a_lower_bound_that_names_it() {
2985 let report = run_report(
2986 SizeClass::Mib2048,
2987 &RunUsage {
2988 image_gb: Some(2.0),
2989 image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2990 ..RunUsage::launched()
2991 },
2992 &rates(),
2993 fresh_day(),
2994 "run",
2995 )
2996 .expect("a build prices");
2997 assert!(!report.is_complete());
2998 let total = report.total();
2999 assert!(total.is_lower_bound());
3000 assert!(matches!(total, Total::AtLeast { .. }));
3001 // The reasons are named, not merely counted (COST-4's "naming its unpriced
3002 // line items").
3003 assert_eq!(total.unpriced_reasons().len(), 1);
3004 assert!(total.unpriced_reasons()[0].contains("does not publish"));
3005 let rendered = total.to_string();
3006 assert!(rendered.starts_with("at least "), "{rendered}");
3007 assert!(rendered.contains("1 unpriced"), "{rendered}");
3008
3009 // The floor is the sum of the priced lines and nothing else: the unpriced line
3010 // is neither summed as zero nor allowed to poison the figure.
3011 let expected: EstimatedUsd = report
3012 .priced()
3013 .map(|item| item.amount.estimate().expect("priced"))
3014 .sum();
3015 assert_eq!(total.floor(), expected);
3016 }
3017
3018 /// The rendered lower bound is the oracle's string, character for character.
3019 ///
3020 /// The Python is the contract here and the two divergences it catches are the ones a
3021 /// re-derivation would not: the parenthetical names **phases** where the Rust used to
3022 /// join reasons — a hundred-and-ninety-character sentence about what AWS does not
3023 /// publish, in the field a reader scans for "which phase" — and the separator is
3024 /// `", "` rather than `"; "`.
3025 ///
3026 /// The expected string is copied from the oracle rather than assembled here. What its
3027 /// `run_report` printed for a 2048 MiB size with 3600 s running, a 300 s measured image
3028 /// build, and a 2 GB image:
3029 ///
3030 /// ```text
3031 /// at least ~$0.166533 (estimated), plus 1 unpriced (image-build)
3032 /// ```
3033 ///
3034 /// A transcript rather than a command: that client was deleted once this one had driven
3035 /// the live suite green, and git history is where the code behind the figure lives.
3036 ///
3037 /// **Falsification** — restore `unpriced.join("; ")` over the reasons in
3038 /// [`Total`]'s `Display` and this is red on the whole parenthetical while every
3039 /// figure, count, and `isLowerBound` still agrees. Verified.
3040 #[test]
3041 fn the_rendered_lower_bound_is_the_oracles_string_verbatim() {
3042 let report = run_report(
3043 SizeClass::Mib2048,
3044 &RunUsage {
3045 running: Some(DurationP::Measured(Duration::from_secs(3600))),
3046 image_build: Some(DurationP::Measured(Duration::from_secs(300))),
3047 image_gb: Some(2.0),
3048 ..RunUsage::launched()
3049 },
3050 &rates(),
3051 fresh_day(),
3052 "run",
3053 )
3054 .expect("a build prices");
3055 assert_eq!(
3056 report.total().to_string(),
3057 "at least ~$0.166533 (estimated), plus 1 unpriced (image-build)"
3058 );
3059
3060 // Two unpriced lines in the same phase name it once, and two phases read sorted
3061 // rather than in report order — `sorted({...})` in the oracle is a set.
3062 let amounts = [
3063 (CostPhase::Running, Amount::unpriced("second")),
3064 (CostPhase::ImageBuild, Amount::unpriced("first")),
3065 (CostPhase::ImageBuild, Amount::unpriced("also first")),
3066 ];
3067 let total = Total::of(amounts.iter().map(|(phase, amount)| (*phase, amount)));
3068 assert_eq!(total.unpriced_phase_names(), ["image-build", "running"]);
3069 assert_eq!(
3070 total.to_string(),
3071 "at least ~$0.000000 (estimated), plus 3 unpriced (image-build, running)",
3072 "three lines, two names — the count is lines and the parenthetical is phases"
3073 );
3074 // The reasons are still reachable, in report order, for the consumer that wants
3075 // the paragraph the total has no room for.
3076 assert_eq!(total.unpriced_reasons(), ["second", "first", "also first"]);
3077 }
3078
3079 /// **Every phase round-trips through its own spelling**, and nothing else parses.
3080 ///
3081 /// The forward direction is the one that matters for the bindings: `by_phase("running")`
3082 /// has to find the same variant the report attributed the line to, so `from_str` must be
3083 /// the exact inverse of `as_str` for all seven. The reverse — an unknown spelling — is
3084 /// refused *with the whole list*, because the failure this replaces was a Python caller
3085 /// typing `"suspending"` and getting an empty result set back rather than an error.
3086 ///
3087 /// `ALL` is asserted to be complete by exhaustion rather than by a length check: the
3088 /// `match` below has no wildcard arm, so adding a variant to [`CostPhase`] fails to
3089 /// compile here until it is added to `ALL` too. A `len() == 7` assertion would only have
3090 /// caught the same mistake after someone changed the number.
3091 ///
3092 /// **Falsification** — drop `CostPhase::Suspend` from `ALL` and the exhaustive match
3093 /// leaves it unreachable, so the round-trip assertion goes red on `"suspend"`; return
3094 /// `Ok(CostPhase::Running)` for an unknown string and the refusal assertion goes red.
3095 /// Verified; see the report at the end of this task.
3096 #[test]
3097 fn every_phase_parses_back_from_its_display_and_nothing_else_does() {
3098 for phase in CostPhase::ALL {
3099 // Both spellings, because `Display` pads and `as_str` does not — a `from_str`
3100 // written against the padded form would fail on every column-formatted string a
3101 // caller copied out of a report.
3102 assert_eq!(
3103 phase.as_str().parse::<CostPhase>().expect("a known phase"),
3104 phase,
3105 "{} did not round-trip through as_str",
3106 phase.as_str()
3107 );
3108 assert_eq!(
3109 phase
3110 .to_string()
3111 .parse::<CostPhase>()
3112 .expect("a known phase"),
3113 phase,
3114 "{phase} did not round-trip through Display"
3115 );
3116 // And the exhaustive match: a variant added to the enum but not to `ALL` fails
3117 // to compile at this arm rather than silently going unparseable.
3118 match phase {
3119 CostPhase::ImageBuild
3120 | CostPhase::ImageStorage
3121 | CostPhase::Launch
3122 | CostPhase::Running
3123 | CostPhase::Suspended
3124 | CostPhase::Suspend
3125 | CostPhase::Resume => {}
3126 }
3127 }
3128 assert_eq!(
3129 CostPhase::ALL.len(),
3130 7,
3131 "the seven phases the billing model has"
3132 );
3133
3134 // The near-misses a caller actually types: a lifecycle state that is not a cost
3135 // phase, the underscore spelling, and the empty string.
3136 for wrong in ["suspending", "image_build", "RUNNING", "", "total"] {
3137 let error = wrong
3138 .parse::<CostPhase>()
3139 .expect_err("only the seven spellings parse");
3140 assert_eq!(error.kind(), ErrorKind::InvalidArg);
3141 let message = error.to_string();
3142 for phase in CostPhase::ALL {
3143 assert!(
3144 message.contains(phase.as_str()),
3145 "the refusal has to offer every phase so a typo is self-correcting, \
3146 {phase} missing from: {message}"
3147 );
3148 }
3149 }
3150 }
3151
3152 /// Every rendered line's amount starts at the same column.
3153 ///
3154 /// The plain-text report is a table, and the phase column is what aligns it. `{:<14}`
3155 /// in [`LineItem`]'s `Display` is not enough on its own: a `Display` impl written as
3156 /// `f.write_str(...)` ignores the formatter's width outright, so the format string
3157 /// reads as padded while the output is ragged — `image-build` and `launch` differ by
3158 /// six characters and the dollar figures walk. The oracle's own render puts the
3159 /// amount at column 44 on all seven lines of this same report, checked with
3160 /// `line.index('~$')` on `cost.py`'s `CostReport.render()`.
3161 ///
3162 /// The assertion is that the columns *agree* rather than that they equal 44, because
3163 /// the widths belong to `LineItem` and a deliberate change there should not have to
3164 /// come here — but a phase that stops padding is not a deliberate change.
3165 ///
3166 /// **Falsification** — put `f.write_str(self.as_str())` back in [`CostPhase`]'s
3167 /// `Display` and this is red with two distinct offsets. Verified.
3168 #[test]
3169 fn every_rendered_line_puts_its_amount_in_the_same_column() {
3170 let report = run_report(
3171 SizeClass::Mib2048,
3172 &RunUsage {
3173 running: Some(DurationP::Measured(Duration::from_secs(3600))),
3174 image_build: Some(DurationP::Measured(Duration::from_secs(300))),
3175 image_gb: Some(2.0),
3176 suspend_resume_cycles: 1,
3177 ..RunUsage::launched()
3178 },
3179 &rates(),
3180 fresh_day(),
3181 "run",
3182 )
3183 .expect("a report");
3184 let text = report.render();
3185 // The phases whose names differ most: `image-storage` is thirteen characters and
3186 // `launch` is six, so an unpadded phase column shows up as a seven-column shift.
3187 let offsets: Vec<(usize, &str)> = text
3188 .lines()
3189 .filter(|line| line.starts_with(" "))
3190 .map(|line| {
3191 let amount = line
3192 .find("~$")
3193 .or_else(|| line.find("unpriced"))
3194 .unwrap_or_else(|| panic!("every line has an amount: {line}"));
3195 (amount, line)
3196 })
3197 .collect();
3198 assert!(offsets.len() >= 7, "{text}");
3199 let (first, first_line) = offsets[0];
3200 for (offset, line) in &offsets {
3201 assert_eq!(
3202 *offset, first,
3203 "the amount column has to agree across lines:\n{first_line}\n{line}"
3204 );
3205 }
3206 // And the phase is genuinely padded rather than the widths happening to cancel.
3207 assert_eq!(
3208 format!("{:<14}|", CostPhase::Launch),
3209 "launch |",
3210 "a phase in a width-bearing format slot has to pad"
3211 );
3212 }
3213
3214 /// A lower bound that will not say what it is missing is not a value [`Total`]
3215 /// holds: an empty reason list routes to [`Total::Exact`].
3216 #[test]
3217 fn a_total_over_only_priced_lines_is_exact_and_names_nothing() {
3218 let amounts = [Amount::estimated(dec!(1)), Amount::estimated(dec!(2))];
3219 let total = Total::of(amounts.iter().map(|amount| (CostPhase::Running, amount)));
3220 assert_eq!(total, Total::Exact(EstimatedUsd::new(dec!(3))));
3221 assert!(!total.is_lower_bound());
3222 assert!(total.unpriced_reasons().is_empty());
3223 assert_eq!(total.to_string(), "~$3.00 (estimated)");
3224 // The degenerate case: no lines at all is exactly zero, not a lower bound.
3225 assert_eq!(
3226 Total::of(std::iter::empty()),
3227 Total::Exact(EstimatedUsd::ZERO)
3228 );
3229 }
3230
3231 /// The complement of the build test: a run against an existing image pays no build,
3232 /// so incompleteness is a property of the phases present rather than a permanent
3233 /// disclaimer nobody reads.
3234 #[test]
3235 fn a_report_with_no_image_is_complete() {
3236 let report = running_report(
3237 SizeClass::Mib2048,
3238 DurationP::Measured(Duration::from_secs(60)),
3239 );
3240 assert!(report.is_complete());
3241 assert!(!report.total().is_lower_bound());
3242 assert!(matches!(report.total(), Total::Exact(_)));
3243 }
3244
3245 // -- per-phase attribution (COST-5, COST-8) ---------------------------------
3246
3247 /// Two entries rather than one blended GB-second, because that is how the pricing
3248 /// page prices them and a blended figure cannot be reconciled against a Cost
3249 /// Explorer breakdown that keeps them apart.
3250 #[test]
3251 fn running_bills_vcpu_and_memory_as_two_separate_lines() {
3252 let report = running_report(
3253 SizeClass::Mib2048,
3254 DurationP::Measured(Duration::from_secs(3600)),
3255 );
3256 let lines: Vec<Option<BillingLine>> = report
3257 .by_phase(CostPhase::Running)
3258 .map(|item| item.line)
3259 .collect();
3260 assert_eq!(lines, [Some(BillingLine::Vcpu), Some(BillingLine::Memory)]);
3261 }
3262
3263 /// COST-5. `sizing` locks that billing follows the baseline; this locks that cost
3264 /// obeys it. The 2 GB class reports 8 GB in the guest, so reading the peak would
3265 /// overstate the memory line exactly 4x — and the assertion below is against the
3266 /// baseline figure spelled out, so a peak-reading implementation is off by that 4x
3267 /// rather than merely different.
3268 #[test]
3269 fn running_bills_the_baseline_and_never_the_peak() {
3270 let size = SizeClass::Mib2048;
3271 let report = running_report(size, DurationP::Measured(Duration::from_secs(3600)));
3272 let rates = rates();
3273
3274 let memory: Vec<&LineItem> = report
3275 .by_phase(CostPhase::Running)
3276 .filter(|item| item.line == Some(BillingLine::Memory))
3277 .collect();
3278 assert_eq!(
3279 memory[0].amount.estimate().expect("priced").amount(),
3280 dec!(2) * dec!(3600) * rates.gb_second()
3281 );
3282 let vcpu: Vec<&LineItem> = report
3283 .by_phase(CostPhase::Running)
3284 .filter(|item| item.line == Some(BillingLine::Vcpu))
3285 .collect();
3286 assert_eq!(
3287 vcpu[0].amount.estimate().expect("priced").amount(),
3288 dec!(1) * dec!(3600) * rates.vcpu_second()
3289 );
3290
3291 // Named explicitly, so the failure message says which figure was read: the
3292 // peak is 8 GB / 4 vCPU for this class.
3293 assert_eq!(memory[0].quantity, dec!(2) * dec!(3600));
3294 assert_ne!(
3295 memory[0].quantity,
3296 gb_decimal(size.peak_gb()).expect("a documented peak") * dec!(3600),
3297 "the memory line must read baseline_gb, not peak_gb"
3298 );
3299 assert_ne!(
3300 vcpu[0].quantity,
3301 gb_decimal(size.peak_vcpu()).expect("a documented peak") * dec!(3600),
3302 "the vcpu line must read baseline_vcpu, not peak_vcpu"
3303 );
3304 }
3305
3306 /// COST-5 across the whole table, since one class cannot distinguish a baseline
3307 /// read from a peak read that happens to agree. No shipped class's baseline equals
3308 /// another's peak in a way that would let a peak-reading implementation pass.
3309 #[test]
3310 fn every_size_class_bills_its_own_baseline() {
3311 let rates = rates();
3312 for size in SizeClass::ALL {
3313 let report = running_report(size, DurationP::Measured(Duration::from_secs(1)));
3314 let memory: Vec<&LineItem> = report
3315 .by_phase(CostPhase::Running)
3316 .filter(|item| item.line == Some(BillingLine::Memory))
3317 .collect();
3318 let expected = gb_decimal(size.baseline_gb()).expect("a documented baseline");
3319 assert_eq!(memory[0].quantity, expected, "{size:?}");
3320 assert_eq!(
3321 memory[0].amount.estimate().expect("priced").amount(),
3322 expected * rates.gb_second(),
3323 "{size:?}"
3324 );
3325 }
3326 }
3327
3328 /// `docs/PLATFORM.md`: "roughly $100 a month to leave the same VM running at
3329 /// baseline". An order-of-magnitude anchor on the compute rates, independent of the
3330 /// exact rate literals above — the two would have to be wrong together.
3331 #[test]
3332 fn a_month_of_running_costs_about_a_hundred_dollars() {
3333 let report = running_report(SizeClass::Mib2048, DurationP::Projected(MONTH));
3334 let total = report.total().floor().amount();
3335 assert!(
3336 dec!(80) < total && total < dec!(120),
3337 "a month at baseline should be roughly $100, got {total}"
3338 );
3339 }
3340
3341 /// A suspended guest is frozen rather than stopped, so there is no compute line at
3342 /// all — not a compute line multiplied by zero, which would reappear the moment
3343 /// someone changed how a duration is derived.
3344 #[test]
3345 fn a_suspended_vm_pays_storage_and_no_compute() {
3346 let report = run_report(
3347 SizeClass::Mib2048,
3348 &RunUsage {
3349 suspended: Some(DurationP::Measured(MONTH)),
3350 launched: false,
3351 ..RunUsage::default()
3352 },
3353 &rates(),
3354 fresh_day(),
3355 "suspended",
3356 )
3357 .expect("a suspended hold prices");
3358 let lines: Vec<Option<BillingLine>> = report
3359 .by_phase(CostPhase::Suspended)
3360 .map(|item| item.line)
3361 .collect();
3362 assert_eq!(lines, [Some(BillingLine::SnapshotStorage)]);
3363 assert!(
3364 !report
3365 .items()
3366 .iter()
3367 .any(|item| item.line == Some(BillingLine::Vcpu)
3368 || item.line == Some(BillingLine::Memory)),
3369 "a frozen VM has no compute line at all"
3370 );
3371 // "about $0.16 a month" for a suspended 2 GB VM.
3372 let total = report.total().floor().amount();
3373 assert!(dec!(0.10) < total && total < dec!(0.25), "{total}");
3374 }
3375
3376 /// COST-8. The line item that dominates a create-and-destroy suite.
3377 /// `docs/PLATFORM.md`: a 2 GB image deleted sixty seconds after creation still bills
3378 /// about a week, "roughly four cents". Not applying the minimum understates the
3379 /// floor by four orders of magnitude and makes the compute look like the cost
3380 /// driver.
3381 #[test]
3382 fn image_storage_bills_the_one_week_minimum_for_a_sixty_second_image() {
3383 let rates = rates();
3384 let report = run_report(
3385 SizeClass::Mib2048,
3386 &RunUsage {
3387 image_gb: Some(2.0),
3388 image_build: Some(DurationP::Measured(Duration::from_secs(300))),
3389 image_retained: Some(DurationP::Measured(Duration::from_secs(60))),
3390 launched: false,
3391 ..RunUsage::default()
3392 },
3393 &rates,
3394 fresh_day(),
3395 "build",
3396 )
3397 .expect("a held image prices");
3398 let week = dec!(604800);
3399 assert_eq!(
3400 priced(&report, CostPhase::ImageStorage),
3401 dec!(2) * week / SECONDS_PER_MONTH * rates.storage_gb_month()
3402 );
3403 let amount = priced(&report, CostPhase::ImageStorage);
3404 assert!(
3405 dec!(0.03) < amount && amount < dec!(0.05),
3406 "roughly four cents, got {amount}"
3407 );
3408 // The floor must be visible, not just applied: a reader checking the quantity
3409 // column against the rate table needs to know why it says a week.
3410 let storage: Vec<&LineItem> = report.by_phase(CostPhase::ImageStorage).collect();
3411 assert!(
3412 storage[0].note.contains("minimum retention"),
3413 "{}",
3414 storage[0].note
3415 );
3416 assert!(storage[0].note.contains("604800s"), "{}", storage[0].note);
3417 // And the duration on the line is still the *measured* sixty seconds — the
3418 // floor changes what is billed, not what was observed.
3419 assert_eq!(
3420 storage[0].duration,
3421 Some(DurationP::Measured(Duration::from_secs(60)))
3422 );
3423 }
3424
3425 /// The note's day count is the rate row's own, not a division written beside the
3426 /// message.
3427 ///
3428 /// The two halves are one requirement: `minimum_retention_days` reports what
3429 /// `minimum_retention` holds, and the note quotes *that* rather than a second
3430 /// convention. Asserted against a table whose floor is deliberately **not** a week,
3431 /// which is the only input that separates the field from the literal — a hand-rolled
3432 /// `as_secs() / 86_400` at the call site passes the seven-day case and then keeps
3433 /// saying "7-day" here.
3434 ///
3435 /// **Falsification** — put `rates.minimum_retention().as_secs() / 86_400` back in
3436 /// `storage_line` and this stays green; hard-code either `7` or the week's seconds and
3437 /// it goes red naming the wrong day count. Verified: replacing the accessor's body
3438 /// with `7` fails the first assertion, and writing `"; billed 7-day minimum retention
3439 /// ({floor}s) instead"` fails the note assertion with `7-day` against a 14-day row.
3440 #[test]
3441 fn the_retention_note_reads_its_day_count_off_the_rate_row() {
3442 let week = rates();
3443 assert_eq!(week.minimum_retention_days(), 7);
3444 assert_eq!(
3445 week.minimum_retention(),
3446 Duration::from_secs(week.minimum_retention_days() * 24 * 60 * 60),
3447 "the days accessor and the duration must describe the same window"
3448 );
3449
3450 // A fortnight, which no rate table ships — the point is that nothing but the
3451 // field decides what the note says.
3452 let mut fortnight = week.clone();
3453 fortnight.minimum_retention = Duration::from_secs(14 * 24 * 60 * 60);
3454 assert_eq!(fortnight.minimum_retention_days(), 14);
3455
3456 let item = storage_line(
3457 CostPhase::ImageStorage,
3458 dec!(2),
3459 DurationP::Measured(Duration::from_secs(60)),
3460 &fortnight,
3461 )
3462 .expect("a floored hold prices");
3463 assert!(
3464 item.note.contains("14-day minimum retention"),
3465 "the note must quote the row's own window: {}",
3466 item.note
3467 );
3468 assert!(
3469 !item.note.contains("7-day"),
3470 "a second day-count convention survived in the message: {}",
3471 item.note
3472 );
3473 }
3474
3475 /// The floor is a floor, not a flat fee. A month-long hold that billed one week
3476 /// would understate a warm pool's storage by more than 4x.
3477 #[test]
3478 fn a_long_hold_bills_actual_time_rather_than_the_minimum() {
3479 let rates = rates();
3480 let report = run_report(
3481 SizeClass::Mib2048,
3482 &RunUsage {
3483 image_gb: Some(2.0),
3484 image_retained: Some(DurationP::Measured(MONTH)),
3485 launched: false,
3486 ..RunUsage::default()
3487 },
3488 &rates,
3489 fresh_day(),
3490 "build",
3491 )
3492 .expect("a long hold prices");
3493 assert_eq!(
3494 priced(&report, CostPhase::ImageStorage),
3495 dec!(2) * rates.storage_gb_month(),
3496 );
3497 let storage: Vec<&LineItem> = report.by_phase(CostPhase::ImageStorage).collect();
3498 assert!(
3499 !storage[0].note.contains("minimum retention"),
3500 "the note must not claim a floor that was not applied: {}",
3501 storage[0].note
3502 );
3503 }
3504
3505 /// The floor applies to a suspend snapshot too, because the minimum is on the rate
3506 /// row rather than on the image phase. `docs/PLATFORM.md` demonstrates it only for
3507 /// images, which is what the note distinguishes.
3508 #[test]
3509 fn a_briefly_held_suspend_snapshot_bills_the_same_floor() {
3510 let report = run_report(
3511 SizeClass::Mib2048,
3512 &RunUsage {
3513 suspended: Some(DurationP::Measured(Duration::from_secs(30))),
3514 launched: false,
3515 ..RunUsage::default()
3516 },
3517 &rates(),
3518 fresh_day(),
3519 "suspended",
3520 )
3521 .expect("a brief suspension prices");
3522 let items: Vec<&LineItem> = report.by_phase(CostPhase::Suspended).collect();
3523 assert!(
3524 items[0].note.contains("minimum retention"),
3525 "{}",
3526 items[0].note
3527 );
3528 assert_eq!(
3529 priced(&report, CostPhase::Suspended),
3530 dec!(2) * dec!(604800) / SECONDS_PER_MONTH * rates().storage_gb_month(),
3531 );
3532 }
3533
3534 /// Asymmetric rates — the write is ~2.5x the read — so a cycle costed with one rate
3535 /// twice is wrong in whichever direction it picked.
3536 #[test]
3537 fn a_suspend_resume_cycle_bills_a_write_and_a_read() {
3538 let rates = rates();
3539 let report = run_report(
3540 SizeClass::Mib2048,
3541 &RunUsage {
3542 suspend_resume_cycles: 1,
3543 launched: false,
3544 ..RunUsage::default()
3545 },
3546 &rates,
3547 fresh_day(),
3548 "cycle",
3549 )
3550 .expect("a cycle prices");
3551 assert_eq!(
3552 priced(&report, CostPhase::Suspend),
3553 dec!(2) * rates.snapshot_write_gb()
3554 );
3555 assert_eq!(
3556 priced(&report, CostPhase::Resume),
3557 dec!(2) * rates.snapshot_read_gb()
3558 );
3559 assert_ne!(
3560 rates.snapshot_write_gb(),
3561 rates.snapshot_read_gb(),
3562 "the two rates differ, which is why the two lines must not share one"
3563 );
3564 // "about $0.011 for a 2 GB VM" per cycle.
3565 let total = report.total().floor().amount();
3566 assert!(dec!(0.010) < total && total < dec!(0.012), "{total}");
3567 }
3568
3569 /// The churn argument only works if churn actually accumulates.
3570 #[test]
3571 fn cycles_scale_the_transition_cost() {
3572 let cycles_cost = |count: u32| {
3573 run_report(
3574 SizeClass::Mib2048,
3575 &RunUsage {
3576 suspend_resume_cycles: count,
3577 launched: false,
3578 ..RunUsage::default()
3579 },
3580 &rates(),
3581 fresh_day(),
3582 "cycle",
3583 )
3584 .expect("a cycle prices")
3585 .total()
3586 .floor()
3587 .amount()
3588 };
3589 assert_eq!(cycles_cost(10), cycles_cost(1) * dec!(10));
3590 // Zero cycles is zero lines, not a zero-priced line: nothing happened.
3591 assert_eq!(cycles_cost(0), Decimal::ZERO);
3592 }
3593
3594 /// A smaller baseline is a real cost lever, which is what makes the CLI's
3595 /// adequate-over-cheap default a deliberate trade rather than an oversight. 512 MiB
3596 /// to 8192 MiB is 16x on both vCPU and memory.
3597 #[test]
3598 fn a_smaller_baseline_is_a_real_cost_lever() {
3599 let small = running_report(SizeClass::Mib512, DurationP::Projected(MONTH))
3600 .total()
3601 .floor()
3602 .amount();
3603 let large = running_report(SizeClass::Mib8192, DurationP::Projected(MONTH))
3604 .total()
3605 .floor()
3606 .amount();
3607 assert_eq!(large, small * dec!(16));
3608 }
3609
3610 // -- estimate mode (COST-10) ------------------------------------------------
3611
3612 /// COST-10. An estimate is spent before the money is, so nothing in it can claim to
3613 /// have been timed.
3614 #[test]
3615 fn an_estimate_labels_every_duration_projected() {
3616 let report = estimate_run(
3617 SizeClass::Mib2048,
3618 &PlanUsage {
3619 running_seconds: 3600.0,
3620 suspended_seconds: 600.0,
3621 image_gb: Some(2.0),
3622 ..PlanUsage::launched()
3623 },
3624 &rates(),
3625 fresh_day(),
3626 "estimate",
3627 )
3628 .expect("a plan prices");
3629 let durations: Vec<DurationP> = report
3630 .items()
3631 .iter()
3632 .filter_map(|item| item.duration)
3633 .collect();
3634 assert!(
3635 !durations.is_empty(),
3636 "the estimate must attribute durations, not just totals"
3637 );
3638 for duration in durations {
3639 assert_eq!(
3640 duration.provenance(),
3641 Provenance::Projected,
3642 "every duration in a plan is projected"
3643 );
3644 }
3645 assert!(!report.fully_measured());
3646 }
3647
3648 /// Same rates, same phases: only the labels differ. If the two paths could disagree
3649 /// numerically, the label would be hiding a second implementation.
3650 #[test]
3651 fn an_estimate_and_a_measured_run_agree_on_the_arithmetic() {
3652 let projected = estimate_run(
3653 SizeClass::Mib2048,
3654 &PlanUsage {
3655 running_seconds: 3600.0,
3656 ..PlanUsage::default()
3657 },
3658 &rates(),
3659 fresh_day(),
3660 "estimate",
3661 )
3662 .expect("a plan prices");
3663 let measured = running_report(
3664 SizeClass::Mib2048,
3665 DurationP::Measured(Duration::from_secs(3600)),
3666 );
3667 assert_eq!(projected.total(), measured.total());
3668 // The line items agree on everything except the label, which is the claim.
3669 let strip = |report: &CostReport| -> Vec<(CostPhase, Option<BillingLine>, Decimal)> {
3670 report
3671 .items()
3672 .iter()
3673 .map(|item| (item.phase, item.line, item.quantity))
3674 .collect()
3675 };
3676 assert_eq!(strip(&projected), strip(&measured));
3677 assert!(!projected.fully_measured() && measured.fully_measured());
3678 }
3679
3680 /// A plan's fields are seconds, so there is no field a `Measured` duration could be
3681 /// written into — and a zero-length phase is an absent phase rather than a line
3682 /// claiming a measurement nobody took.
3683 #[test]
3684 fn a_plan_with_no_phases_has_no_durations_at_all() {
3685 let report = estimate_run(
3686 SizeClass::Mib2048,
3687 &PlanUsage::default(),
3688 &rates(),
3689 fresh_day(),
3690 "estimate",
3691 )
3692 .expect("an empty plan prices");
3693 assert!(report.items().is_empty());
3694 assert_eq!(report.total(), Total::Exact(EstimatedUsd::ZERO));
3695 // `fully_measured` is false for a report with no durations, not vacuously true
3696 // — an empty report must not read as a receipt.
3697 assert!(!report.fully_measured());
3698 }
3699
3700 /// An image on a plan still drags the unpriced build line in, because an image that
3701 /// has storage cost has to be built and the build's price is unknown. So a
3702 /// create-and-destroy estimate is never complete either.
3703 #[test]
3704 fn an_estimate_with_an_image_is_a_lower_bound_too() {
3705 let report = estimate_run(
3706 SizeClass::Mib2048,
3707 &PlanUsage {
3708 image_gb: Some(2.0),
3709 ..PlanUsage::launched()
3710 },
3711 &rates(),
3712 fresh_day(),
3713 "estimate",
3714 )
3715 .expect("a plan with an image prices");
3716 assert!(!report.is_complete());
3717 assert!(report.total().is_lower_bound());
3718 // The build line has no duration: a plan has not built anything, so there is
3719 // nothing to have timed, and inventing a projected build time would be a
3720 // number with no source.
3721 let build: Vec<&LineItem> = report.by_phase(CostPhase::ImageBuild).collect();
3722 assert_eq!(build[0].duration, None);
3723 assert_eq!(build[0].unit, "seconds (untimed)");
3724 }
3725
3726 /// A cost figure for a size the platform would refuse never gets produced — it would
3727 /// look like an answer. The refusal is `sizing`'s (TRAP-10) and this is the seam
3728 /// showing it is reachable from here.
3729 #[test]
3730 fn an_off_table_baseline_never_reaches_a_cost_figure() {
3731 let err =
3732 SizeClass::from_baseline_mib(1500).expect_err("1500 MiB is not a documented baseline");
3733 assert_eq!(err.kind(), ErrorKind::InvalidArg);
3734 assert!(
3735 err.to_string()
3736 .contains("not a documented size class baseline")
3737 );
3738 // And the accepted case really does select the class the caller named.
3739 let size = SizeClass::from_baseline_mib(1024).expect("1024 MiB is documented");
3740 assert_eq!(size.baseline_gb(), 1.0);
3741 }
3742
3743 /// A size a caller supplies as a float still has to be a number. The one f64
3744 /// boundary is fallible for this reason (COST-6).
3745 #[test]
3746 fn a_non_finite_or_negative_size_is_refused_at_the_float_boundary() {
3747 for gb in [-1.0, f64::NAN, f64::INFINITY] {
3748 let err = gb_decimal(gb).expect_err("only a finite non-negative size prices");
3749 assert_eq!(err.kind(), ErrorKind::InvalidArg, "{gb}");
3750 }
3751 // A magnitude no decimal can hold is refused rather than saturated: a saturated
3752 // figure is a plausible-looking number, which is the only kind nobody checks.
3753 assert!(gb_decimal(1e30).is_err());
3754 // And the boundary is exact where it succeeds — via the decimal string, not the
3755 // binary value, so 0.1 is 0.1 and not 0.1000000000000000055511151231257827.
3756 assert_eq!(gb_decimal(0.1).expect("0.1 is representable"), dec!(0.1));
3757 assert_eq!(gb_decimal(0.25).expect("0.25 is representable"), dec!(0.25));
3758 }
3759
3760 // -- the ARM-only rule (COST-9) --------------------------------------------
3761
3762 /// The catalog as the Pricing API returned it on 2026-08-07 for us-east-1: seven
3763 /// MicroVM line items, including the two x86 compute rates a MicroVM can never be
3764 /// billed at. They are here precisely so a test can prove we do not reach for them.
3765 fn recorded_catalog() -> RateCatalog {
3766 RateCatalog::new()
3767 .with_entry(
3768 "AWS-Lambda-MicroVM-vCPU-Second-ARM",
3769 "vCPU-Seconds",
3770 dec!(0.0000276944),
3771 )
3772 .with_entry(
3773 "AWS-Lambda-MicroVM-vCPU-Second",
3774 "vCPU-Seconds",
3775 dec!(0.0000326557),
3776 )
3777 .with_entry(
3778 "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
3779 "GB-Seconds",
3780 dec!(0.0000036667),
3781 )
3782 .with_entry(
3783 "AWS-Lambda-MicroVM-Memory-GB-Second",
3784 "GB-Seconds",
3785 dec!(0.0000043235),
3786 )
3787 .with_entry(
3788 "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
3789 "GB-Hours",
3790 dec!(0.0001111111),
3791 )
3792 .with_entry(
3793 "AWS-Lambda-MicroVM-Snapshot-Read-GB",
3794 "GB",
3795 dec!(0.0015467699),
3796 )
3797 .with_entry(
3798 "AWS-Lambda-MicroVM-Snapshot-Write-GB",
3799 "GB",
3800 dec!(0.0037977138),
3801 )
3802 }
3803
3804 /// The same catalog without one group, for the refusal cases.
3805 fn without(group: &str) -> RateCatalog {
3806 let mut catalog = RateCatalog::new();
3807 for line in CatalogLine::ALL {
3808 for candidate in [line.group()].into_iter().chain(line.x86_group()) {
3809 if candidate == group {
3810 continue;
3811 }
3812 let entry = recorded_catalog()
3813 .matching(candidate)
3814 .first()
3815 .map(|entry| (*entry).clone());
3816 if let Some(entry) = entry {
3817 catalog = catalog.with_entry(entry.group, entry.unit, entry.usd);
3818 }
3819 }
3820 }
3821 catalog
3822 }
3823
3824 /// The recorded catalog fills a table, and the pinned rates agree with it within the
3825 /// rounding of the pinned figures. This is the offline half of `mise run live:rates`
3826 /// — a hand-edit to [`pinned_rates`] fails here rather than waiting for someone to
3827 /// have credentials.
3828 #[test]
3829 fn the_recorded_catalog_fills_a_table_that_matches_the_pinned_one() {
3830 let fetched = RateTable::from_catalog(
3831 Region::UsEast1,
3832 "aws pricing api",
3833 CalendarDate::from_ymd(2026, 8, 7),
3834 &recorded_catalog(),
3835 )
3836 .expect("the recorded catalog is complete");
3837 // The compute rates are exact: the pinned table holds the API's own figures.
3838 assert_eq!(fetched.vcpu_second(), rates().vcpu_second());
3839 assert_eq!(fetched.gb_second(), rates().gb_second());
3840 // Storage went through the GB-hour to GB-month conversion and lands exactly on
3841 // the pinned literal, which is what makes that literal checkable offline.
3842 assert_eq!(fetched.storage_gb_month(), rates().storage_gb_month());
3843 // The snapshot rates are pinned as three-significant-figure roundings, so they
3844 // agree within the 0.5% drift tolerance rather than exactly.
3845 for (fetched, pinned) in [
3846 (fetched.snapshot_read_gb(), rates().snapshot_read_gb()),
3847 (fetched.snapshot_write_gb(), rates().snapshot_write_gb()),
3848 ] {
3849 let relative = (fetched - pinned).abs() / pinned;
3850 assert!(relative < dec!(0.005), "{fetched} vs {pinned}");
3851 assert_ne!(fetched, pinned, "the pinned figure really is a rounding");
3852 }
3853 // A fetched table is authoritative on rates and still hand-read on rules.
3854 assert_eq!(fetched.minimum_retention(), MINIMUM_RETENTION);
3855 }
3856
3857 /// COST-9, the falsification. The one substitution that would look entirely
3858 /// healthy: the x86 rate parses, is the same shape, and is 17.9% higher. Every
3859 /// estimate would inflate and nothing would say so.
3860 #[test]
3861 fn a_catalog_with_only_the_x86_compute_line_is_refused_not_substituted() {
3862 let catalog = without("AWS-Lambda-MicroVM-vCPU-Second-ARM");
3863 let err = RateTable::from_catalog(
3864 Region::UsEast1,
3865 "aws pricing api",
3866 CalendarDate::from_ymd(2026, 8, 7),
3867 &catalog,
3868 )
3869 .expect_err("a missing ARM line is refused, never substituted");
3870 assert_eq!(err.kind(), ErrorKind::InvalidArg);
3871 let message = err.to_string();
3872 assert!(message.contains("ARM64-only"), "{message}");
3873 assert!(
3874 message.contains("0.0000326557"),
3875 "the error must name the rate it refuses to substitute: {message}"
3876 );
3877 assert!(message.contains("18%"), "{message}");
3878 assert!(message.contains("vcpu_second"), "{message}");
3879 }
3880
3881 /// The same for memory, since two compute lines means two chances to substitute.
3882 #[test]
3883 fn the_memory_line_refuses_its_x86_sibling_too() {
3884 let err = RateTable::from_catalog(
3885 Region::UsEast1,
3886 "aws pricing api",
3887 CalendarDate::from_ymd(2026, 8, 7),
3888 &without("AWS-Lambda-MicroVM-Memory-GB-Second-ARM"),
3889 )
3890 .expect_err("a missing ARM memory line is refused");
3891 let message = err.to_string();
3892 assert!(message.contains("ARM64-only"), "{message}");
3893 assert!(message.contains("0.0000043235"), "{message}");
3894 assert!(message.contains("gb_second"), "{message}");
3895 }
3896
3897 /// The x86 probe is a courtesy, not a requirement: with neither line present the
3898 /// error still has to say which field cannot be filled.
3899 #[test]
3900 fn both_compute_rates_missing_still_names_the_field() {
3901 let mut catalog = without("AWS-Lambda-MicroVM-Memory-GB-Second-ARM");
3902 catalog = {
3903 let mut rebuilt = RateCatalog::new();
3904 for entry in catalog.entries.iter() {
3905 if entry.group != "AWS-Lambda-MicroVM-Memory-GB-Second" {
3906 rebuilt = rebuilt.with_entry(&*entry.group, &*entry.unit, entry.usd);
3907 }
3908 }
3909 rebuilt
3910 };
3911 let err = RateTable::from_catalog(
3912 Region::UsEast1,
3913 "aws pricing api",
3914 CalendarDate::from_ymd(2026, 8, 7),
3915 &catalog,
3916 )
3917 .expect_err("neither compute line means no table");
3918 let message = err.to_string();
3919 assert!(message.contains("gb_second"), "{message}");
3920 assert!(message.contains("neither"), "{message}");
3921 }
3922
3923 /// Snapshot lines have no architecture variant, so there is nothing to refuse — and
3924 /// the message must not claim otherwise, or a reader goes looking for an ARM line
3925 /// that never existed.
3926 #[test]
3927 fn a_missing_snapshot_rate_names_no_x86_sibling() {
3928 let err = RateTable::from_catalog(
3929 Region::UsEast1,
3930 "aws pricing api",
3931 CalendarDate::from_ymd(2026, 8, 7),
3932 &without("AWS-Lambda-MicroVM-Snapshot-Write-GB"),
3933 )
3934 .expect_err("a missing snapshot rate means no table");
3935 let message = err.to_string();
3936 assert!(message.contains("snapshot_write_gb"), "{message}");
3937 assert!(!message.contains("ARM64-only"), "{message}");
3938 }
3939
3940 /// An empty catalog is the *region*, not the catalog, and it is a different repair:
3941 /// check the region versus work out what AWS renamed. Measured, an unpriced region
3942 /// answers `AccessDeniedException` with a null message, which is why this message
3943 /// carries the IAM caveat.
3944 #[test]
3945 fn an_empty_catalog_reads_as_an_unpriced_region_not_a_renamed_line() {
3946 let err = RateTable::from_catalog(
3947 Region::unlisted("eu-central-1"),
3948 "aws pricing api",
3949 CalendarDate::from_ymd(2026, 8, 7),
3950 &RateCatalog::new(),
3951 )
3952 .expect_err("an empty catalog means no table");
3953 let message = err.to_string();
3954 assert!(message.contains("AccessDeniedException"), "{message}");
3955 assert!(message.contains("null message"), "{message}");
3956 assert!(
3957 message.contains("before auditing a policy"),
3958 "the caveat that saves someone reading an IAM policy that is fine: {message}"
3959 );
3960 assert!(!message.contains("ARM64-only"), "{message}");
3961 }
3962
3963 /// A restated unit is refused rather than silently rescaled. It is the only signal
3964 /// available if AWS restates storage per GB-month: the number moves 730x and every
3965 /// arithmetic check downstream still passes, because they all read the same table.
3966 #[test]
3967 fn a_restated_unit_is_refused_rather_than_silently_rescaled() {
3968 let catalog = without("AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour").with_entry(
3969 "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
3970 "GB-Months",
3971 dec!(0.0001111111),
3972 );
3973 let err = RateTable::from_catalog(
3974 Region::UsEast1,
3975 "aws pricing api",
3976 CalendarDate::from_ymd(2026, 8, 7),
3977 &catalog,
3978 )
3979 .expect_err("a restated unit is refused");
3980 let message = err.to_string();
3981 assert!(message.contains("quoted per \"GB-Months\""), "{message}");
3982 assert!(message.contains("still look plausible"), "{message}");
3983 }
3984
3985 /// A tiered rate cannot be flattened into one number, and picking the first would
3986 /// silently choose one.
3987 #[test]
3988 fn two_products_for_one_group_is_refused_rather_than_arbitrated() {
3989 let catalog = recorded_catalog().with_entry(
3990 "AWS-Lambda-MicroVM-vCPU-Second-ARM",
3991 "vCPU-Seconds",
3992 dec!(0.0000999999),
3993 );
3994 let err = RateTable::from_catalog(
3995 Region::UsEast1,
3996 "aws pricing api",
3997 CalendarDate::from_ymd(2026, 8, 7),
3998 &catalog,
3999 )
4000 .expect_err("two products for one group is refused");
4001 assert!(err.to_string().contains("silently select a rate"), "{err}");
4002 }
4003
4004 /// A fetched table drops straight into a report, which is the point of returning a
4005 /// [`RateTable`] rather than a map: every figure is computed against whatever table
4006 /// it was handed, so a regional table needs no second arithmetic path. Tokyo's
4007 /// compute is 16.4% over Virginia's.
4008 #[test]
4009 fn a_regional_table_needs_no_second_arithmetic_path() {
4010 let tokyo_catalog = RateCatalog::new()
4011 .with_entry(
4012 "AWS-Lambda-MicroVM-vCPU-Second-ARM",
4013 "vCPU-Seconds",
4014 dec!(0.0000322421),
4015 )
4016 .with_entry(
4017 "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
4018 "GB-Seconds",
4019 dec!(0.0000042688),
4020 )
4021 .with_entry(
4022 "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
4023 "GB-Hours",
4024 dec!(0.0001333333),
4025 )
4026 .with_entry(
4027 "AWS-Lambda-MicroVM-Snapshot-Read-GB",
4028 "GB",
4029 dec!(0.0018548941),
4030 )
4031 .with_entry(
4032 "AWS-Lambda-MicroVM-Snapshot-Write-GB",
4033 "GB",
4034 dec!(0.0046556039),
4035 );
4036 let tokyo = RateTable::from_catalog(
4037 Region::ApNortheast1,
4038 "aws pricing api",
4039 fresh_day(),
4040 &tokyo_catalog,
4041 )
4042 .expect("Tokyo's catalog is complete");
4043 assert!(tokyo.vcpu_second() / rates().vcpu_second() > dec!(1.16));
4044 assert!(tokyo.snapshot_write_gb() / rates().snapshot_write_gb() > dec!(1.22));
4045
4046 let here = run_report(
4047 SizeClass::Mib2048,
4048 &RunUsage {
4049 running: Some(DurationP::Measured(Duration::from_secs(3600))),
4050 launched: false,
4051 ..RunUsage::default()
4052 },
4053 &tokyo,
4054 fresh_day(),
4055 "run",
4056 )
4057 .expect("a Tokyo report prices");
4058 let there = running_report(
4059 SizeClass::Mib2048,
4060 DurationP::Measured(Duration::from_secs(3600)),
4061 );
4062 assert!(here.total().floor() > there.total().floor());
4063 assert!(here.render().contains("ap-northeast-1"));
4064 }
4065
4066 /// The catalog line metadata is the Python's `MICROVM_LINES`, field for field, so a
4067 /// drift report names the same thing in both clients.
4068 #[test]
4069 fn the_catalog_lines_match_the_python_rate_line_table() {
4070 let described: Vec<(&str, &str, &str, Option<&str>)> = CatalogLine::ALL
4071 .iter()
4072 .map(|line| (line.field(), line.group(), line.unit(), line.x86_group()))
4073 .collect();
4074 assert_eq!(
4075 described,
4076 [
4077 (
4078 "vcpu_second",
4079 "AWS-Lambda-MicroVM-vCPU-Second-ARM",
4080 "vCPU-Seconds",
4081 Some("AWS-Lambda-MicroVM-vCPU-Second")
4082 ),
4083 (
4084 "gb_second",
4085 "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
4086 "GB-Seconds",
4087 Some("AWS-Lambda-MicroVM-Memory-GB-Second")
4088 ),
4089 (
4090 "storage_gb_month",
4091 "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
4092 "GB-Hours",
4093 None
4094 ),
4095 (
4096 "snapshot_read_gb",
4097 "AWS-Lambda-MicroVM-Snapshot-Read-GB",
4098 "GB",
4099 None
4100 ),
4101 (
4102 "snapshot_write_gb",
4103 "AWS-Lambda-MicroVM-Snapshot-Write-GB",
4104 "GB",
4105 None
4106 ),
4107 ]
4108 );
4109 // Only the storage line converts, because only it is quoted per hour.
4110 assert!(CatalogLine::SnapshotStorageGbHour.is_per_hour());
4111 assert_eq!(
4112 CatalogLine::ALL
4113 .iter()
4114 .filter(|line| line.is_per_hour())
4115 .count(),
4116 1
4117 );
4118 }
4119
4120 // -- the warm-pool comparison ----------------------------------------------
4121
4122 /// The comparison at the shape every golden figure below was recorded against.
4123 fn month_comparison() -> ResidencyComparison {
4124 compare_residency(SizeClass::Mib2048, MONTH, 1, &rates(), fresh_day())
4125 .expect("a month-long comparison prices")
4126 }
4127
4128 /// The whole argument for a warm suspended pool, and the reason the strategy memo
4129 /// can decline to build the scheduler and still hand over the numbers.
4130 #[test]
4131 fn suspended_is_two_orders_of_magnitude_cheaper_than_running() {
4132 assert!(month_comparison().ratio() > dec!(100));
4133 }
4134
4135 /// Without the per-cycle cost the honest conclusion inverts: "suspend constantly"
4136 /// reads as free when each cycle pays a write plus a read.
4137 #[test]
4138 fn the_comparison_includes_the_per_cycle_cost() {
4139 let comparison = month_comparison();
4140 let rates = rates();
4141 assert_eq!(
4142 comparison.per_cycle().expect("a documented size").amount(),
4143 dec!(2) * (rates.snapshot_write_gb() + rates.snapshot_read_gb())
4144 );
4145 let rendered = comparison.render().expect("a comparison renders");
4146 assert!(rendered.contains("avoid churn"), "{rendered}");
4147 assert!(rendered.contains("break-even hold"), "{rendered}");
4148 }
4149
4150 /// **The golden.** The break-even hold at 2 GB, pinned against the Python oracle
4151 /// rather than against a re-derivation: its `compare_residency(size=2048,
4152 /// hold_seconds=730*3600, cycles=1).break_even_seconds` printed
4153 /// `1371.2916483478837` when it was run in the session that wrote this test.
4154 ///
4155 /// Worth recording that the task packet predicted ≈1357s. The oracle decides, and it
4156 /// says 1371.29 — a re-derivation would have been the wrong check here, because it
4157 /// is exactly what would have agreed with a plausible wrong number.
4158 ///
4159 /// The two clients agree to 22 significant digits and diverge only past that:
4160 /// `rust_decimal` saturates at 28 digits of scale where Python's `Decimal` context
4161 /// is unbounded. So the exact figure is asserted to a tolerance well inside a
4162 /// second, and the f64 form — which is what `cli.py` emits as `breakEvenSeconds` —
4163 /// is asserted to agree with the Python's float exactly.
4164 #[test]
4165 fn the_break_even_hold_at_two_gigabytes_matches_the_python_oracle() {
4166 let comparison = month_comparison();
4167 let exact = comparison.break_even_seconds().expect("a documented size");
4168 let oracle = dec!(1371.291648347883680961978771);
4169 assert!(
4170 (exact - oracle).abs() < dec!(0.000000001),
4171 "the Rust figure {exact} must match the Python oracle {oracle}"
4172 );
4173 // The f64 the CLI emits, byte-identical to the Python's.
4174 assert_eq!(
4175 comparison
4176 .break_even_seconds_f64()
4177 .expect("a documented size"),
4178 1371.2916483478837
4179 );
4180 // And the other recorded figures from the same oracle run, so a change to any
4181 // one of them is visible here rather than only in the break-even.
4182 assert_eq!(
4183 comparison.per_cycle().expect("a documented size").amount(),
4184 dec!(0.01070)
4185 );
4186 assert_eq!(comparison.ratio().round_dp(4), dec!(532.3380));
4187 }
4188
4189 /// The break-even is the number a pool scheduler needs, and this is what makes it
4190 /// meaningful rather than merely reproducible: just under it, a cycle loses money;
4191 /// just over, it saves. The test asserts the *verdict* the figure predicts, because a
4192 /// test that only pinned 1371.29 would pass against a formula that computed a number
4193 /// with no relation to the crossover.
4194 #[test]
4195 fn churn_below_break_even_costs_more_than_leaving_the_vm_running() {
4196 let break_even = month_comparison()
4197 .break_even_seconds()
4198 .expect("a documented size");
4199 assert!(break_even > Decimal::ZERO);
4200
4201 let suspended_beats_running = |hold: Duration| {
4202 let comparison = compare_residency(SizeClass::Mib2048, hold, 1, &rates(), fresh_day())
4203 .expect("a comparison prices");
4204 comparison.suspended().total().floor() < comparison.running().total().floor()
4205 };
4206 let at = |factor: Decimal| {
4207 Duration::from_secs_f64(
4208 (break_even * factor)
4209 .to_f64()
4210 .expect("the break-even hold fits in an f64"),
4211 )
4212 };
4213 assert!(
4214 !suspended_beats_running(at(dec!(0.9))),
4215 "just under the break-even hold, a cycle must lose money"
4216 );
4217 assert!(
4218 suspended_beats_running(at(dec!(1.1))),
4219 "just over it, a cycle must save"
4220 );
4221 }
4222
4223 /// Image build and storage are identical either way, so including them would shrink
4224 /// the ratio the comparison exists to show — and drag an unpriced build into a figure
4225 /// whose whole job is to be comparable.
4226 #[test]
4227 fn the_comparison_excludes_the_image_so_the_ratio_is_not_diluted() {
4228 let comparison = month_comparison();
4229 for report in [comparison.running(), comparison.suspended()] {
4230 let phases: Vec<CostPhase> = report.items().iter().map(|item| item.phase).collect();
4231 assert!(!phases.contains(&CostPhase::ImageBuild), "{phases:?}");
4232 assert!(!phases.contains(&CostPhase::ImageStorage), "{phases:?}");
4233 assert!(report.is_complete());
4234 // No launch either: a launch read would be paid on the running side only and
4235 // would tilt the ratio it is not part of.
4236 assert!(!phases.contains(&CostPhase::Launch), "{phases:?}");
4237 }
4238 }
4239
4240 /// The hold on a comparison is always projected: a comparison is a hypothetical
4241 /// about a hold nobody has taken yet, so neither side may read as a receipt.
4242 #[test]
4243 fn a_comparison_is_never_a_measured_report() {
4244 let comparison = month_comparison();
4245 assert_eq!(comparison.hold().provenance(), Provenance::Projected);
4246 assert!(!comparison.running().fully_measured());
4247 assert!(!comparison.suspended().fully_measured());
4248 }
4249
4250 /// The ratio's denominator can never be zero, because snapshot storage always has
4251 /// the minimum-retention floor (COST-8). The degenerate input — a zero-length hold —
4252 /// is the case that would divide by zero without it.
4253 #[test]
4254 fn a_zero_length_hold_still_has_a_finite_ratio() {
4255 let comparison =
4256 compare_residency(SizeClass::Mib2048, Duration::ZERO, 1, &rates(), fresh_day())
4257 .expect("a zero hold prices");
4258 assert!(comparison.suspended().total().floor().amount() > Decimal::ZERO);
4259 // Running for zero seconds costs nothing, so the ratio is zero rather than
4260 // undefined — and suspending a VM for no time is a pure loss, which is what the
4261 // break-even figure is about.
4262 assert_eq!(comparison.ratio(), Decimal::ZERO);
4263 }
4264
4265 // -- properties ------------------------------------------------------------
4266
4267 /// A plausible span, in seconds. Weighted rather than uniform, for the reason
4268 /// T-W2-2 measured: a uniform draw over the whole domain essentially never lands in
4269 /// the band where the interesting branch is reachable. Here the band that matters is
4270 /// the minimum-retention window, since that is where the storage floor applies and
4271 /// where the break-even formula changes slope.
4272 fn plausible_seconds() -> impl Strategy<Value = u64> {
4273 prop_oneof![
4274 // Inside the retention window, where the storage floor binds.
4275 4 => 0u64..604_800,
4276 // Past it, where storage grows with the hold.
4277 3 => 604_800u64..(730 * 3600 * 12),
4278 // The neighbourhood of the floor itself, where an off-by-one in the `max`
4279 // would hide.
4280 2 => 604_795u64..=604_805,
4281 // A long tail, so nothing above goes unchecked.
4282 1 => 0u64..u32::MAX as u64,
4283 ]
4284 }
4285
4286 proptest! {
4287 /// COST-4, as a property over every combination of priced and unpriced lines: a
4288 /// total is `AtLeast` exactly when an unpriced line is present, its floor is the
4289 /// sum of the priced ones and nothing else, and every unpriced reason is named.
4290 ///
4291 /// The verdict is computed from the generated input rather than read off the
4292 /// answer, which is what makes this catch a `Total::of` that dropped the
4293 /// unpriced line: such an implementation answers `Exact` where `documented`
4294 /// says `AtLeast`.
4295 ///
4296 /// The parenthetical is checked against a reference built the oracle's way —
4297 /// `", ".join(sorted({phase}))` — so a `Display` that reaches for reasons, report
4298 /// order, or the wrong separator disagrees on some generated input rather than
4299 /// only on the one report a hand-written test happens to build.
4300 #[test]
4301 fn a_total_is_a_lower_bound_exactly_when_something_is_unpriced(
4302 figures in prop::collection::vec(
4303 (
4304 prop::sample::select(&[
4305 CostPhase::ImageBuild,
4306 CostPhase::Running,
4307 CostPhase::Suspended,
4308 ][..]),
4309 prop_oneof![
4310 (0u64..1_000_000).prop_map(|cents| Amount::estimated(
4311 Decimal::from(cents) / dec!(100)
4312 )),
4313 "[a-z ]{1,20}".prop_map(Amount::unpriced),
4314 ],
4315 ),
4316 0..12,
4317 ),
4318 ) {
4319 let expected_floor: Decimal = figures
4320 .iter()
4321 .filter_map(|(_, amount)| amount.estimate())
4322 .map(EstimatedUsd::amount)
4323 .sum();
4324 let expected_reasons: Vec<String> = figures
4325 .iter()
4326 .filter_map(|(_, amount)| amount.unpriced_reason())
4327 .map(str::to_string)
4328 .collect();
4329 let expected_names: Vec<&str> = {
4330 let mut names: Vec<&str> = figures
4331 .iter()
4332 .filter(|(_, amount)| amount.unpriced_reason().is_some())
4333 .map(|(phase, _)| phase.as_str())
4334 .collect();
4335 names.sort_unstable();
4336 names.dedup();
4337 names
4338 };
4339
4340 let total = Total::of(figures.iter().map(|(phase, amount)| (*phase, amount)));
4341 prop_assert_eq!(total.is_lower_bound(), !expected_reasons.is_empty());
4342 prop_assert_eq!(total.floor().amount(), expected_floor);
4343 prop_assert_eq!(total.unpriced_reasons(), expected_reasons.as_slice());
4344 prop_assert_eq!(total.unpriced_phase_names(), expected_names.as_slice());
4345 match &total {
4346 Total::Exact(_) => prop_assert!(expected_reasons.is_empty()),
4347 Total::AtLeast { unpriced, .. } => prop_assert!(!unpriced.is_empty()),
4348 }
4349 // The rendering says which it is, since that is what a reader sees.
4350 prop_assert_eq!(
4351 total.to_string().starts_with("at least "),
4352 total.is_lower_bound()
4353 );
4354 if total.is_lower_bound() {
4355 prop_assert_eq!(
4356 total.to_string(),
4357 format!(
4358 "at least {}, plus {} unpriced ({})",
4359 total.floor(),
4360 expected_reasons.len(),
4361 expected_names.join(", ")
4362 )
4363 );
4364 }
4365 }
4366
4367 /// COST-6, over the whole arithmetic: a report's figures are exactly what the
4368 /// rate table times the quantities says they are, with no precision lost. The
4369 /// reference is recomputed in `Decimal` from the same inputs, so a float
4370 /// intermediate anywhere in the compute path disagrees.
4371 #[test]
4372 fn compute_figures_are_exactly_the_decimal_reference(
4373 seconds in plausible_seconds(),
4374 class_index in 0usize..5,
4375 ) {
4376 let size = SizeClass::ALL[class_index];
4377 let rates = rates();
4378 let duration = Duration::from_secs(seconds);
4379 let report = run_report(
4380 size,
4381 &RunUsage {
4382 running: Some(DurationP::Measured(duration)),
4383 launched: false,
4384 ..RunUsage::default()
4385 },
4386 &rates,
4387 fresh_day(),
4388 "run",
4389 )?;
4390
4391 let exact_seconds = Decimal::from(seconds);
4392 let vcpu = gb_decimal(size.baseline_vcpu())?;
4393 let memory = gb_decimal(size.baseline_gb())?;
4394 let expected_vcpu = vcpu * exact_seconds * rates.vcpu_second();
4395 let expected_memory = memory * exact_seconds * rates.gb_second();
4396
4397 let mut lines = report.by_phase(CostPhase::Running);
4398 let vcpu_line = lines.next().expect("a vcpu line");
4399 let memory_line = lines.next().expect("a memory line");
4400 prop_assert_eq!(vcpu_line.amount.estimate().expect("priced").amount(), expected_vcpu);
4401 prop_assert_eq!(memory_line.amount.estimate().expect("priced").amount(), expected_memory);
4402 // And the total is the sum of exactly those two, not a re-derivation.
4403 prop_assert_eq!(
4404 report.total(),
4405 Total::Exact(EstimatedUsd::new(expected_vcpu + expected_memory))
4406 );
4407 // COST-5 as a property: the quantity is the baseline times the seconds, and
4408 // for every class the peak figure is a different number.
4409 prop_assert_eq!(memory_line.quantity, memory * exact_seconds);
4410 if seconds > 0 {
4411 prop_assert_ne!(
4412 memory_line.quantity,
4413 gb_decimal(size.peak_gb())? * exact_seconds
4414 );
4415 }
4416 }
4417
4418 /// COST-8 as a property: storage bills `max(held, one week)` for every hold,
4419 /// and the note says so exactly when the floor bound. A `max` written the wrong
4420 /// way round passes at long holds and fails inside the window, which is why the
4421 /// generator is weighted towards it.
4422 #[test]
4423 fn storage_bills_the_greater_of_the_hold_and_the_retention_floor(
4424 seconds in plausible_seconds(),
4425 ) {
4426 let rates = rates();
4427 let report = run_report(
4428 SizeClass::Mib2048,
4429 &RunUsage {
4430 suspended: Some(DurationP::Measured(Duration::from_secs(seconds))),
4431 launched: false,
4432 ..RunUsage::default()
4433 },
4434 &rates,
4435 fresh_day(),
4436 "suspended",
4437 )?;
4438 let floor = seconds_of(rates.minimum_retention());
4439 let billed = Decimal::from(seconds).max(floor);
4440 let expected = dec!(2) * billed / SECONDS_PER_MONTH;
4441
4442 let items: Vec<&LineItem> = report.by_phase(CostPhase::Suspended).collect();
4443 prop_assert_eq!(items[0].quantity, expected);
4444 prop_assert_eq!(
4445 items[0].amount.estimate().expect("priced").amount(),
4446 expected * rates.storage_gb_month()
4447 );
4448 // The verdict on the note, computed from the input: it claims the floor
4449 // exactly when the floor was what bound.
4450 prop_assert_eq!(
4451 items[0].note.contains("minimum retention"),
4452 Decimal::from(seconds) < floor
4453 );
4454 // Never below the floor's own charge, which is the failure this requirement
4455 // is about — understating a create-and-destroy suite by four orders of
4456 // magnitude.
4457 prop_assert!(items[0].quantity >= dec!(2) * floor / SECONDS_PER_MONTH);
4458 }
4459
4460 /// COST-10 as a property: whatever a plan contains, every duration in the
4461 /// resulting report is projected and the report is never fully measured.
4462 #[test]
4463 fn every_duration_in_a_plan_is_projected(
4464 running in 0u64..1_000_000,
4465 suspended in 0u64..1_000_000,
4466 retained in prop::option::of(1u64..1_000_000),
4467 cycles in 0u32..5,
4468 image in prop::option::of(0.5f64..64.0),
4469 ) {
4470 let report = estimate_run(
4471 SizeClass::Mib2048,
4472 &PlanUsage {
4473 running_seconds: running as f64,
4474 suspended_seconds: suspended as f64,
4475 image_gb: image,
4476 image_retained_seconds: retained.map(|seconds| seconds as f64),
4477 suspend_resume_cycles: cycles,
4478 snapshot_gb: None,
4479 launched: true,
4480 },
4481 &rates(),
4482 fresh_day(),
4483 "estimate",
4484 )?;
4485 for item in report.items() {
4486 if let Some(duration) = item.duration {
4487 prop_assert_eq!(duration.provenance(), Provenance::Projected);
4488 }
4489 }
4490 prop_assert!(!report.fully_measured());
4491 // An image drags the unpriced build in, so the total's shape is decided by
4492 // whether one was named — the verdict, computed from the input.
4493 prop_assert_eq!(report.total().is_lower_bound(), image.is_some());
4494 }
4495
4496 /// The measured and projected paths are one arithmetic. If they could disagree
4497 /// numerically, the label would be hiding a second implementation.
4498 #[test]
4499 fn a_plan_and_a_timed_run_of_the_same_shape_price_identically(
4500 running in 1u64..1_000_000,
4501 cycles in 0u32..5,
4502 ) {
4503 let projected = estimate_run(
4504 SizeClass::Mib2048,
4505 &PlanUsage {
4506 running_seconds: running as f64,
4507 suspend_resume_cycles: cycles,
4508 ..PlanUsage::default()
4509 },
4510 &rates(),
4511 fresh_day(),
4512 "estimate",
4513 )?;
4514 let measured = run_report(
4515 SizeClass::Mib2048,
4516 &RunUsage {
4517 running: Some(DurationP::Measured(Duration::from_secs(running))),
4518 suspend_resume_cycles: cycles,
4519 launched: false,
4520 ..RunUsage::default()
4521 },
4522 &rates(),
4523 fresh_day(),
4524 "run",
4525 )?;
4526 prop_assert_eq!(projected.total(), measured.total());
4527 prop_assert!(!projected.fully_measured());
4528 }
4529
4530 /// The float boundary is exact where it succeeds (COST-6). Every f64 that has a
4531 /// decimal reading gets that reading, not its binary approximation — which is
4532 /// what `Decimal::try_from(0.1f64)` would give.
4533 #[test]
4534 fn the_float_boundary_reads_the_decimal_spelling_not_the_binary_value(
4535 gb in 0.0f64..1_000_000.0,
4536 ) {
4537 let converted = gb_decimal(gb)?;
4538 prop_assert_eq!(converted.to_string(), gb.to_string());
4539 prop_assert!(converted >= Decimal::ZERO);
4540 }
4541
4542 /// The staleness verdict is a comparison against ninety days and nothing else,
4543 /// over a range of retrieval ages. Computed from the input, so an off-by-one at
4544 /// the boundary fails.
4545 #[test]
4546 fn staleness_is_the_ninety_day_verdict_and_carries_the_age(
4547 age_days in -10i64..400,
4548 ) {
4549 let rates = rates();
4550 let today = CalendarDate::from_day_number(
4551 rates.retrieved().day_number() + age_days
4552 )
4553 .expect("in range");
4554 prop_assert_eq!(rates.age_days(today), age_days);
4555 prop_assert_eq!(rates.is_stale(today), age_days > STALE_AFTER_DAYS);
4556 match rates.staleness(today) {
4557 Some(warning) => {
4558 prop_assert!(age_days > STALE_AFTER_DAYS);
4559 let expected = format!("{age_days} days ago");
4560 prop_assert!(warning.contains(&expected), "{}", warning);
4561 }
4562 None => prop_assert!(age_days <= STALE_AFTER_DAYS),
4563 }
4564 // And the report carries whatever the table said.
4565 let report = run_report(
4566 SizeClass::Mib2048,
4567 &RunUsage {
4568 running: Some(DurationP::Measured(Duration::from_secs(60))),
4569 launched: false,
4570 ..RunUsage::default()
4571 },
4572 &rates,
4573 today,
4574 "run",
4575 )?;
4576 prop_assert_eq!(report.staleness().is_some(), age_days > STALE_AFTER_DAYS);
4577 }
4578
4579 /// The date round trip, which is what licenses a hand-rolled calendar: every day
4580 /// number is a date whose day number is itself, across four centuries.
4581 #[test]
4582 fn a_day_number_round_trips_through_its_calendar_date(
4583 day_number in -30_000i64..30_000,
4584 ) {
4585 let date = CalendarDate::from_day_number(day_number).expect("in range");
4586 prop_assert_eq!(date.day_number(), day_number);
4587 // And the parts are a real calendar day, so the validator agrees with the
4588 // arithmetic.
4589 prop_assert!(
4590 CalendarDate::try_from_ymd(date.year(), date.month(), date.day()).is_ok(),
4591 "{date} is not a calendar day"
4592 );
4593 }
4594 }
4595
4596 // ── the report as JSON (#255) ─────────────────────────────────────────
4597
4598 /// A create-and-destroy run: timed running and build, and an image whose retention
4599 /// nobody timed.
4600 fn a_create_and_destroy_report() -> CostReport {
4601 run_report(
4602 SizeClass::Mib2048,
4603 &RunUsage {
4604 running: Some(DurationP::Measured(Duration::from_secs(3600))),
4605 image_gb: Some(2.0),
4606 image_build: Some(DurationP::Measured(Duration::from_secs(600))),
4607 ..RunUsage::launched()
4608 },
4609 &pinned_rates(),
4610 fresh_day(),
4611 "run img",
4612 )
4613 .expect("a report")
4614 }
4615
4616 /// Running time or a non-zero image implies a launch, and nothing else does: suspended
4617 /// time alone is a VM that was already up, and a zero-sized image is no image (#255).
4618 ///
4619 /// **Falsification**: `verify/guards/faults/cost-defaults.toml` entry
4620 /// `domain-infer-launched-zero-image` counts `Some(0.0)` as an image, and the zero-image
4621 /// rows go red.
4622 #[test]
4623 fn a_launch_is_inferred_from_running_time_or_a_real_image_only() {
4624 let plans = [
4625 (PlanUsage::default(), false),
4626 (
4627 PlanUsage {
4628 running_seconds: 60.0,
4629 ..PlanUsage::default()
4630 },
4631 true,
4632 ),
4633 (
4634 PlanUsage {
4635 suspended_seconds: 3600.0,
4636 ..PlanUsage::default()
4637 },
4638 false,
4639 ),
4640 (
4641 PlanUsage {
4642 suspend_resume_cycles: 2,
4643 ..PlanUsage::default()
4644 },
4645 false,
4646 ),
4647 (
4648 PlanUsage {
4649 image_gb: Some(2.0),
4650 ..PlanUsage::default()
4651 },
4652 true,
4653 ),
4654 (
4655 PlanUsage {
4656 image_gb: Some(0.0),
4657 ..PlanUsage::default()
4658 },
4659 false,
4660 ),
4661 ];
4662 for (plan, launched) in plans {
4663 assert_eq!(plan.infer_launched(), launched, "{plan:?}");
4664 }
4665 let runs = [
4666 (RunUsage::default(), false),
4667 (
4668 RunUsage {
4669 running: Some(DurationP::Measured(Duration::from_secs(60))),
4670 ..RunUsage::default()
4671 },
4672 true,
4673 ),
4674 (
4675 RunUsage {
4676 running: Some(DurationP::Measured(Duration::ZERO)),
4677 ..RunUsage::default()
4678 },
4679 false,
4680 ),
4681 (
4682 RunUsage {
4683 suspended: Some(DurationP::Measured(Duration::from_secs(60))),
4684 ..RunUsage::default()
4685 },
4686 false,
4687 ),
4688 (
4689 RunUsage {
4690 image_gb: Some(2.0),
4691 ..RunUsage::default()
4692 },
4693 true,
4694 ),
4695 (
4696 RunUsage {
4697 image_gb: Some(0.0),
4698 ..RunUsage::default()
4699 },
4700 false,
4701 ),
4702 ];
4703 for (usage, launched) in runs {
4704 assert_eq!(usage.infer_launched(), launched, "{usage:?}");
4705 }
4706 }
4707
4708 /// A usage's default counts the report's default cycles, zero, and a report of it has no
4709 /// suspend or resume line, while the comparison's default prices one cycle (#280).
4710 #[test]
4711 fn a_report_defaults_to_no_cycle_and_a_comparison_to_one() {
4712 assert_eq!(DEFAULT_REPORT_CYCLES, 0);
4713 assert_eq!(
4714 RunUsage::default().suspend_resume_cycles,
4715 DEFAULT_REPORT_CYCLES
4716 );
4717 assert_eq!(
4718 PlanUsage::default().suspend_resume_cycles,
4719 DEFAULT_REPORT_CYCLES
4720 );
4721 let report = estimate_run(
4722 SizeClass::Mib2048,
4723 &PlanUsage {
4724 running_seconds: 600.0,
4725 ..PlanUsage::launched()
4726 },
4727 &rates(),
4728 fresh_day(),
4729 DEFAULT_ESTIMATE_LABEL,
4730 )
4731 .expect("a report");
4732 let phases: Vec<CostPhase> = report.items().iter().map(|item| item.phase).collect();
4733 assert_eq!(
4734 phases,
4735 [CostPhase::Launch, CostPhase::Running, CostPhase::Running]
4736 );
4737
4738 assert_eq!(DEFAULT_RESIDENCY_CYCLES, 1);
4739 let comparison = compare_residency(
4740 SizeClass::Mib2048,
4741 Duration::from_secs(3600),
4742 DEFAULT_RESIDENCY_CYCLES,
4743 &rates(),
4744 fresh_day(),
4745 )
4746 .expect("a comparison");
4747 let suspended: Vec<CostPhase> = comparison
4748 .suspended()
4749 .items()
4750 .iter()
4751 .map(|item| item.phase)
4752 .collect();
4753 assert_eq!(
4754 suspended,
4755 [CostPhase::Suspended, CostPhase::Suspend, CostPhase::Resume]
4756 );
4757 }
4758
4759 /// **The unpriced line has no `usd` key at all.**
4760 ///
4761 /// Not a null, which anything permissive sums as zero, and the build is the one phase
4762 /// AWS does not publish a rate for, so this is the field that stops a create-and-destroy
4763 /// report from reading as compute-only.
4764 ///
4765 /// **Falsification**: `verify/guards/faults/cost-defaults.toml` entry
4766 /// `domain-report-json-usd-null` emits `"usd": null` for the unpriced arm, and this test is
4767 /// red on the key's presence while every total still looks right.
4768 #[test]
4769 fn an_unpriced_line_omits_the_usd_key_rather_than_reporting_zero() {
4770 let json = a_create_and_destroy_report().to_json();
4771 let items = json["items"].as_array().expect("items");
4772 let build = items
4773 .iter()
4774 .find(|item| item["phase"] == "image-build")
4775 .expect("the build line is always present when an image is");
4776 assert_eq!(build["amount"]["kind"], "unpriced");
4777 assert!(
4778 build["amount"].get("usd").is_none(),
4779 "a null would be summed as zero: {build}"
4780 );
4781 assert!(
4782 build["amount"]["reason"]
4783 .as_str()
4784 .expect("a reason")
4785 .contains("does not publish"),
4786 "{build}"
4787 );
4788 // And the total says it is a floor rather than a figure.
4789 assert_eq!(json["total"]["isLowerBound"], true);
4790 assert!(
4791 json["total"]["render"]
4792 .as_str()
4793 .expect("a render")
4794 .contains("at least"),
4795 "{}",
4796 json["total"]["render"]
4797 );
4798 assert_eq!(json["complete"], false);
4799 }
4800
4801 /// Every dollar figure is a string, and every priced line has one. Every *seconds*
4802 /// figure is a number.
4803 ///
4804 /// A JSON number for a dollar figure is a `Decimal` that went through an f64, which is the
4805 /// exactness the type exists to hold. Asserted over every item so a single unconverted
4806 /// field fails.
4807 ///
4808 /// The seconds half is the same assertion pointed the other way, and it is pinned here
4809 /// rather than left implicit because the two rules look contradictory side by side and the
4810 /// natural "tidy-up" is to make them agree. The Python oracle's `cli.py:743` emitted
4811 /// `duration.seconds` as a float, so a string here is a field a consumer has to branch on
4812 /// by client, which is exactly the substitutability `breakEvenSeconds` was already a
4813 /// number to preserve.
4814 ///
4815 /// The oracle's recorded output for `cost --json --running-sec=3600`, read off
4816 /// `data.report.items[0].duration.seconds`:
4817 ///
4818 /// ```text
4819 /// 3600.0
4820 /// ```
4821 ///
4822 /// A transcript rather than a command, because that client was deleted once this one had
4823 /// driven the live suite green. The figure is what it printed; git history is where the
4824 /// code that printed it is.
4825 ///
4826 /// **Falsification**: put `.to_string()` back on either seconds field and the matching
4827 /// arm here is red while every dollar assertion stays green, which is what says the two
4828 /// halves are independent. `verify/guards/faults/cost-defaults.toml` entry
4829 /// `domain-report-json-seconds-string` does it to a line's seconds.
4830 #[test]
4831 fn every_dollar_is_a_string_and_every_seconds_figure_is_a_number() {
4832 let json = a_create_and_destroy_report().to_json();
4833 let mut durations = 0;
4834 for item in json["items"].as_array().expect("items") {
4835 if let Some(usd) = item["amount"].get("usd") {
4836 assert!(usd.is_string(), "{item}");
4837 usd.as_str()
4838 .expect("a string")
4839 .parse::<f64>()
4840 .expect("still parses as a figure");
4841 }
4842 assert!(item["quantity"].is_string(), "{item}");
4843 if !item["duration"].is_null() {
4844 durations += 1;
4845 assert!(
4846 item["duration"]["seconds"].is_number(),
4847 "a number, matching cli.py:743: {item}"
4848 );
4849 }
4850 }
4851 assert!(
4852 durations > 0,
4853 "vacuous unless a duration was present: {json}"
4854 );
4855 assert!(json["total"]["priced"].is_string());
4856
4857 // The exact values, so this is parity rather than merely a type check. Checked against
4858 // the oracle on this same report shape (`600` for the timed build and `604800.0` for
4859 // the retention floor), read through `as_f64` because the oracle's build figure is a
4860 // JSON int where its retention figure is a float, and both are numbers.
4861 let seconds: Vec<f64> = json["items"]
4862 .as_array()
4863 .expect("items")
4864 .iter()
4865 .filter(|item| !item["duration"].is_null())
4866 .map(|item| item["duration"]["seconds"].as_f64().expect("a number"))
4867 .collect();
4868 assert!(seconds.contains(&600.0), "{seconds:?}");
4869 assert!(seconds.contains(&604_800.0), "{seconds:?}");
4870
4871 // And `holdSeconds` on the comparison, which is the second field `cli.py` (`:2189`)
4872 // emits as a number and the one a scheduler compares against a timeout.
4873 let comparison = compare_residency(
4874 SizeClass::Mib2048,
4875 Duration::from_secs(3600),
4876 1,
4877 &pinned_rates(),
4878 fresh_day(),
4879 )
4880 .expect("a comparison")
4881 .to_json()
4882 .expect("renders");
4883 assert_eq!(comparison["holdSeconds"].as_f64(), Some(3600.0));
4884 assert!(comparison["breakEvenSeconds"].is_number());
4885 // The money beside it stays a string, which is the whole point of the split.
4886 assert!(comparison["perCycleUsd"].is_string());
4887 }
4888
4889 /// A measured run labels the phases a clock timed measured, and the one nobody timed
4890 /// projected.
4891 ///
4892 /// The mixed case is the interesting one and my first draft got it wrong by asserting every
4893 /// duration on a measured report is measured. It is not: `image-storage`'s duration defaults
4894 /// to the documented one-week minimum retention, and core marks it
4895 /// `Projected` because, in its own words, "nobody timed that week either". So a
4896 /// create-and-destroy report is honestly a mixture, and `fullyMeasured` is `false` for it.
4897 ///
4898 /// Worth pinning precisely because the wrong version reads better: "a measured report is
4899 /// measured" is the assertion someone would write, and it would force the retention default
4900 /// to lie about a week nobody observed.
4901 #[test]
4902 fn a_measured_report_labels_the_timed_phases_measured_and_the_retention_projected() {
4903 let measured = a_create_and_destroy_report().to_json();
4904 let labelled: Vec<(&str, &str)> = measured["items"]
4905 .as_array()
4906 .expect("items")
4907 .iter()
4908 .filter(|item| !item["duration"].is_null())
4909 .map(|item| {
4910 (
4911 item["phase"].as_str().unwrap_or_default(),
4912 item["duration"]["provenance"].as_str().unwrap_or_default(),
4913 )
4914 })
4915 .collect();
4916 assert!(
4917 labelled.contains(&("running", "measured")),
4918 "a clock timed the run: {labelled:?}"
4919 );
4920 assert!(
4921 labelled.contains(&("image-build", "measured")),
4922 "a clock timed the build: {labelled:?}"
4923 );
4924 assert!(
4925 labelled.contains(&("image-storage", "projected")),
4926 "nobody timed the one-week retention floor: {labelled:?}"
4927 );
4928 assert_eq!(
4929 measured["fullyMeasured"], false,
4930 "a report carrying a projected retention is not fully measured"
4931 );
4932
4933 let estimate = estimate_run(
4934 SizeClass::Mib2048,
4935 &PlanUsage {
4936 running_seconds: 3600.0,
4937 ..PlanUsage::launched()
4938 },
4939 &pinned_rates(),
4940 fresh_day(),
4941 DEFAULT_ESTIMATE_LABEL,
4942 )
4943 .expect("an estimate");
4944 let json = estimate.to_json();
4945 assert_eq!(json["fullyMeasured"], false);
4946 // *Every* duration, with no exception, which is the difference from the measured report
4947 // above: an estimate has nothing a clock touched.
4948 for item in json["items"].as_array().expect("items") {
4949 if !item["duration"].is_null() {
4950 assert_eq!(item["duration"]["provenance"], "projected", "{item}");
4951 }
4952 }
4953
4954 // And a report whose only phase *was* timed is fully measured, so the flag is a real
4955 // comparison rather than something that is always false.
4956 let timed_only = run_report(
4957 SizeClass::Mib2048,
4958 &RunUsage {
4959 running: Some(DurationP::Measured(Duration::from_secs(60))),
4960 ..RunUsage::launched()
4961 },
4962 &pinned_rates(),
4963 fresh_day(),
4964 "run",
4965 )
4966 .expect("a report");
4967 assert_eq!(timed_only.to_json()["fullyMeasured"], true);
4968 // And `estimated: true` is on both, because dollars are always estimates.
4969 assert_eq!(measured["estimated"], true);
4970 assert_eq!(json["estimated"], true);
4971 }
4972
4973 /// The report's key set is `cli.py`'s `report_to_dict` key for key, plus the one
4974 /// addition since that client retired: `headroomMib` (issue #99).
4975 ///
4976 /// Pinned because the conformance oracle reads these names: a rename here is a driver
4977 /// that reports every cost check as missing rather than as wrong. It's every surface's
4978 /// shape since #255, when the bindings' hand-written copies lacked `headroomMib`.
4979 ///
4980 /// **Falsification**: `verify/guards/faults/cost-defaults.toml` entry
4981 /// `domain-report-json-headroom` drops `headroomMib`, and the size keys go red.
4982 #[test]
4983 fn the_report_json_carries_the_pythons_key_set() {
4984 let json = a_create_and_destroy_report().to_json();
4985 let mut keys: Vec<&String> = json.as_object().expect("object").keys().collect();
4986 keys.sort();
4987 assert_eq!(
4988 keys,
4989 [
4990 "complete",
4991 "estimated",
4992 "fullyMeasured",
4993 "items",
4994 "label",
4995 "rates",
4996 "size",
4997 "staleness",
4998 "total",
4999 ]
5000 );
5001 let mut size_keys: Vec<&String> =
5002 json["size"].as_object().expect("object").keys().collect();
5003 size_keys.sort();
5004 assert_eq!(
5005 size_keys,
5006 [
5007 "baselineMib",
5008 "baselineVcpu",
5009 "describe",
5010 "headroomMib",
5011 "peakMib",
5012 "peakVcpu"
5013 ]
5014 );
5015 let mut item_keys: Vec<&String> = json["items"][0]
5016 .as_object()
5017 .expect("object")
5018 .keys()
5019 .collect();
5020 item_keys.sort();
5021 assert_eq!(
5022 item_keys,
5023 [
5024 "amount", "duration", "line", "note", "phase", "quantity", "unit"
5025 ]
5026 );
5027 }
5028
5029 /// A comparison carries its own counter-argument: the per-cycle cost and the break-even
5030 /// hold.
5031 ///
5032 /// Without them "100x cheaper suspended" reads as "suspend constantly", and a pool that
5033 /// churns every few seconds spends more on transitions than it saves on residency.
5034 #[test]
5035 fn a_comparison_carries_the_per_cycle_cost_and_the_break_even_hold() {
5036 let comparison = compare_residency(
5037 SizeClass::Mib2048,
5038 Duration::from_secs(3600),
5039 1,
5040 &pinned_rates(),
5041 fresh_day(),
5042 )
5043 .expect("a comparison");
5044 let json = comparison.to_json().expect("renders");
5045 assert!(json["perCycleUsd"].is_string());
5046 assert!(
5047 json["ratio"].is_string(),
5048 "the ratio is not money but is decimal"
5049 );
5050 let break_even = json["breakEvenSeconds"].as_f64().expect("a number");
5051 assert!(break_even > 0.0, "{break_even}");
5052 assert!(
5053 json["render"]
5054 .as_str()
5055 .expect("a render")
5056 .contains("avoid churn"),
5057 "the conclusion the numbers support has to travel with them"
5058 );
5059 }
5060
5061 /// A staleness warning survives into the envelope.
5062 ///
5063 /// The warning has to reach whatever renders it: a library caller with a log filter and a
5064 /// CLI that only wrote stderr would each lose it on their own, which is why core carries
5065 /// it on the report.
5066 #[test]
5067 fn a_stale_rate_table_puts_its_warning_in_the_envelope() {
5068 let rates = pinned_rates();
5069 let retrieved = rates.retrieved();
5070 // A day past the ninety-day window, computed from the table's own date so this test
5071 // cannot go stale.
5072 let stale_day =
5073 CalendarDate::try_from_ymd(retrieved.year() + 1, retrieved.month(), retrieved.day())
5074 .expect("a real day one year on");
5075 let report = run_report(
5076 SizeClass::Mib2048,
5077 &RunUsage {
5078 running: Some(DurationP::Measured(Duration::from_secs(60))),
5079 ..RunUsage::launched()
5080 },
5081 &rates,
5082 stale_day,
5083 "run",
5084 )
5085 .expect("a report");
5086 let json = report.to_json();
5087 assert!(json["staleness"].is_string(), "{json}");
5088 assert!(
5089 json["staleness"].as_str().expect("text").contains("days"),
5090 "{json}"
5091 );
5092 }
5093
5094 // ── the budget gate (#270) ────────────────────────────────────────────
5095
5096 /// A report with only priced lines: an hour running at the default size.
5097 fn an_exact_report() -> CostReport {
5098 running_report(
5099 SizeClass::Mib2048,
5100 DurationP::Measured(Duration::from_secs(3600)),
5101 )
5102 }
5103
5104 /// Each judgement over each side of the ceiling, on an exact total and on a lower bound:
5105 /// a breach aborts only under `Abort`, at the ceiling is within, and a lower bound says so
5106 /// whichever side it lands on (#270).
5107 ///
5108 /// **Falsification**: `verify/guards/faults/cost-defaults.toml` entries
5109 /// `domain-budget-abort-ignores-the-judgement` (a breach aborts under `Warn` too) and
5110 /// `domain-budget-at-the-ceiling-breaches` (`>=` for `>`) each turn a row red.
5111 #[test]
5112 fn a_budget_verdict_follows_the_judgement_and_the_total_it_was_given() {
5113 let exact = an_exact_report();
5114 let floor = exact.total().floor();
5115 let lower = a_create_and_destroy_report();
5116 assert!(lower.total().is_lower_bound());
5117 let lower_floor = lower.total().floor();
5118 let over = |figure: EstimatedUsd| EstimatedUsd::new(figure.amount() - dec!(0.001));
5119 let rows = [
5120 (&exact, floor, OnBreach::Abort, false, false, "exact"),
5121 (&exact, over(floor), OnBreach::Warn, true, false, "exact"),
5122 (&exact, over(floor), OnBreach::Abort, true, true, "exact"),
5123 (
5124 &lower,
5125 over(lower_floor),
5126 OnBreach::Warn,
5127 true,
5128 false,
5129 "lower-bound",
5130 ),
5131 (
5132 &lower,
5133 over(lower_floor),
5134 OnBreach::Abort,
5135 true,
5136 true,
5137 "lower-bound",
5138 ),
5139 (
5140 &lower,
5141 lower_floor,
5142 OnBreach::Abort,
5143 false,
5144 false,
5145 "lower-bound",
5146 ),
5147 ];
5148 for (report, max, on_breach, breached, aborts, basis) in rows {
5149 let verdict = Budget::new(max, on_breach).check(report);
5150 let row = format!("{} under {} ({basis})", max.amount(), on_breach.as_str());
5151 assert_eq!(verdict.breached(), breached, "breached: {row}");
5152 assert_eq!(verdict.aborts(), aborts, "aborts: {row}");
5153 assert_eq!(verdict.basis(), basis, "basis: {row}");
5154 assert_eq!(verdict.overage().is_some(), breached, "overage: {row}");
5155 assert_eq!(verdict.to_json()["breached"], breached, "{row}");
5156 assert_eq!(verdict.to_json()["onBreach"], on_breach.as_str(), "{row}");
5157 }
5158 let breach = Budget::new(over(lower_floor), OnBreach::Warn).check(&lower);
5159 assert_eq!(breach.overage(), Some(EstimatedUsd::new(dec!(0.001))));
5160 assert!(
5161 breach.render().contains("by at least"),
5162 "{}",
5163 breach.render()
5164 );
5165 assert!(
5166 breach.render().contains("unknown margin"),
5167 "{}",
5168 breach.render()
5169 );
5170 let within = Budget::new(lower_floor, OnBreach::Warn).check(&lower);
5171 assert!(
5172 within.render().contains("that floor is a lower bound"),
5173 "{}",
5174 within.render()
5175 );
5176 assert_eq!(within.unpriced_phases(), ["image-build"]);
5177 // An exact total under the ceiling says it's the estimate and claims no lower bound.
5178 let exact_within = Budget::new(floor, OnBreach::Warn).check(&exact);
5179 assert!(exact_within.unpriced_phases().is_empty());
5180 let text = exact_within.render();
5181 assert!(text.starts_with("budget: the estimated total $"), "{text}");
5182 assert!(!text.contains("lower bound"), "{text}");
5183 }
5184
5185 /// An empty report is an exact zero, not a lower bound: nothing on it is unpriced, so a
5186 /// zero ceiling holds it, and the dense line says `exact`.
5187 #[test]
5188 fn an_empty_report_is_judged_as_an_exact_zero() {
5189 let empty = run_report(
5190 SizeClass::Mib2048,
5191 &RunUsage::default(),
5192 &pinned_rates(),
5193 fresh_day(),
5194 DEFAULT_RUN_LABEL,
5195 )
5196 .expect("an empty report");
5197 let verdict = Budget::new(EstimatedUsd::ZERO, OnBreach::Abort).check(&empty);
5198 assert!(!verdict.is_lower_bound());
5199 assert!(!verdict.breached());
5200 assert_eq!(verdict.render_dense(), "budget\t0\texact\twithin\t-");
5201 }
5202
5203 /// A judgement parses from its spelling and nothing else, so a surface taking a string
5204 /// can't default one in by accident.
5205 #[test]
5206 fn a_judgement_parses_from_its_spelling_only() {
5207 for judgement in OnBreach::ALL {
5208 assert_eq!(judgement.as_str().parse::<OnBreach>().ok(), Some(judgement));
5209 }
5210 for bad in ["", "Warn", "abort ", "fail"] {
5211 let error = bad.parse::<OnBreach>().expect_err("not a judgement");
5212 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
5213 }
5214 }
5215}