Skip to main content

microvms_core/
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, SystemTime, UNIX_EPOCH};
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, refused when the float cannot be a duration.
155///
156/// The complement of [`gb_decimal`] for the API boundary where seconds arrive as a
157/// number — a CLI flag, a JSON field. `Duration::try_from_secs_f64` is what rejects
158/// a negative or non-finite figure, which is why [`DurationP`] itself needs no
159/// validation: an inverted clock is refused here or it never becomes a duration.
160pub fn duration_of_secs_f64(seconds: f64) -> Result<Duration, Error> {
161    Duration::try_from_secs_f64(seconds).map_err(|source| {
162        Error::invalid_arg(format!(
163            "{seconds}s is not a duration: it must be finite and non-negative, and a negative \
164             one would silently produce a credit on the report"
165        ))
166        .with_source(source)
167    })
168}
169
170// ── display precision ────────────────────────────────────────────────────────
171
172/// Enough places to see a sub-cent figure, few enough to read a monthly one.
173///
174/// A create-and-destroy test suite's compute cost lives in the sixth decimal place
175/// while a warm pool's monthly bill lives in the second, and one fixed precision
176/// cannot show both without either lying or shouting. Half-even at the boundary,
177/// matching Python's `Decimal.quantize` default, so the two clients render the same
178/// figure the same way.
179fn render_amount(amount: Decimal) -> String {
180    let places = if amount.abs() >= Decimal::ONE { 2 } else { 6 };
181    let mut rendered = amount.round_dp(places);
182    // Fixed places rather than significant digits: a trailing zero is what says the
183    // figure was rounded to the cent rather than happening to land there.
184    rendered.rescale(places);
185    rendered.to_string()
186}
187
188/// A consumed quantity, rounded only for display.
189///
190/// Division by 730 hours yields 28 significant digits, and a line item nobody can
191/// scan is a line item nobody checks the rate table against — which is the one job
192/// the quantity column has.
193fn render_quantity(quantity: Decimal) -> String {
194    quantity.round_dp(6).normalize().to_string()
195}
196
197// ── dates, without a date crate ──────────────────────────────────────────────
198
199/// A calendar day, which is the only temporal resolution a rate table has.
200///
201/// Deliberately not a dependency. `cargo tree` shows `time 0.3` in this workspace,
202/// but only as a transitive dependency of `aws-smithy-runtime` — taking it here
203/// would mean adding a direct dependency, and the module needs exactly two
204/// operations: pin a retrieval date, and subtract two dates to get an age in days.
205/// No parsing, no formatting, no zones, no arithmetic on months. So the whole date
206/// surface is the proleptic-Gregorian day number below, which is twelve lines and
207/// has a pinned test against three known values.
208#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
209pub struct CalendarDate {
210    year: i32,
211    month: u32,
212    day: u32,
213}
214
215impl CalendarDate {
216    /// A date from its parts, for the literals in this file.
217    ///
218    /// `const` so [`pinned_rates`] can pin its retrieval date, which is also why it does
219    /// not validate: a `const fn` cannot return a `Result` a caller must handle at
220    /// compile time. The literals it is used on are pinned by
221    /// `the_pinned_dates_are_real_calendar_days`; anything arriving at runtime goes
222    /// through [`CalendarDate::try_from_ymd`].
223    pub const fn from_ymd(year: i32, month: u32, day: u32) -> CalendarDate {
224        CalendarDate { year, month, day }
225    }
226
227    /// A date from parts that came from outside this crate.
228    ///
229    /// The S2 boundary: a `--today` flag or a JSON field is three integers, and
230    /// `2026-02-30` would otherwise produce a day number for March 2nd and an age
231    /// two days out.
232    pub fn try_from_ymd(year: i32, month: u32, day: u32) -> Result<CalendarDate, Error> {
233        let candidate = CalendarDate { year, month, day };
234        if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
235            return Err(Error::invalid_arg(format!(
236                "{year:04}-{month:02}-{day:02} is not a calendar day, so it cannot date a rate \
237                 table: month must be 1-12 and day must be within that month"
238            )));
239        }
240        Ok(candidate)
241    }
242
243    /// Today, UTC.
244    ///
245    /// UTC rather than local, and no dependency: a rate table's age is measured in
246    /// ninety-day units, so the zone can only ever move the answer by a day and
247    /// carrying a timezone database to decide it would be absurd. Falls back to the
248    /// epoch if the clock is set before 1970, which is a machine whose age
249    /// arithmetic is already meaningless and is not worth a `Result` on every
250    /// report.
251    pub fn today_utc() -> CalendarDate {
252        let seconds = SystemTime::now()
253            .duration_since(UNIX_EPOCH)
254            .map_or(0, |since| since.as_secs());
255        // Integer division floors, and the epoch is midnight, so this is the UTC
256        // civil day without any rounding question.
257        Self::from_day_number((seconds / 86_400) as i64)
258    }
259
260    pub fn year(self) -> i32 {
261        self.year
262    }
263
264    pub fn month(self) -> u32 {
265        self.month
266    }
267
268    pub fn day(self) -> u32 {
269        self.day
270    }
271
272    /// Days since 1970-01-01, negative before it.
273    ///
274    /// Howard Hinnant's `days_from_civil`, which is the algorithm every date library
275    /// uses: it shifts the year to start in March so the leap day lands at the end
276    /// of a year and the month-length pattern becomes the linear `(153m + 2) / 5`.
277    /// Correct for every proleptic-Gregorian date, and pinned below against
278    /// 1970-01-01, a leap day, and this table's own retrieval date.
279    pub fn day_number(self) -> i64 {
280        let month = i64::from(self.month);
281        let day = i64::from(self.day);
282        // March-based year, so February's variable length is the last month.
283        let year = i64::from(self.year) - i64::from(month <= 2);
284        let era = if year >= 0 { year } else { year - 399 } / 400;
285        let year_of_era = year - era * 400;
286        let shifted = if month > 2 { month - 3 } else { month + 9 };
287        let day_of_year = (153 * shifted + 2) / 5 + day - 1;
288        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
289        // 719468 is the day number of 1970-01-01 in the era-based count.
290        era * 146_097 + day_of_era - 719_468
291    }
292
293    /// The inverse of [`CalendarDate::day_number`], for [`CalendarDate::today_utc`].
294    fn from_day_number(day_number: i64) -> CalendarDate {
295        let shifted = day_number + 719_468;
296        let era = if shifted >= 0 {
297            shifted
298        } else {
299            shifted - 146_096
300        } / 146_097;
301        let day_of_era = shifted - era * 146_097;
302        let year_of_era =
303            (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
304        let year = year_of_era + era * 400;
305        let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
306        let month_shifted = (5 * day_of_year + 2) / 153;
307        let day = day_of_year - (153 * month_shifted + 2) / 5 + 1;
308        let month = if month_shifted < 10 {
309            month_shifted + 3
310        } else {
311            month_shifted - 9
312        };
313        CalendarDate {
314            year: (year + i64::from(month <= 2)) as i32,
315            month: month as u32,
316            day: day as u32,
317        }
318    }
319
320    /// Days from `earlier` to `self`, negative when `self` is the earlier one.
321    pub fn days_since(self, earlier: CalendarDate) -> i64 {
322        self.day_number() - earlier.day_number()
323    }
324}
325
326/// ISO 8601, because that is what the Python client's `retrieved.isoformat()` puts
327/// in the report header and the two have to be diffable.
328impl fmt::Display for CalendarDate {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
331    }
332}
333
334/// Days in a month, for [`CalendarDate::try_from_ymd`]'s validation only.
335fn days_in_month(year: i32, month: u32) -> u32 {
336    match month {
337        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
338        4 | 6 | 9 | 11 => 30,
339        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
340        2 => 28,
341        _ => 0,
342    }
343}
344
345// ── provenance (COST-1) ──────────────────────────────────────────────────────
346
347/// Where a duration came from. There is no third option and no default.
348///
349/// `Measured` means a clock ran. `Projected` means a caller supplied a
350/// hypothetical, which is also what an estimate's durations are and what the
351/// documented one-week storage minimum is — nobody timed that week either.
352#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
353pub enum Provenance {
354    Measured,
355    Projected,
356}
357
358impl Provenance {
359    /// The wire spelling, identical to the Python `StrEnum` member.
360    pub fn as_str(self) -> &'static str {
361        match self {
362            Provenance::Measured => "measured",
363            Provenance::Projected => "projected",
364        }
365    }
366}
367
368impl fmt::Display for Provenance {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        f.write_str(self.as_str())
371    }
372}
373
374/// Seconds, plus how we know them (COST-1).
375///
376/// The labelling rule is the enum: every way of building one of these names a
377/// provenance, because there is no other variant to reach and no
378/// `From<Duration>`/`Default` to fall into. `cost.py` achieved the same thing with
379/// a field that had no default, so `Duration(3600)` raised `TypeError` — the whole
380/// rule collapses the moment an unlabelled duration can be written, because the
381/// label that goes missing is always the weaker one, and here it cannot be written.
382///
383/// Wrapping [`std::time::Duration`] also retires the Python's negativity check: a
384/// negative duration is not a value this type can hold, so an inverted clock is
385/// caught at [`duration_of_secs_f64`] or never enters at all.
386///
387/// # Provenance cannot be omitted (COST-1)
388///
389/// "No unlabelled constructor" is a claim about what does not exist, so it is checked
390/// by programs that do not build:
391///
392/// Each carries its expected error code, because a bare `compile_fail` passes for any
393/// build failure at all — a typo in the test would leave the guard green.
394///
395/// ```compile_fail,E0277
396/// # use microvms_core::cost::DurationP;
397/// # use std::time::Duration;
398/// // There is no `From<Duration> for DurationP`, so a bare span cannot become a
399/// // labelled one by coercion.
400/// let unlabelled: DurationP = Duration::from_secs(3600).into();
401/// ```
402///
403/// ```compile_fail,E0599
404/// # use microvms_core::cost::DurationP;
405/// // No `Default` either. A default would have to pick a provenance, and the one it
406/// // would pick is the stronger claim.
407/// let unlabelled = DurationP::default();
408/// ```
409///
410/// The positive case, for contrast — naming the provenance is the only way in, and it
411/// is one word:
412///
413/// ```
414/// # use microvms_core::cost::{DurationP, Provenance};
415/// # use std::time::Duration;
416/// let timed = DurationP::Measured(Duration::from_secs(3600));
417/// assert_eq!(timed.provenance(), Provenance::Measured);
418/// ```
419#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
420pub enum DurationP {
421    /// A clock ran and this is what it read.
422    Measured(Duration),
423    /// A hypothetical: an estimate's input, or a documented minimum nobody timed.
424    Projected(Duration),
425}
426
427impl DurationP {
428    /// A timed phase, from seconds a caller measured as a float.
429    ///
430    /// Fallible because the float is: see [`duration_of_secs_f64`].
431    pub fn measured_secs_f64(seconds: f64) -> Result<DurationP, Error> {
432        Ok(DurationP::Measured(duration_of_secs_f64(seconds)?))
433    }
434
435    /// A hypothetical phase, from seconds a caller supplied as a float.
436    pub fn projected_secs_f64(seconds: f64) -> Result<DurationP, Error> {
437        Ok(DurationP::Projected(duration_of_secs_f64(seconds)?))
438    }
439
440    /// The span, without its label. Named so a call site that drops the provenance
441    /// is visible as one.
442    pub fn duration(self) -> Duration {
443        match self {
444            DurationP::Measured(duration) | DurationP::Projected(duration) => duration,
445        }
446    }
447
448    /// How this duration is known.
449    pub fn provenance(self) -> Provenance {
450        match self {
451            DurationP::Measured(_) => Provenance::Measured,
452            DurationP::Projected(_) => Provenance::Projected,
453        }
454    }
455
456    /// The span as exact decimal seconds, for the money path.
457    pub fn seconds(self) -> Decimal {
458        seconds_of(self.duration())
459    }
460
461    /// The span as an f64, for a JSON envelope.
462    ///
463    /// **Lossy, and named so**, exactly like
464    /// [`ResidencyComparison::break_even_seconds_f64`]. The exact answer is
465    /// [`DurationP::seconds`]; this exists because `cli.py:743` emits a line item's
466    /// `duration.seconds` as a JSON *number* and the two clients' envelopes have to be
467    /// substitutable. Seconds, not dollars — no money figure has an f64 accessor
468    /// (COST-2), and that asymmetry is the whole rule: a consumer doing arithmetic on
469    /// a duration should not have to branch on which client produced the envelope,
470    /// while a consumer doing arithmetic on a dollar figure should be reading the
471    /// string and told so.
472    ///
473    /// (cli.py line numbers resolve at `git show 'c4d396e^:clients/python/src/microvms_agentd/cli.py'` — the retired oracle.)
474    pub fn seconds_f64(self) -> f64 {
475        self.duration().as_secs_f64()
476    }
477
478    /// True only for a timed span. Read by [`CostReport::fully_measured`].
479    pub fn is_measured(self) -> bool {
480        matches!(self, DurationP::Measured(_))
481    }
482}
483
484/// The Python's `f"{seconds:g}s ({provenance})"`, so a report line reads the same in
485/// either client.
486impl fmt::Display for DurationP {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        write!(f, "{}s ({})", self.seconds().normalize(), self.provenance())
489    }
490}
491
492// ── dollars, which are always estimates (COST-2) ─────────────────────────────
493
494/// Dollars derived from published rates. Not the bill.
495///
496/// There is deliberately no `ActualUsd` alongside this: a client that never sees an
497/// invoice cannot produce an actual. And there is deliberately **no way to get an
498/// f64 out** (COST-2) — no `From<EstimatedUsd> for f64`, no `Into`, no `Deref`, no
499/// `as_f64`. The Python original could only omit `__float__` and hope; the cheapest
500/// way to launder an estimate into a bill is `f"${float(x):.2f}"`, and the Rust
501/// equivalent does not compile.
502///
503/// [`EstimatedUsd::amount`] returns a [`Decimal`], which is one visible step and
504/// keeps the type name at the call site. A caller who genuinely needs a float
505/// reaches for `Decimal::to_f64`, which is two named steps and a `Option` — and
506/// which is a decision a reviewer can see.
507///
508/// [`Add`](std::ops::Add) is implemented only against itself, so summing an amount
509/// that might be [`Amount::Unpriced`] is a type error rather than a silent zero.
510///
511/// # The coercion does not compile (COST-2)
512///
513/// The requirement is about an impl that is *absent*, so the check has to be a
514/// program that fails to build. `compile_fail` doctests are that check, and they are
515/// run by `cargo test` like any other:
516///
517/// The error code is pinned on each one, because a bare `compile_fail` passes for
518/// *any* build failure — including a typo in the test itself, which would leave the
519/// guard green while measuring nothing.
520///
521/// ```compile_fail,E0277
522/// # use microvms_core::cost::EstimatedUsd;
523/// # use rust_decimal_macros::dec;
524/// let estimate = EstimatedUsd::new(dec!(1.23));
525/// // No `From<EstimatedUsd> for f64`, so no `Into` either. This is the line the
526/// // Python client could only ask a reader not to write.
527/// let laundered: f64 = estimate.into();
528/// ```
529///
530/// ```compile_fail,E0614
531/// # use microvms_core::cost::EstimatedUsd;
532/// # use rust_decimal_macros::dec;
533/// let estimate = EstimatedUsd::new(dec!(1.23));
534/// // No `Deref` and no public field: the figure comes out through `amount()`, which
535/// // names the type at the call site.
536/// let bare = *estimate;
537/// ```
538///
539/// ```compile_fail,E0308
540/// # use microvms_core::cost::{Amount, EstimatedUsd};
541/// # use rust_decimal_macros::dec;
542/// let estimate = EstimatedUsd::new(dec!(1.23));
543/// let unknown = Amount::unpriced("no rate is published");
544/// // `Add` is implemented only against `EstimatedUsd`, so the Python's runtime
545/// // `TypeError` from `NotImplemented` is a compile error here (COST-4).
546/// let wrong = estimate + unknown;
547/// ```
548#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
549pub struct EstimatedUsd(Decimal);
550
551impl EstimatedUsd {
552    /// Zero dollars, which is a legitimate *priced* figure — a zero-length hold, a
553    /// zero-cycle comparison — and is not the same claim as [`Amount::Unpriced`].
554    pub const ZERO: EstimatedUsd = EstimatedUsd(Decimal::ZERO);
555
556    /// An estimate from a decimal figure already in the money path.
557    ///
558    /// Takes a [`Decimal`] and not an f64, so this constructor cannot be the second
559    /// float boundary: [`gb_decimal`] and [`seconds_of`] are the only two, which is
560    /// what makes COST-6's "exactly once" checkable by reading them.
561    pub fn new(amount: Decimal) -> EstimatedUsd {
562        EstimatedUsd(amount)
563    }
564
565    /// The figure, in decimal.
566    ///
567    /// The one visible step out. Deliberately not `Into<Decimal>`: an explicit call
568    /// keeps `EstimatedUsd` in the reader's view at the point the label is dropped.
569    pub fn amount(self) -> Decimal {
570        self.0
571    }
572
573    /// The figure rendered at display precision, without the estimate label.
574    ///
575    /// For a caller assembling its own sentence. [`fmt::Display`] is what carries
576    /// the label, and it is what a report renders through.
577    pub fn amount_string(self) -> String {
578        render_amount(self.0)
579    }
580}
581
582impl std::ops::Add for EstimatedUsd {
583    type Output = EstimatedUsd;
584
585    /// Only an estimate adds to an estimate.
586    ///
587    /// There is no `Add<Amount>` and no `Add<Unpriced>`, so the Python's
588    /// `NotImplemented` — a runtime `TypeError` — becomes a compile error. This is
589    /// the last line of defence for COST-4: even a consumer that ignores
590    /// [`CostReport::is_complete`] cannot sum an unknown into a dollar figure.
591    fn add(self, other: EstimatedUsd) -> EstimatedUsd {
592        EstimatedUsd(self.0 + other.0)
593    }
594}
595
596impl std::iter::Sum for EstimatedUsd {
597    fn sum<I: Iterator<Item = EstimatedUsd>>(iter: I) -> EstimatedUsd {
598        iter.fold(EstimatedUsd::ZERO, std::ops::Add::add)
599    }
600}
601
602/// A figure copied out of a terminal loses its docstring, so the label travels in
603/// `Display` rather than sitting in the type name alone.
604impl fmt::Display for EstimatedUsd {
605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606        write!(f, "~${} (estimated)", render_amount(self.0))
607    }
608}
609
610/// What a line item's cost can be (COST-3).
611///
612/// Any consumer that matches on this has to handle the unknown case, which is the
613/// property the whole module rests on. [`Amount::Unpriced`] is distinct from
614/// `Amount::Estimated(EstimatedUsd::ZERO)` on purpose: zero is a claim about the
615/// bill, unpriced is a claim about the documentation.
616#[derive(Clone, Debug, Eq, PartialEq)]
617pub enum Amount {
618    Estimated(EstimatedUsd),
619    /// A quantity we can measure but cannot price, because no rate is published.
620    ///
621    /// The reason is a field and not optional, so the line item can say *why* rather
622    /// than just that it cannot. [`BUILD_UNPRICED_REASON`] is the one this module
623    /// raises.
624    Unpriced {
625        reason: String,
626    },
627}
628
629impl Amount {
630    /// A priced amount from a decimal figure.
631    pub fn estimated(amount: Decimal) -> Amount {
632        Amount::Estimated(EstimatedUsd::new(amount))
633    }
634
635    /// An unpriced amount, with the reason that goes on the line item.
636    pub fn unpriced(reason: impl Into<String>) -> Amount {
637        Amount::Unpriced {
638            reason: reason.into(),
639        }
640    }
641
642    /// The estimate, when there is one.
643    ///
644    /// Returns `Option` rather than defaulting to zero, which is the same decision
645    /// as `cli.py`'s JSON omitting the `usd` key entirely for an unpriced line: a
646    /// null gets summed as zero by anything permissive, and that is the one
647    /// arithmetic this module refuses to enable.
648    pub fn estimate(&self) -> Option<EstimatedUsd> {
649        match self {
650            Amount::Estimated(usd) => Some(*usd),
651            Amount::Unpriced { .. } => None,
652        }
653    }
654
655    /// The reason this could not be priced, when it could not.
656    pub fn unpriced_reason(&self) -> Option<&str> {
657        match self {
658            Amount::Estimated(_) => None,
659            Amount::Unpriced { reason } => Some(reason),
660        }
661    }
662
663    /// The stable tag `cli.py` puts in the JSON envelope's `amount.kind`.
664    pub fn kind(&self) -> &'static str {
665        match self {
666            Amount::Estimated(_) => "estimated-usd",
667            Amount::Unpriced { .. } => "unpriced",
668        }
669    }
670}
671
672impl fmt::Display for Amount {
673    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
674        match self {
675            Amount::Estimated(usd) => write!(f, "{usd}"),
676            Amount::Unpriced { reason } => write!(f, "unpriced — {reason}"),
677        }
678    }
679}
680
681/// One line item a total could not price: which phase it belonged to, and why.
682///
683/// The phase travels beside the reason because the two answer different questions and
684/// a reader needs both. `cost.py:339-343` renders the *phase names* in the total's
685/// parenthetical — `plus 1 unpriced (image-build)` — because a reason is a paragraph
686/// and a total is one line, while [`Total::unpriced_reasons`] still hands over the
687/// paragraphs for the consumer that wants them. Carrying only the reason, which is
688/// what this type replaced, made the phase unavailable at render time and the Rust
689/// total printed the paragraph.
690#[derive(Clone, Debug, Eq, PartialEq)]
691pub struct UnpricedLine {
692    /// The phase the line belonged to. What the total's parenthetical names.
693    pub phase: CostPhase,
694    /// Why no figure could be attached to it.
695    pub reason: String,
696}
697
698/// A report's total, which is a lower bound whenever anything is unpriced (COST-4).
699///
700/// Two variants rather than a struct with a flag, and that is the upgrade over the
701/// Python's `Total(priced, unpriced=())`: there a consumer could read `.priced` and
702/// never look at `.is_lower_bound`, so the honesty rule depended on the caller
703/// checking. Here the floor is *inside* [`Total::AtLeast`], beside the reasons, so
704/// reaching it means seeing them. A plain sum cannot express "everything we could
705/// price, plus a build AWS will not tell us the price of", and the natural way to
706/// force it to — dropping the unpriced line — is exactly the lie this module exists
707/// not to tell.
708#[derive(Clone, Debug, Eq, PartialEq)]
709pub enum Total {
710    /// Every line item was priced, so this is the whole estimate.
711    Exact(EstimatedUsd),
712    /// At least this much, plus line items with no published rate.
713    AtLeast {
714        /// The sum of the priced lines. Not the total — the total is unknowable.
715        floor: EstimatedUsd,
716        /// The lines that could not be priced, in report order.
717        ///
718        /// Non-empty by construction: [`Total::of`] answers [`Total::Exact`] when
719        /// there is nothing to name, so an `AtLeast` with an empty list — a lower
720        /// bound that will not say what it is missing — is not a value this type
721        /// holds.
722        unpriced: Vec<UnpricedLine>,
723    },
724}
725
726impl Total {
727    /// The total over a report's amounts, each labelled with the phase it came from.
728    ///
729    /// The one place a total is computed, so COST-4 holds for every consumer rather
730    /// than for the ones that remembered. An unpriced amount routes the whole answer
731    /// to [`Total::AtLeast`]; it is never skipped and never summed as zero.
732    ///
733    /// Takes `(phase, amount)` rather than a bare amount so the phase cannot go
734    /// missing on the way in: the rendered total names phases, and a `Total` built
735    /// without them could only fall back to printing reasons.
736    pub fn of<'a>(amounts: impl IntoIterator<Item = (CostPhase, &'a Amount)>) -> Total {
737        let mut floor = EstimatedUsd::ZERO;
738        let mut unpriced: Vec<UnpricedLine> = Vec::new();
739        for (phase, amount) in amounts {
740            match amount {
741                Amount::Estimated(usd) => floor = floor + *usd,
742                Amount::Unpriced { reason } => unpriced.push(UnpricedLine {
743                    phase,
744                    reason: reason.clone(),
745                }),
746            }
747        }
748        if unpriced.is_empty() {
749            Total::Exact(floor)
750        } else {
751            Total::AtLeast { floor, unpriced }
752        }
753    }
754
755    /// Everything that could be priced.
756    ///
757    /// Named `floor` rather than `total` on purpose: for [`Total::AtLeast`] it is a
758    /// lower bound, and a consumer reading a field called `total` would have no
759    /// reason to check which variant it came from.
760    pub fn floor(&self) -> EstimatedUsd {
761        match self {
762            Total::Exact(usd) => *usd,
763            Total::AtLeast { floor, .. } => *floor,
764        }
765    }
766
767    /// True when line items with no published rate are missing from the floor.
768    pub fn is_lower_bound(&self) -> bool {
769        matches!(self, Total::AtLeast { .. })
770    }
771
772    /// The lines that could not be priced. Empty for [`Total::Exact`].
773    pub fn unpriced_lines(&self) -> &[UnpricedLine] {
774        match self {
775            Total::Exact(_) => &[],
776            Total::AtLeast { unpriced, .. } => unpriced,
777        }
778    }
779
780    /// The reasons the unpriced lines could not be priced, in report order. Empty for
781    /// [`Total::Exact`].
782    ///
783    /// Kept beside [`Total::unpriced_lines`] because the reason is what a consumer
784    /// surfacing the *why* wants, while the rendered total names phases.
785    pub fn unpriced_reasons(&self) -> Vec<String> {
786        self.unpriced_lines()
787            .iter()
788            .map(|line| line.reason.clone())
789            .collect()
790    }
791
792    /// The distinct phase names of the unpriced lines, sorted — what the rendered
793    /// total names.
794    ///
795    /// Sorted and deduplicated to match `cost.py:342`'s
796    /// `", ".join(sorted({item.phase.value for item in self.unpriced}))`: two unpriced
797    /// lines in one phase name it once, and the order does not depend on report order.
798    pub fn unpriced_phase_names(&self) -> Vec<&'static str> {
799        let mut names: Vec<&'static str> = self
800            .unpriced_lines()
801            .iter()
802            .map(|line| line.phase.as_str())
803            .collect();
804        names.sort_unstable();
805        names.dedup();
806        names
807    }
808}
809
810/// "at least" is the whole point, so it leads.
811///
812/// The parenthetical names **phases**, not reasons, which is `cost.py:343` verbatim. A
813/// reason is a sentence explaining what AWS does not publish, and joining several of
814/// them turns a one-line total into a paragraph the two clients cannot both print.
815impl fmt::Display for Total {
816    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
817        match self {
818            Total::Exact(usd) => write!(f, "{usd}"),
819            Total::AtLeast { floor, unpriced } => write!(
820                f,
821                "at least {floor}, plus {} unpriced ({})",
822                unpriced.len(),
823                self.unpriced_phase_names().join(", ")
824            ),
825        }
826    }
827}
828
829// ── the rate table ───────────────────────────────────────────────────────────
830
831/// us-east-1 rates, pinned to when they were read and where from.
832///
833/// The three booleans and the absent increment are documented *facts* rather than
834/// settings, and they are fields because each one is a mistake someone would
835/// otherwise make in arithmetic: blending vCPU into a GB-second, adding a per-invoke
836/// charge by analogy with Lambda Functions, rounding a 200 ms exec up to a 1-second
837/// increment, or subtracting a free tier that does not exist.
838///
839/// # Why the rate fields are private (COST-9)
840///
841/// The Python's `RateTable` was a plain frozen dataclass, so anyone could construct
842/// one — which is how `pricing.py`'s ARM refusal ended up guarding only the fetch
843/// path. Here the five rates are private and there are exactly two ways to obtain a
844/// table: the pinned [`pinned_rates`], and [`RateTable::from_catalog`], which refuses a
845/// catalog missing its ARM compute line rather than substituting the x86 one. So
846/// "compute is priced from the ARM rate" is a property of the type rather than of a
847/// code path that can be bypassed.
848#[derive(Clone, Debug, Eq, PartialEq)]
849pub struct RateTable {
850    region: Region,
851    source_url: String,
852    retrieved: CalendarDate,
853    vcpu_second: Decimal,
854    gb_second: Decimal,
855    storage_gb_month: Decimal,
856    snapshot_read_gb: Decimal,
857    snapshot_write_gb: Decimal,
858    minimum_retention: Duration,
859}
860
861impl RateTable {
862    /// The region these rates price.
863    ///
864    /// Not a cosmetic label: eu-west-1 is 5.3% over us-east-1 on compute and 19% on
865    /// snapshot storage, and ap-northeast-1 is 16.4% and 20%. A caller in Tokyo
866    /// reading the us-east-1 table understates their snapshot write bill by 22.6%,
867    /// which is not a rounding difference and would never show up as staleness.
868    pub fn region(&self) -> &Region {
869        &self.region
870    }
871
872    /// Where these figures came from, as an instruction a reader can act on.
873    pub fn source_url(&self) -> &str {
874        &self.source_url
875    }
876
877    /// When they were read.
878    pub fn retrieved(&self) -> CalendarDate {
879        self.retrieved
880    }
881
882    /// Per vCPU-second, billed separately from memory.
883    pub fn vcpu_second(&self) -> Decimal {
884        self.vcpu_second
885    }
886
887    /// Per GB-second of *baseline* memory, billed separately from vCPU.
888    pub fn gb_second(&self) -> Decimal {
889        self.gb_second
890    }
891
892    /// Per GB-month of snapshot storage.
893    ///
894    /// The one derived figure: the Pricing API quotes this per GB-hour, and this
895    /// table holds per GB-month. See [`HOURS_PER_MONTH`].
896    pub fn storage_gb_month(&self) -> Decimal {
897        self.storage_gb_month
898    }
899
900    /// Per GB read on a launch or a resume.
901    pub fn snapshot_read_gb(&self) -> Decimal {
902        self.snapshot_read_gb
903    }
904
905    /// Per GB written on a suspend. Roughly 2.5x the read rate, so a cycle costed
906    /// with one rate twice is wrong in whichever direction it picked.
907    pub fn snapshot_write_gb(&self) -> Decimal {
908        self.snapshot_write_gb
909    }
910
911    /// The documented storage minimum (COST-8).
912    ///
913    /// On the rate row itself, so it applies to anything stored there.
914    pub fn minimum_retention(&self) -> Duration {
915        self.minimum_retention
916    }
917
918    /// The same minimum in whole days, which is the unit the note on a floored line
919    /// item quotes.
920    ///
921    /// Here rather than as an `as_secs() / 86_400` at the one call site, because that
922    /// spelling is a second place the seconds-to-days convention lives: the field is
923    /// the only thing that knows how long the window is, and a division written beside
924    /// the message would keep saying "7-day" after a rate row moved to a fortnight.
925    /// Truncating rather than rounding is deliberate — a floor quoted as longer than it
926    /// is would overstate the charge a reader is being told about.
927    pub fn minimum_retention_days(&self) -> u64 {
928        self.minimum_retention.as_secs() / SECONDS_PER_DAY
929    }
930
931    /// MicroVMs bills per second with no per-request charge.
932    ///
933    /// A method returning a constant rather than a field, because it is a documented
934    /// fact and not a table entry someone could edit into a different claim. The
935    /// Lambda free tier is Functions-only and no MicroVMs free tier is published.
936    pub fn per_request(&self) -> Decimal {
937        Decimal::ZERO
938    }
939
940    /// vCPU and memory are two line items, as the pricing page prices them.
941    ///
942    /// A blended GB-second figure cannot be reconciled against a Cost Explorer
943    /// breakdown that keeps them apart.
944    pub fn bills_vcpu_and_memory_separately(&self) -> bool {
945        true
946    }
947
948    /// No published free tier.
949    pub fn free_tier(&self) -> bool {
950        false
951    }
952
953    /// The minimum billing increment, which is **not published**.
954    ///
955    /// `None` means not published, not "one second". Nothing here rounds a duration
956    /// up, because inventing an increment would overcharge every short exec in a
957    /// report and there is no source for one.
958    pub fn minimum_billing_increment(&self) -> Option<Duration> {
959        None
960    }
961
962    /// How many days ago these rates were read.
963    pub fn age_days(&self, today: CalendarDate) -> i64 {
964        today.days_since(self.retrieved)
965    }
966
967    /// Whether the table is past [`STALE_AFTER_DAYS`] (COST-7).
968    pub fn is_stale(&self, today: CalendarDate) -> bool {
969        self.age_days(today) > STALE_AFTER_DAYS
970    }
971
972    /// The warning text, or `None` when the table is fresh.
973    ///
974    /// Text rather than a bool because the warning has to survive into whatever
975    /// renders it, carrying the retrieval date and the URL — a figure copied out of a
976    /// terminal loses everything else.
977    pub fn staleness(&self, today: CalendarDate) -> Option<String> {
978        if !self.is_stale(today) {
979            return None;
980        }
981        Some(format!(
982            "rate table for {} was retrieved {}, {} days ago (stale after {STALE_AFTER_DAYS}) — \
983             re-read {} before trusting these figures",
984            self.region,
985            self.retrieved,
986            self.age_days(today),
987            self.source_url,
988        ))
989    }
990}
991
992/// Read 2026-08-07, us-east-1, and recorded in `docs/PLATFORM.md` under "What
993/// actually costs money".
994///
995/// Four rates appear on the Lambda pricing page as written; `storage_gb_month` is
996/// *derived*, because the AWS Pricing API quotes snapshot storage per GB-hour and
997/// this table holds per GB-month. Change any of them and change that document in the
998/// same commit.
999///
1000/// A drift check against the Pricing API — not [`STALE_AFTER_DAYS`] — is what
1001/// actually tells you whether a rate moved; the staleness warning only ever said
1002/// that nobody had looked. See [`RateCatalog`].
1003///
1004/// Only the ARM figures are correct here and it is not a preference: MicroVMs are
1005/// ARM64-only, the x86 compute rates in the same catalog are 17.9% higher, and this
1006/// table used to hold the right ones by luck rather than by construction (COST-9).
1007///
1008/// A function rather than a `static`, because [`Region`] and the URL own heap
1009/// allocations. Cheap — five decimal copies and one small string — and called once
1010/// per report.
1011pub fn pinned_rates() -> RateTable {
1012    RateTable {
1013        region: Region::UsEast1,
1014        source_url: "https://aws.amazon.com/lambda/pricing/".to_string(),
1015        retrieved: CalendarDate::from_ymd(2026, 8, 7),
1016        vcpu_second: dec!(0.0000276944),
1017        gb_second: dec!(0.0000036667),
1018        // $0.0001111111 per GB-hour x 730 hours. Was 0.08 — a plausible-looking
1019        // round number that understated every stored GB by 1.37%, which is the whole
1020        // argument for deriving it from the API figure rather than reading a page.
1021        storage_gb_month: dec!(0.0811111030),
1022        snapshot_read_gb: dec!(0.00155),
1023        snapshot_write_gb: dec!(0.0038),
1024        minimum_retention: MINIMUM_RETENTION,
1025    }
1026}
1027
1028// ── the catalog, and the ARM-only rule (COST-9) ───────────────────────────────
1029
1030/// One rate the catalog has to carry, and what it fills.
1031///
1032/// The Python held this as `pricing.MICROVM_LINES`, a tuple of `RateLine` records
1033/// naming a `group`, a `usagetype`, a `unit`, and — for the two compute lines — an
1034/// `x86_group` probed only when the ARM line was missing. Same shape here, as a
1035/// closed enum: the catalog has five lines this client reads, and a sixth is not a
1036/// value that exists.
1037#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1038pub enum CatalogLine {
1039    VcpuSecond,
1040    MemoryGbSecond,
1041    /// Quoted per GB-**hour** by the API. Converted once, in
1042    /// [`RateTable::from_catalog`].
1043    SnapshotStorageGbHour,
1044    SnapshotReadGb,
1045    SnapshotWriteGb,
1046}
1047
1048impl CatalogLine {
1049    /// Every line, in the order the Python's `MICROVM_LINES` declares them.
1050    pub const ALL: [CatalogLine; 5] = [
1051        CatalogLine::VcpuSecond,
1052        CatalogLine::MemoryGbSecond,
1053        CatalogLine::SnapshotStorageGbHour,
1054        CatalogLine::SnapshotReadGb,
1055        CatalogLine::SnapshotWriteGb,
1056    ];
1057
1058    /// The region-independent `group` attribute, which is what a fetch filters on.
1059    ///
1060    /// `usagetype` carries a location prefix in every region except us-east-1
1061    /// (`Lambda-MicroVM-vCPU-Second-ARM` is `USW2-Lambda-MicroVM-vCPU-Second-ARM` in
1062    /// us-west-2), so comparing raw usage types across regions matches nothing and
1063    /// yields a table of holes rather than an error. `group` does not.
1064    pub fn group(self) -> &'static str {
1065        match self {
1066            CatalogLine::VcpuSecond => "AWS-Lambda-MicroVM-vCPU-Second-ARM",
1067            CatalogLine::MemoryGbSecond => "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
1068            CatalogLine::SnapshotStorageGbHour => "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
1069            CatalogLine::SnapshotReadGb => "AWS-Lambda-MicroVM-Snapshot-Read-GB",
1070            CatalogLine::SnapshotWriteGb => "AWS-Lambda-MicroVM-Snapshot-Write-GB",
1071        }
1072    }
1073
1074    /// The [`RateTable`] field this fills, as the Python's `RateLine.field` spells
1075    /// it — so a drift report names the same thing in both clients.
1076    pub fn field(self) -> &'static str {
1077        match self {
1078            CatalogLine::VcpuSecond => "vcpu_second",
1079            CatalogLine::MemoryGbSecond => "gb_second",
1080            CatalogLine::SnapshotStorageGbHour => "storage_gb_month",
1081            CatalogLine::SnapshotReadGb => "snapshot_read_gb",
1082            CatalogLine::SnapshotWriteGb => "snapshot_write_gb",
1083        }
1084    }
1085
1086    /// The unit the API must report for this line.
1087    ///
1088    /// Checked rather than assumed. It is the only signal available if AWS restates
1089    /// storage per GB-month: the number would change by 730x and every arithmetic
1090    /// check downstream would still pass, because they all read the same table.
1091    pub fn unit(self) -> &'static str {
1092        match self {
1093            CatalogLine::VcpuSecond => "vCPU-Seconds",
1094            CatalogLine::MemoryGbSecond => "GB-Seconds",
1095            CatalogLine::SnapshotStorageGbHour => "GB-Hours",
1096            CatalogLine::SnapshotReadGb | CatalogLine::SnapshotWriteGb => "GB",
1097        }
1098    }
1099
1100    /// The x86 sibling, for the two compute lines that have one (COST-9).
1101    ///
1102    /// Named so the refusal can say what it is refusing. MicroVMs are ARM64-only —
1103    /// the `Architecture` shape is `enum: ['ARM_64']` with no other member — so the
1104    /// x86 rate can never apply, and it is 17.9% higher. Snapshot lines have no
1105    /// architecture variant, so there is nothing to refuse.
1106    pub fn x86_group(self) -> Option<&'static str> {
1107        match self {
1108            CatalogLine::VcpuSecond => Some("AWS-Lambda-MicroVM-vCPU-Second"),
1109            CatalogLine::MemoryGbSecond => Some("AWS-Lambda-MicroVM-Memory-GB-Second"),
1110            CatalogLine::SnapshotStorageGbHour
1111            | CatalogLine::SnapshotReadGb
1112            | CatalogLine::SnapshotWriteGb => None,
1113        }
1114    }
1115
1116    /// True for storage, quoted per GB-hour where [`RateTable`] holds per GB-month.
1117    pub fn is_per_hour(self) -> bool {
1118        matches!(self, CatalogLine::SnapshotStorageGbHour)
1119    }
1120}
1121
1122/// A priced line item as the catalog states it: a group, a unit, and one USD figure.
1123///
1124/// The USD figure is a [`Decimal`] and not an f64 because the API quotes it as a
1125/// string with ten significant digits — parsing it as a float and converting back
1126/// would lose exactly the precision the string was carrying.
1127#[derive(Clone, Debug, Eq, PartialEq)]
1128pub struct CatalogEntry {
1129    /// The region-independent `group`. Compared against [`CatalogLine::group`].
1130    pub group: String,
1131    /// The unit the API reported. Compared against [`CatalogLine::unit`].
1132    pub unit: String,
1133    /// The rate, in USD per that unit.
1134    pub usd: Decimal,
1135}
1136
1137/// What a rate source offers for one region, before it becomes a [`RateTable`].
1138///
1139/// This is the parse boundary COST-9 lives at. A transport — the Pricing API, a
1140/// recorded fixture, a `mise run live:rates` dump — assembles one of these, and
1141/// [`RateTable::from_catalog`] is the only door from here into a priced report. So
1142/// the ARM refusal cannot be bypassed by building a table directly, which is what it
1143/// could be in the Python.
1144///
1145/// Deliberately holds *every* entry the source offered, including the x86 compute
1146/// rates. A catalog that filtered them out could not tell "the ARM line is gone and
1147/// the x86 one is right there" from "the whole catalog is empty", and those are
1148/// different repairs: work out what AWS renamed, versus check the region.
1149#[derive(Clone, Debug, Default, Eq, PartialEq)]
1150pub struct RateCatalog {
1151    entries: Vec<CatalogEntry>,
1152}
1153
1154impl RateCatalog {
1155    /// An empty catalog, to add entries to.
1156    pub fn new() -> RateCatalog {
1157        RateCatalog {
1158            entries: Vec::new(),
1159        }
1160    }
1161
1162    /// Records one priced line.
1163    #[must_use]
1164    pub fn with_entry(
1165        mut self,
1166        group: impl Into<String>,
1167        unit: impl Into<String>,
1168        usd: Decimal,
1169    ) -> RateCatalog {
1170        self.entries.push(CatalogEntry {
1171            group: group.into(),
1172            unit: unit.into(),
1173            usd,
1174        });
1175        self
1176    }
1177
1178    /// Every entry for a group. Normally exactly one.
1179    fn matching(&self, group: &str) -> Vec<&CatalogEntry> {
1180        self.entries
1181            .iter()
1182            .filter(|entry| entry.group == group)
1183            .collect()
1184    }
1185
1186    /// Whether the catalog offered nothing at all.
1187    pub fn is_empty(&self) -> bool {
1188        self.entries.is_empty()
1189    }
1190}
1191
1192impl RateTable {
1193    /// A table for one region, built from a catalog — or a refusal (COST-9).
1194    ///
1195    /// All or nothing. A partial table would price a run at less than it costs, and
1196    /// the caller has no way to see which field was quietly left at a stale value.
1197    ///
1198    /// Four ways this refuses, each a way the catalog could change and still parse:
1199    /// a missing ARM compute line whose x86 sibling is present (the substitution that
1200    /// would look entirely healthy and inflate every estimate by 17.9%), a missing
1201    /// line with no sibling, a restated unit, and two products where there was one.
1202    pub fn from_catalog(
1203        region: Region,
1204        source_url: impl Into<String>,
1205        retrieved: CalendarDate,
1206        catalog: &RateCatalog,
1207    ) -> Result<RateTable, Error> {
1208        let mut rates: Vec<Decimal> = Vec::with_capacity(CatalogLine::ALL.len());
1209        for line in CatalogLine::ALL {
1210            let matches = catalog.matching(line.group());
1211            let entry = match matches.as_slice() {
1212                [] => return Err(missing_line_error(&region, line, catalog)),
1213                [only] => *only,
1214                many => {
1215                    return Err(Error::invalid_arg(format!(
1216                        "{} in {region} returned {} products where one group in one region has \
1217                         always been one product; the catalog gained a dimension this client \
1218                         does not know how to choose between, and picking one would silently \
1219                         select a rate",
1220                        line.group(),
1221                        many.len(),
1222                    )));
1223                }
1224            };
1225            if entry.unit != line.unit() {
1226                return Err(Error::invalid_arg(format!(
1227                    "{} in {region} is now quoted per {:?}, not {:?}; the conversion into the \
1228                     rate table no longer holds and every downstream figure would still look \
1229                     plausible",
1230                    line.group(),
1231                    entry.unit,
1232                    line.unit(),
1233                )));
1234            }
1235            // The API quotes storage per GB-hour. Converting here rather than at
1236            // every use site keeps `RateTable` one convention deep, which is the
1237            // whole reason `SECONDS_PER_MONTH` exists.
1238            rates.push(if line.is_per_hour() {
1239                entry.usd * HOURS_PER_MONTH
1240            } else {
1241                entry.usd
1242            });
1243        }
1244        Ok(RateTable {
1245            region,
1246            source_url: source_url.into(),
1247            retrieved,
1248            vcpu_second: rates[0],
1249            gb_second: rates[1],
1250            storage_gb_month: rates[2],
1251            snapshot_read_gb: rates[3],
1252            snapshot_write_gb: rates[4],
1253            // The catalog prices line items and says nothing about a one-week storage
1254            // minimum, a per-request charge, a billing increment, or a free tier. So
1255            // a fetched table is authoritative on *rates* and still hand-read on
1256            // *rules*, and dropping the retention floor here would understate a
1257            // create-and-destroy suite by four orders of magnitude.
1258            minimum_retention: MINIMUM_RETENTION,
1259        })
1260    }
1261}
1262
1263/// The error for a line the catalog does not have (COST-9).
1264///
1265/// Split out because the ARM case needs to say more than "missing": the x86 sibling
1266/// is right there, 17.9% higher, the same shape, and it parses. The tempting fix is
1267/// to use it, and every estimate would inflate while nothing said so — so the message
1268/// names the rate it is refusing and the magnitude of the error that substituting it
1269/// would introduce.
1270fn missing_line_error(region: &Region, line: CatalogLine, catalog: &RateCatalog) -> Error {
1271    if catalog.is_empty() {
1272        return Error::invalid_arg(format!(
1273            "the rate catalog has no MicroVM line items for {region} at all, so {} cannot be \
1274             filled. Measured 2026-08-07, pricing coverage and service availability coincide: a \
1275             region that does not price MicroVMs does not run them either, and answers \
1276             lambda-microvms with AccessDeniedException and a null message that reads exactly \
1277             like an IAM problem it is not. Check the region before auditing a policy",
1278            line.field(),
1279        ));
1280    }
1281    let Some(x86_group) = line.x86_group() else {
1282        return Error::invalid_arg(format!(
1283            "{region} prices MicroVMs but has no {} line item, so {} cannot be filled",
1284            line.group(),
1285            line.field(),
1286        ));
1287    };
1288    match catalog.matching(x86_group).first() {
1289        Some(sibling) => Error::invalid_arg(format!(
1290            "{region} has no {}, only the x86 rate {x86_group} at ${}. MicroVMs are ARM64-only \
1291             — the Architecture shape is enum ['ARM_64'] with no other member — so the x86 rate \
1292             can never apply and is not substituted for {}; doing so would overstate every \
1293             compute figure by roughly 18%",
1294            line.group(),
1295            sibling.usd,
1296            line.field(),
1297        )),
1298        None => Error::invalid_arg(format!(
1299            "{region} has neither {} nor {x86_group}, so {} cannot be filled",
1300            line.group(),
1301            line.field(),
1302        )),
1303    }
1304}
1305
1306// ── phases and billing lines ─────────────────────────────────────────────────
1307
1308/// The line items AWS bills, spelled as separately as AWS bills them.
1309///
1310/// vCPU and memory are two entries rather than one blended GB-second because that is
1311/// how the pricing page prices them, and a blended figure cannot be reconciled
1312/// against a Cost Explorer breakdown that keeps them apart.
1313#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1314pub enum BillingLine {
1315    Vcpu,
1316    Memory,
1317    SnapshotStorage,
1318    SnapshotRead,
1319    SnapshotWrite,
1320}
1321
1322impl BillingLine {
1323    /// The wire spelling, identical to the Python `StrEnum` member.
1324    pub fn as_str(self) -> &'static str {
1325        match self {
1326            BillingLine::Vcpu => "vcpu",
1327            BillingLine::Memory => "memory",
1328            BillingLine::SnapshotStorage => "snapshot-storage",
1329            BillingLine::SnapshotRead => "snapshot-read",
1330            BillingLine::SnapshotWrite => "snapshot-write",
1331        }
1332    }
1333}
1334
1335impl fmt::Display for BillingLine {
1336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1337        f.write_str(self.as_str())
1338    }
1339}
1340
1341/// The lifecycle a sandbox goes through, as the phases that cost money.
1342#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1343pub enum CostPhase {
1344    ImageBuild,
1345    ImageStorage,
1346    Launch,
1347    Running,
1348    Suspended,
1349    Suspend,
1350    Resume,
1351}
1352
1353impl CostPhase {
1354    /// Every phase, in lifecycle order.
1355    ///
1356    /// Public because a caller that has to *judge* a phase string needs the list to
1357    /// refuse with, and the two bindings each grew their own copy of this array before
1358    /// it existed here — a parallel table that would have gone stale the first time a
1359    /// phase was added. [`CostPhase::from_str`] is the reader; the round-trip test below
1360    /// is what stops a variant being added to the enum and forgotten here.
1361    pub const ALL: [CostPhase; 7] = [
1362        CostPhase::ImageBuild,
1363        CostPhase::ImageStorage,
1364        CostPhase::Launch,
1365        CostPhase::Running,
1366        CostPhase::Suspended,
1367        CostPhase::Suspend,
1368        CostPhase::Resume,
1369    ];
1370
1371    /// The wire spelling, identical to the Python `StrEnum` member.
1372    pub fn as_str(self) -> &'static str {
1373        match self {
1374            CostPhase::ImageBuild => "image-build",
1375            CostPhase::ImageStorage => "image-storage",
1376            CostPhase::Launch => "launch",
1377            CostPhase::Running => "running",
1378            CostPhase::Suspended => "suspended",
1379            CostPhase::Suspend => "suspend",
1380            CostPhase::Resume => "resume",
1381        }
1382    }
1383}
1384
1385impl FromStr for CostPhase {
1386    type Err = Error;
1387
1388    /// A phase from its wire spelling, refusing anything else with the whole list.
1389    ///
1390    /// Here rather than in each caller because the callers are *bindings*: a Python or
1391    /// JS `report.by_phase("running")` has a bare string where Rust has a variant, so
1392    /// something has to judge it. Both bindings did, each with its own seven-element
1393    /// array — two parallel tables over a closed set, which is one table too many and
1394    /// two too many to keep in step with the enum. This is the single reader, and the
1395    /// list it offers on a refusal is [`CostPhase::ALL`] rather than a written-out
1396    /// sentence, so a phase added to the enum appears in the message without an edit.
1397    ///
1398    /// Note the asymmetry with [`Region::from_str`](crate::Region): a region's refusal
1399    /// exists because an unlisted region is a *plausible* value this client has no
1400    /// evidence for, and there is an opt-in. A phase has no opt-in and needs none — the
1401    /// set is closed by the billing model, so an unrecognized spelling is a typo and
1402    /// nothing more.
1403    fn from_str(phase: &str) -> Result<CostPhase, Error> {
1404        CostPhase::ALL
1405            .into_iter()
1406            .find(|candidate| candidate.as_str() == phase)
1407            .ok_or_else(|| {
1408                let offered = CostPhase::ALL
1409                    .iter()
1410                    .map(|candidate| candidate.as_str())
1411                    .collect::<Vec<_>>()
1412                    .join(", ");
1413                Error::invalid_arg(format!(
1414                    "{phase:?} is not a cost phase; the phases are {offered}"
1415                ))
1416            })
1417    }
1418}
1419
1420/// `pad` rather than `write_str`, because a phase is a column.
1421///
1422/// `write_str` ignores the formatter's width entirely, so `{:<14}` on a phase silently
1423/// produced a ragged table — every plain-text line's dollar figure started at a
1424/// different offset, and the fix looks like it is already there in the format string.
1425/// [`fmt::Formatter::pad`] is the one spelling that honours width, precision, and
1426/// alignment; `write!(f, "{}", ...)` here would recurse straight back into this
1427/// impl and drop the width again.
1428impl fmt::Display for CostPhase {
1429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1430        f.pad(self.as_str())
1431    }
1432}
1433
1434/// One phase's one billing line: what was consumed, and what that costs.
1435///
1436/// `quantity` and `unit` are kept beside `amount` so a reader can check the
1437/// arithmetic against the rate table instead of trusting the total, which is the only
1438/// defence against a rate that drifts out of date without anyone noticing.
1439#[derive(Clone, Debug, Eq, PartialEq)]
1440pub struct LineItem {
1441    pub phase: CostPhase,
1442    /// `None` only for a phase with no published rate to attribute it to.
1443    pub line: Option<BillingLine>,
1444    pub quantity: Decimal,
1445    pub unit: String,
1446    pub amount: Amount,
1447    pub duration: Option<DurationP>,
1448    pub note: String,
1449}
1450
1451impl LineItem {
1452    /// The quantity at display precision, for a CLI column.
1453    pub fn quantity_string(&self) -> String {
1454        render_quantity(self.quantity)
1455    }
1456}
1457
1458impl fmt::Display for LineItem {
1459    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1460        let consumed = format!("{} {}", self.quantity_string(), self.unit);
1461        let head = format!("{:<14} {:<26} {}", self.phase, consumed, self.amount);
1462        if self.note.is_empty() {
1463            f.write_str(&head)
1464        } else {
1465            write!(f, "{head}  [{}]", self.note)
1466        }
1467    }
1468}
1469
1470// ── the report ───────────────────────────────────────────────────────────────
1471
1472/// Per-phase attribution for one sandbox, measured or projected.
1473///
1474/// Holds the rate table it was computed against rather than reaching for the pinned
1475/// default, so a report stays readable — and reproducible — after [`pinned_rates`] is
1476/// updated.
1477#[derive(Clone, Debug, Eq, PartialEq)]
1478pub struct CostReport {
1479    label: String,
1480    size: SizeClass,
1481    rates: RateTable,
1482    items: Vec<LineItem>,
1483    staleness: Option<String>,
1484}
1485
1486impl CostReport {
1487    /// What this report is of, for a header.
1488    pub fn label(&self) -> &str {
1489        &self.label
1490    }
1491
1492    /// The class every compute figure here was billed at (COST-5).
1493    pub fn size(&self) -> SizeClass {
1494        self.size
1495    }
1496
1497    /// The table these figures were computed against.
1498    pub fn rates(&self) -> &RateTable {
1499        &self.rates
1500    }
1501
1502    /// Every line item, in lifecycle order.
1503    pub fn items(&self) -> &[LineItem] {
1504        &self.items
1505    }
1506
1507    /// The staleness warning, when the table was stale at computation time (COST-7).
1508    ///
1509    /// Carried on the report because the warning has to survive into whatever renders
1510    /// it: a library caller with a log filter and a CLI that only wrote stderr would
1511    /// each lose it on their own.
1512    pub fn staleness(&self) -> Option<&str> {
1513        self.staleness.as_deref()
1514    }
1515
1516    /// The line items with a published rate.
1517    pub fn priced(&self) -> impl Iterator<Item = &LineItem> {
1518        self.items
1519            .iter()
1520            .filter(|item| item.amount.estimate().is_some())
1521    }
1522
1523    /// The line items with no published rate.
1524    pub fn unpriced(&self) -> impl Iterator<Item = &LineItem> {
1525        self.items
1526            .iter()
1527            .filter(|item| item.amount.unpriced_reason().is_some())
1528    }
1529
1530    /// The total, which is a lower bound whenever anything is unpriced (COST-4).
1531    pub fn total(&self) -> Total {
1532        Total::of(self.items.iter().map(|item| (item.phase, &item.amount)))
1533    }
1534
1535    /// False whenever any phase has no published rate. See [`Total`].
1536    pub fn is_complete(&self) -> bool {
1537        self.unpriced().next().is_none()
1538    }
1539
1540    /// True only if every duration was timed. An estimate is never this (COST-10).
1541    pub fn fully_measured(&self) -> bool {
1542        let mut durations = self
1543            .items
1544            .iter()
1545            .filter_map(|item| item.duration)
1546            .peekable();
1547        durations.peek().is_some() && durations.all(DurationP::is_measured)
1548    }
1549
1550    /// The line items belonging to one phase.
1551    pub fn by_phase(&self, phase: CostPhase) -> impl Iterator<Item = &LineItem> {
1552        self.items.iter().filter(move |item| item.phase == phase)
1553    }
1554
1555    /// Plain text for a CLI. Leads with what the dollars are, not the dollars.
1556    ///
1557    /// The header is not decoration: a figure copied out of a terminal loses its
1558    /// docstring, so the estimate label and the retrieval date travel with it.
1559    pub fn render(&self) -> String {
1560        let mut lines = vec![
1561            format!("{} — {}", self.label, self.size),
1562            format!(
1563                "dollars are estimates derived from published {} rates (retrieved {}); only Cost \
1564                 Explorer knows the bill",
1565                self.rates.region, self.rates.retrieved,
1566            ),
1567        ];
1568        if let Some(warning) = &self.staleness {
1569            lines.push(format!("WARNING: {warning}"));
1570        }
1571        lines.extend(self.items.iter().map(|item| format!("  {item}")));
1572        lines.push(format!("total: {}", self.total()));
1573        lines.join("\n")
1574    }
1575}
1576
1577// ── the line builders ────────────────────────────────────────────────────────
1578
1579/// Compute for one phase, as two line items (COST-5).
1580///
1581/// Both figures read `baseline_*`. The guest reports the peak and bursts to it, but
1582/// the peak is charged only for the seconds above baseline that are actually consumed
1583/// — which this client cannot observe, so it is left out rather than guessed at. See
1584/// [`crate::sizing`], whose 2 GB class reports 8 GB in the guest: reading the peak
1585/// would overstate the memory line exactly 4x.
1586fn compute_lines(
1587    size: SizeClass,
1588    duration: DurationP,
1589    rates: &RateTable,
1590    phase: CostPhase,
1591) -> Result<[LineItem; 2], Error> {
1592    let seconds = duration.seconds();
1593    // `baseline_gb` and `baseline_vcpu` are the accessors whose doc comments say they
1594    // are the billed figures; `peak_gb`/`peak_vcpu` are deliberately not reachable
1595    // from here. Both cross the float boundary through `gb_decimal` (COST-6).
1596    let vcpu = gb_decimal(size.baseline_vcpu())?;
1597    let memory = gb_decimal(size.baseline_gb())?;
1598    Ok([
1599        LineItem {
1600            phase,
1601            line: Some(BillingLine::Vcpu),
1602            quantity: vcpu * seconds,
1603            unit: "vCPU-seconds".to_string(),
1604            amount: Amount::estimated(vcpu * seconds * rates.vcpu_second()),
1605            duration: Some(duration),
1606            note: format!("{vcpu} vCPU baseline"),
1607        },
1608        LineItem {
1609            phase,
1610            line: Some(BillingLine::Memory),
1611            quantity: memory * seconds,
1612            unit: "GB-seconds".to_string(),
1613            amount: Amount::estimated(memory * seconds * rates.gb_second()),
1614            duration: Some(duration),
1615            note: format!("{memory} GB baseline, billed separately from vCPU"),
1616        },
1617    ])
1618}
1619
1620/// Snapshot storage for a hold, with the documented minimum retention applied
1621/// (COST-8).
1622///
1623/// The minimum is on the rate row itself, so it applies to anything stored there.
1624/// `docs/PLATFORM.md` demonstrates it only for images — a 2 GB image deleted after
1625/// sixty seconds still bills about four cents — and says nothing about a suspend
1626/// snapshot released early, so the note names which case is documented and which is
1627/// the rate row read at face value. Not applying it would understate the one line item
1628/// that dominates a create-and-destroy suite, by four orders of magnitude.
1629fn storage_line(
1630    phase: CostPhase,
1631    gb: Decimal,
1632    held: DurationP,
1633    rates: &RateTable,
1634) -> Result<LineItem, Error> {
1635    let floor = seconds_of(rates.minimum_retention());
1636    let held_seconds = held.seconds();
1637    let billed = held_seconds.max(floor);
1638    let quantity = gb * billed / SECONDS_PER_MONTH;
1639    let mut note = format!("{} GB held {held}", gb.normalize());
1640    if billed > held_seconds {
1641        // The day count comes off the rate row rather than out of a division here: the
1642        // field is the only thing that knows how long the window is, and `as_secs() /
1643        // 86_400` beside the message is a second convention that would keep saying
1644        // "7-day" after the row moved.
1645        note.push_str(&format!(
1646            "; billed {}-day minimum retention ({floor}s) instead",
1647            rates.minimum_retention_days(),
1648        ));
1649    }
1650    Ok(LineItem {
1651        phase,
1652        line: Some(BillingLine::SnapshotStorage),
1653        quantity,
1654        unit: "GB-months".to_string(),
1655        amount: Amount::estimated(quantity * rates.storage_gb_month()),
1656        duration: Some(held),
1657        note,
1658    })
1659}
1660
1661/// A snapshot write or read, billed per GB moved with no time component.
1662fn transfer_line(
1663    phase: CostPhase,
1664    line: BillingLine,
1665    gb: Decimal,
1666    count: u32,
1667    rates: &RateTable,
1668    note_suffix: &str,
1669) -> LineItem {
1670    let rate = if line == BillingLine::SnapshotWrite {
1671        rates.snapshot_write_gb()
1672    } else {
1673        rates.snapshot_read_gb()
1674    };
1675    let quantity = gb * Decimal::from(count);
1676    LineItem {
1677        phase,
1678        line: Some(line),
1679        quantity,
1680        unit: "GB".to_string(),
1681        amount: Amount::estimated(quantity * rate),
1682        duration: None,
1683        note: format!("{count} x {} GB{note_suffix}", gb.normalize()),
1684    }
1685}
1686
1687/// Why the image build has no price.
1688///
1689/// Not a caveat in a doc comment: it is the [`Amount::Unpriced`] reason that appears
1690/// on the line item and in every total that contains it. The build starts a real
1691/// MicroVM to run the Dockerfile, so it plausibly is billed, but AWS does not say and
1692/// we have not measured it.
1693pub const BUILD_UNPRICED_REASON: &str = "AWS does not publish whether the server-side image build is billed as compute; the build \
1694     runs a real MicroVM, so treating it as free would understate the run";
1695
1696/// The image build: a real phase with a real duration and no published rate (COST-3).
1697///
1698/// An untimed build still gets a line. Omitting it would leave the report looking
1699/// complete, and the report being visibly incomplete is the whole point of the phase
1700/// appearing at all.
1701fn build_line(duration: Option<DurationP>) -> LineItem {
1702    LineItem {
1703        phase: CostPhase::ImageBuild,
1704        line: None,
1705        quantity: duration.map_or(Decimal::ZERO, DurationP::seconds),
1706        unit: if duration.is_some() {
1707            "seconds".to_string()
1708        } else {
1709            "seconds (untimed)".to_string()
1710        },
1711        amount: Amount::unpriced(BUILD_UNPRICED_REASON),
1712        duration,
1713        note: "unknown, not zero — see docs/PLATFORM.md, 'Not published'".to_string(),
1714    }
1715}
1716
1717// ── the reports ──────────────────────────────────────────────────────────────
1718
1719/// What to attribute cost to, for one sandbox's lifecycle.
1720///
1721/// A struct rather than eleven parameters, because the Python's keyword-only
1722/// signature has no direct Rust equivalent and eleven positional arguments is how a
1723/// caller passes `image_gb` where `snapshot_gb` was meant. `Default` gives the same
1724/// "pass only the phases that happened" ergonomics as the Python's defaults.
1725///
1726/// Every duration is a [`DurationP`], so a report built from timed phases and one
1727/// built from a plan are the same shape and are told apart by their own contents
1728/// rather than by which function produced them.
1729#[derive(Clone, Debug, Default, PartialEq)]
1730pub struct RunUsage {
1731    /// Wall-clock time in RUNNING. Bills at baseline whether or not anything is
1732    /// executing — unlike AgentCore Runtime there is no free I/O wait, which is why
1733    /// suspension rather than idleness is the lever.
1734    pub running: Option<DurationP>,
1735    /// Time held suspended. Pays storage only: a suspended VM is frozen.
1736    pub suspended: Option<DurationP>,
1737    /// How long the image build took, if it was timed. Always unpriced (COST-3).
1738    pub image_build: Option<DurationP>,
1739    /// The image's size. Passing this adds both an image-storage line *and* the
1740    /// unpriced build line: an image that has storage cost was built, and the build's
1741    /// price is unknown, so a create-and-destroy report is never complete. A caller
1742    /// reusing an existing image passes `None` and pays neither.
1743    pub image_gb: Option<f64>,
1744    /// How long the image was retained. Defaults to the documented minimum, marked
1745    /// [`Provenance::Projected`] — nobody timed that week either.
1746    pub image_retained: Option<DurationP>,
1747    /// Each cycle pays a snapshot write plus a read.
1748    pub suspend_resume_cycles: u32,
1749    /// The suspend snapshot's size. Defaults to the baseline memory footprint, which
1750    /// is what `docs/PLATFORM.md`'s own worked figures use ("a suspended 2 GB VM pays
1751    /// about $0.16 a month" is 2 GB at the storage rate). Whether a suspend snapshot
1752    /// is baseline-sized or peak-sized is not documented; override it if you have
1753    /// measured otherwise.
1754    pub snapshot_gb: Option<f64>,
1755    /// Whether a launch happened. A launch reads a snapshot.
1756    pub launched: bool,
1757}
1758
1759impl RunUsage {
1760    /// A usage record for a sandbox that launched, with no phase attributed yet.
1761    ///
1762    /// `launched: true` matches the Python's default, and it is the right default:
1763    /// almost every report is of a VM that ran, and the comparison path that does not
1764    /// launch says so explicitly.
1765    pub fn launched() -> RunUsage {
1766        RunUsage {
1767            launched: true,
1768            ..RunUsage::default()
1769        }
1770    }
1771}
1772
1773/// Per-phase attribution for one sandbox's lifecycle.
1774///
1775/// `today` decides staleness (COST-7) and is a parameter rather than a clock read, so
1776/// a report is a pure function of its inputs and a test does not have to travel in
1777/// time. Pass [`CalendarDate::today_utc`] from a binary.
1778pub fn run_report(
1779    size: SizeClass,
1780    usage: &RunUsage,
1781    rates: &RateTable,
1782    today: CalendarDate,
1783    label: impl Into<String>,
1784) -> Result<CostReport, Error> {
1785    let snapshot = match usage.snapshot_gb {
1786        Some(gb) => gb_decimal(gb)?,
1787        None => gb_decimal(size.baseline_gb())?,
1788    };
1789    let image_gb = usage.image_gb.map(gb_decimal).transpose()?;
1790    let mut items: Vec<LineItem> = Vec::new();
1791
1792    if let Some(image_gb) = image_gb {
1793        items.push(build_line(usage.image_build));
1794        let held = usage
1795            .image_retained
1796            .unwrap_or(DurationP::Projected(rates.minimum_retention()));
1797        items.push(storage_line(
1798            CostPhase::ImageStorage,
1799            image_gb,
1800            held,
1801            rates,
1802        )?);
1803    } else if usage.image_build.is_some() {
1804        items.push(build_line(usage.image_build));
1805    }
1806
1807    if usage.launched {
1808        // A launch reads a snapshot at the same per-GB rate as a resume. Which
1809        // snapshot's size that read covers is not documented, so it uses the image
1810        // when the caller named one and the memory footprint otherwise.
1811        let read_gb = image_gb.unwrap_or(snapshot);
1812        items.push(transfer_line(
1813            CostPhase::Launch,
1814            BillingLine::SnapshotRead,
1815            read_gb,
1816            1,
1817            rates,
1818            "; 'launch or resume' shares one rate, and which snapshot a launch reads is \
1819             undocumented",
1820        ));
1821    }
1822
1823    if let Some(running) = usage.running {
1824        items.extend(compute_lines(size, running, rates, CostPhase::Running)?);
1825    }
1826
1827    if let Some(suspended) = usage.suspended {
1828        // No compute line at all: a suspended VM is frozen, so it pays storage only.
1829        // Not a compute line multiplied by zero, which would reappear the moment
1830        // someone changed how a duration is derived.
1831        items.push(storage_line(
1832            CostPhase::Suspended,
1833            snapshot,
1834            suspended,
1835            rates,
1836        )?);
1837    }
1838
1839    if usage.suspend_resume_cycles > 0 {
1840        items.push(transfer_line(
1841            CostPhase::Suspend,
1842            BillingLine::SnapshotWrite,
1843            snapshot,
1844            usage.suspend_resume_cycles,
1845            rates,
1846            "",
1847        ));
1848        items.push(transfer_line(
1849            CostPhase::Resume,
1850            BillingLine::SnapshotRead,
1851            snapshot,
1852            usage.suspend_resume_cycles,
1853            rates,
1854            "",
1855        ));
1856    }
1857
1858    Ok(CostReport {
1859        label: label.into(),
1860        size,
1861        rates: rates.clone(),
1862        items,
1863        staleness: rates.staleness(today),
1864    })
1865}
1866
1867/// A plan's phases, in plain seconds, before anything is spent.
1868///
1869/// Separate from [`RunUsage`] on purpose (COST-10): its fields are `f64` seconds
1870/// rather than [`DurationP`], so there is no field an accidentally-`Measured`
1871/// duration could be written into. The wrapping happens in [`estimate_run`], in one
1872/// place, and every one of them is [`Provenance::Projected`].
1873#[derive(Clone, Copy, Debug, Default, PartialEq)]
1874pub struct PlanUsage {
1875    pub running_seconds: f64,
1876    pub suspended_seconds: f64,
1877    pub image_gb: Option<f64>,
1878    pub image_retained_seconds: Option<f64>,
1879    pub suspend_resume_cycles: u32,
1880    pub snapshot_gb: Option<f64>,
1881    pub launched: bool,
1882}
1883
1884impl PlanUsage {
1885    /// A plan for a sandbox that launches, with no phase attributed yet.
1886    pub fn launched() -> PlanUsage {
1887        PlanUsage {
1888            launched: true,
1889            ..PlanUsage::default()
1890        }
1891    }
1892}
1893
1894/// What a plan will cost, before spending anything (COST-10).
1895///
1896/// Takes plain seconds and marks every one of them [`Provenance::Projected`], so the
1897/// resulting report can never claim to be measured. That is the difference between
1898/// this and [`run_report`]: not the arithmetic, which is shared — it delegates — but
1899/// what the durations admit about themselves.
1900pub fn estimate_run(
1901    size: SizeClass,
1902    plan: &PlanUsage,
1903    rates: &RateTable,
1904    today: CalendarDate,
1905    label: impl Into<String>,
1906) -> Result<CostReport, Error> {
1907    // Zero seconds means "this phase did not happen", matching the Python's
1908    // `if running_seconds else None`. A zero-length phase priced at zero would put a
1909    // line on the report claiming a measurement nobody took.
1910    let projected = |seconds: f64| -> Result<Option<DurationP>, Error> {
1911        if seconds == 0.0 {
1912            return Ok(None);
1913        }
1914        Ok(Some(DurationP::projected_secs_f64(seconds)?))
1915    };
1916    let usage = RunUsage {
1917        running: projected(plan.running_seconds)?,
1918        suspended: projected(plan.suspended_seconds)?,
1919        // No `image_build` duration: a plan has not built anything, so there is
1920        // nothing to have timed. The unpriced build line still appears whenever
1921        // `image_gb` is set, because the image will have to be built.
1922        image_build: None,
1923        image_gb: plan.image_gb,
1924        image_retained: plan
1925            .image_retained_seconds
1926            .map(DurationP::projected_secs_f64)
1927            .transpose()?,
1928        suspend_resume_cycles: plan.suspend_resume_cycles,
1929        snapshot_gb: plan.snapshot_gb,
1930        launched: plan.launched,
1931    };
1932    run_report(size, &usage, rates, today, label)
1933}
1934
1935// ── running versus suspended ─────────────────────────────────────────────────
1936
1937/// Running versus suspended for the same VM over the same wall time.
1938///
1939/// The gap is roughly two orders of magnitude over a month, which is the entire
1940/// argument for a warm suspended pool. `cycles` is here so the argument stays honest:
1941/// each suspend/resume pays a snapshot write plus a read, and a pool that churns
1942/// spends more on transitions than it saves on residency. The conclusion to draw is
1943/// "avoid churn", not "avoid residency".
1944///
1945/// Both sides exclude image build and storage. They are the same for either choice,
1946/// and leaving them in would dilute the ratio the comparison exists to show — and drag
1947/// an unpriced build into a figure whose whole job is to be comparable.
1948#[derive(Clone, Debug, Eq, PartialEq)]
1949pub struct ResidencyComparison {
1950    size: SizeClass,
1951    hold: DurationP,
1952    cycles: u32,
1953    running: CostReport,
1954    suspended: CostReport,
1955    rates: RateTable,
1956}
1957
1958impl ResidencyComparison {
1959    pub fn size(&self) -> SizeClass {
1960        self.size
1961    }
1962
1963    /// The wall time both sides cover. Always [`Provenance::Projected`]: a comparison
1964    /// is a hypothetical about a hold nobody has taken yet.
1965    pub fn hold(&self) -> DurationP {
1966        self.hold
1967    }
1968
1969    pub fn cycles(&self) -> u32 {
1970        self.cycles
1971    }
1972
1973    /// The report for leaving the VM running.
1974    pub fn running(&self) -> &CostReport {
1975        &self.running
1976    }
1977
1978    /// The report for suspending it, transitions included.
1979    pub fn suspended(&self) -> &CostReport {
1980        &self.suspended
1981    }
1982
1983    /// How many times more the running VM costs.
1984    ///
1985    /// Zero-safe by construction: snapshot storage always has the minimum-retention
1986    /// floor (COST-8), so the denominator is never zero. Both totals are
1987    /// [`Total::Exact`] because both sides exclude the unpriced build, which is what
1988    /// makes reading their floors sound here.
1989    pub fn ratio(&self) -> Decimal {
1990        self.running.total().floor().amount() / self.suspended.total().floor().amount()
1991    }
1992
1993    /// One suspend/resume: a snapshot write plus a read, per GB.
1994    ///
1995    /// Without it the honest conclusion inverts — "suspend constantly" reads as free.
1996    pub fn per_cycle(&self) -> Result<EstimatedUsd, Error> {
1997        let gb = gb_decimal(self.size.baseline_gb())?;
1998        Ok(EstimatedUsd::new(
1999            gb * (self.rates.snapshot_write_gb() + self.rates.snapshot_read_gb()),
2000        ))
2001    }
2002
2003    /// How long a VM must stay suspended for the cycle to pay for itself.
2004    ///
2005    /// Below this, suspending and resuming costs more than having left the VM running
2006    /// — which is the number a pool scheduler actually needs, and the one a bare "100x
2007    /// cheaper" headline hides.
2008    ///
2009    /// Returns a [`Decimal`] where the Python returned a `float`, so the seconds
2010    /// figure stays in the same representation as the money it was derived from. See
2011    /// [`ResidencyComparison::break_even_seconds_f64`] for the lossy step, which is
2012    /// named as one.
2013    pub fn break_even_seconds(&self) -> Result<Decimal, Error> {
2014        let vcpu = gb_decimal(self.size.baseline_vcpu())?;
2015        let gb = gb_decimal(self.size.baseline_gb())?;
2016        let running_per_sec = vcpu * self.rates.vcpu_second() + gb * self.rates.gb_second();
2017        let storage_per_sec = gb * self.rates.storage_gb_month() / SECONDS_PER_MONTH;
2018        let floor_sec = seconds_of(self.rates.minimum_retention());
2019        let churn = self.per_cycle()?.amount();
2020        // Inside the minimum-retention window the storage charge is a constant, so the
2021        // equation is linear in the hold; past it storage grows with the hold and the
2022        // slope changes. Solve the constant branch first and only take the other if the
2023        // answer falls outside the window.
2024        let candidate = (churn + floor_sec * storage_per_sec) / running_per_sec;
2025        if candidate > floor_sec {
2026            return Ok(churn / (running_per_sec - storage_per_sec));
2027        }
2028        Ok(candidate)
2029    }
2030
2031    /// The break-even hold as an f64, for a JSON envelope.
2032    ///
2033    /// **Lossy, and named so.** The exact answer is
2034    /// [`ResidencyComparison::break_even_seconds`]; this exists because `cli.py` emits
2035    /// `breakEvenSeconds` as a JSON number and the two clients have to agree on it.
2036    /// Seconds, not dollars — no money figure has an f64 accessor (COST-2).
2037    pub fn break_even_seconds_f64(&self) -> Result<f64, Error> {
2038        let exact = self.break_even_seconds()?;
2039        exact.to_f64().ok_or_else(|| {
2040            Error::invalid_arg(format!(
2041                "the break-even hold of {exact}s does not fit in an f64, so it cannot be reported \
2042                 as a JSON number"
2043            ))
2044        })
2045    }
2046
2047    /// Plain text for a CLI, ending with the conclusion the numbers support.
2048    pub fn render(&self) -> Result<String, Error> {
2049        Ok([
2050            format!("{} held {}", self.size, self.hold),
2051            format!("  running:   {}", self.running.total()),
2052            format!(
2053                "  suspended: {} ({} cycle(s) included)",
2054                self.suspended.total(),
2055                self.cycles,
2056            ),
2057            format!(
2058                "  ratio:     {}x cheaper suspended",
2059                self.ratio().round_dp(1)
2060            ),
2061            format!(
2062                "  per cycle: {} — break-even hold {}s, so avoid churn rather than residency",
2063                self.per_cycle()?,
2064                self.break_even_seconds()?.round_dp(0),
2065            ),
2066        ]
2067        .join("\n"))
2068    }
2069}
2070
2071/// The warm-pool argument, with its own counter-argument attached.
2072///
2073/// `cycles` should be at least 1: a suspension that is never resumed is a
2074/// termination, and pricing it as free transitions is how a pool design that churns
2075/// every few seconds looks affordable on paper. Zero is accepted rather than refused
2076/// because a caller comparing pure residency has a legitimate question, and the
2077/// per-cycle figure is on the comparison either way.
2078pub fn compare_residency(
2079    size: SizeClass,
2080    hold: Duration,
2081    cycles: u32,
2082    rates: &RateTable,
2083    today: CalendarDate,
2084) -> Result<ResidencyComparison, Error> {
2085    let hold = DurationP::Projected(hold);
2086    let running = run_report(
2087        size,
2088        &RunUsage {
2089            running: Some(hold),
2090            launched: false,
2091            ..RunUsage::default()
2092        },
2093        rates,
2094        today,
2095        "left running",
2096    )?;
2097    let suspended = run_report(
2098        size,
2099        &RunUsage {
2100            suspended: Some(hold),
2101            suspend_resume_cycles: cycles,
2102            launched: false,
2103            ..RunUsage::default()
2104        },
2105        rates,
2106        today,
2107        "suspended",
2108    )?;
2109    Ok(ResidencyComparison {
2110        size,
2111        hold,
2112        cycles,
2113        running,
2114        suspended,
2115        rates: rates.clone(),
2116    })
2117}
2118
2119#[cfg(test)]
2120mod tests {
2121    use proptest::prelude::*;
2122
2123    use super::*;
2124    use crate::error::ErrorKind;
2125
2126    /// A month as AWS's GB-month rate defines it. Every "per month" figure in
2127    /// `docs/PLATFORM.md` is this many seconds, not 30 days.
2128    const MONTH: Duration = Duration::from_secs(730 * 3600);
2129
2130    /// A date the pinned table is fresh on, so a staleness warning in a test that is
2131    /// not about staleness is a failure rather than noise.
2132    fn fresh_day() -> CalendarDate {
2133        CalendarDate::from_ymd(2026, 8, 8)
2134    }
2135
2136    /// The pinned table, which every arithmetic test computes against.
2137    fn rates() -> RateTable {
2138        pinned_rates()
2139    }
2140
2141    /// One phase's amount, asserting it was priced at all.
2142    fn priced(report: &CostReport, phase: CostPhase) -> Decimal {
2143        let items: Vec<&LineItem> = report.by_phase(phase).collect();
2144        assert_eq!(items.len(), 1, "{phase} should have exactly one line item");
2145        items[0]
2146            .amount
2147            .estimate()
2148            .unwrap_or_else(|| panic!("{phase} should be priced"))
2149            .amount()
2150    }
2151
2152    /// A report of one running phase, which is the shape most arithmetic tests want.
2153    fn running_report(size: SizeClass, duration: DurationP) -> CostReport {
2154        run_report(
2155            size,
2156            &RunUsage {
2157                running: Some(duration),
2158                launched: false,
2159                ..RunUsage::default()
2160            },
2161            &rates(),
2162            fresh_day(),
2163            "run",
2164        )
2165        .expect("a running phase at a documented size prices")
2166    }
2167
2168    // -- the rate table --------------------------------------------------------
2169
2170    /// Every rate as a literal, transcribed from `docs/PLATFORM.md`, "What actually
2171    /// costs money", us-east-1. Asserted literally rather than computed, because the
2172    /// whole value of pinning a rate table is that it can be diffed against the page
2173    /// it came from — and against the Python client's own copy of these literals in
2174    /// `test_cost.py`.
2175    ///
2176    /// `storage_gb_month` is the one derived value: the Pricing API quotes snapshot
2177    /// storage per GB-hour, and $0.0001111111 x 730 is $0.0811111030. It read 0.08
2178    /// until 2026-08-07, which was 1.37% low.
2179    #[test]
2180    fn every_rate_byte_matches_the_python_literal() {
2181        let rates = rates();
2182        assert_eq!(rates.region(), &Region::UsEast1);
2183        assert_eq!(rates.vcpu_second(), dec!(0.0000276944));
2184        assert_eq!(rates.gb_second(), dec!(0.0000036667));
2185        assert_eq!(rates.storage_gb_month(), dec!(0.0811111030));
2186        assert_eq!(rates.snapshot_read_gb(), dec!(0.00155));
2187        assert_eq!(rates.snapshot_write_gb(), dec!(0.0038));
2188        assert_eq!(rates.minimum_retention(), Duration::from_secs(604_800));
2189        assert_eq!(rates.retrieved(), CalendarDate::from_ymd(2026, 8, 7));
2190        assert!(rates.source_url().contains("aws.amazon.com"));
2191        // Scale as well as value: `0.0038` and `0.00380000` are numerically equal to
2192        // `Decimal` but not the same transcription, and the point of this test is that
2193        // it is a transcription check.
2194        assert_eq!(rates.snapshot_write_gb().to_string(), "0.0038");
2195        assert_eq!(rates.storage_gb_month().to_string(), "0.0811111030");
2196    }
2197
2198    /// The storage rate is the hourly figure times a 730-hour month, asserted through
2199    /// [`HOURS_PER_MONTH`] rather than against a literal — the defect it replaced was
2200    /// a second month convention, not a typo.
2201    #[test]
2202    fn the_storage_rate_is_the_hourly_figure_times_a_seven_hundred_thirty_hour_month() {
2203        assert_eq!(SECONDS_PER_MONTH / dec!(3600), HOURS_PER_MONTH);
2204        assert_eq!(HOURS_PER_MONTH, dec!(730));
2205        assert_eq!(rates().storage_gb_month(), dec!(0.0001111111) * dec!(730));
2206        assert_ne!(
2207            rates().storage_gb_month(),
2208            dec!(0.08),
2209            "the old hand-read value was 1.37% low"
2210        );
2211    }
2212
2213    /// Four arithmetic mistakes someone would otherwise make by analogy with Lambda
2214    /// Functions: one blended GB-second, a per-invoke charge, a rounded-up billing
2215    /// increment, and a free tier. `None` for the increment is "not published" —
2216    /// distinct from one second, which would overcharge every sub-second exec.
2217    #[test]
2218    fn the_documented_billing_facts_are_data_not_prose() {
2219        let rates = rates();
2220        assert!(rates.bills_vcpu_and_memory_separately());
2221        assert_eq!(rates.per_request(), Decimal::ZERO);
2222        assert!(!rates.free_tier());
2223        assert_eq!(rates.minimum_billing_increment(), None);
2224    }
2225
2226    // -- staleness (COST-7) ----------------------------------------------------
2227
2228    /// COST-7. A silently stale price is the same failure class as a silently stale
2229    /// schema, which this repo already fails CI on.
2230    #[test]
2231    fn a_rate_table_past_ninety_days_attaches_a_warning_to_the_report() {
2232        let rates = rates();
2233        let just_past = CalendarDate::from_ymd(2026, 11, 6);
2234        assert_eq!(just_past.days_since(rates.retrieved()), 91);
2235        assert!(rates.is_stale(just_past));
2236
2237        let report = run_report(
2238            SizeClass::Mib2048,
2239            &RunUsage {
2240                running: Some(DurationP::Measured(Duration::from_secs(60))),
2241                launched: false,
2242                ..RunUsage::default()
2243            },
2244            &rates,
2245            just_past,
2246            "run",
2247        )
2248        .expect("a stale table still prices");
2249        let warning = report
2250            .staleness()
2251            .expect("a report from a stale table carries the warning");
2252        assert!(warning.contains("91 days ago"), "{warning}");
2253        assert!(warning.contains("stale after 90"), "{warning}");
2254        // The URL travels with it: a figure copied out of a terminal loses everything
2255        // else, so the warning has to say what to re-read.
2256        assert!(warning.contains(rates.source_url()), "{warning}");
2257    }
2258
2259    /// The complement. A warning that fires on a fresh table is a warning everyone
2260    /// learns to filter, and then the stale case goes unseen too. Ninety days exactly
2261    /// is fresh — the Python's comparison is `>`, not `>=`.
2262    #[test]
2263    fn a_fresh_rate_table_is_silent_and_ninety_days_exactly_is_still_fresh() {
2264        let rates = rates();
2265        let ninety = CalendarDate::from_ymd(2026, 11, 5);
2266        assert_eq!(ninety.days_since(rates.retrieved()), 90);
2267        assert!(!rates.is_stale(ninety));
2268        assert_eq!(rates.staleness(ninety), None);
2269
2270        let report = running_report(
2271            SizeClass::Mib2048,
2272            DurationP::Measured(Duration::from_secs(60)),
2273        );
2274        assert_eq!(report.staleness(), None);
2275    }
2276
2277    // -- dates -----------------------------------------------------------------
2278
2279    /// The day-number algorithm against three known values, since every age in this
2280    /// module is a subtraction of two of them. A hand-rolled date is only acceptable
2281    /// if it is pinned.
2282    #[test]
2283    fn the_day_number_agrees_with_the_gregorian_calendar() {
2284        assert_eq!(CalendarDate::from_ymd(1970, 1, 1).day_number(), 0);
2285        // Both verified against Python's `datetime.date` in this session.
2286        assert_eq!(CalendarDate::from_ymd(2026, 8, 7).day_number(), 20_672);
2287        assert_eq!(CalendarDate::from_ymd(2024, 2, 29).day_number(), 19_782);
2288        assert_eq!(CalendarDate::from_ymd(1969, 12, 31).day_number(), -1);
2289        // A leap year's February really has 29 days in the arithmetic, not just in
2290        // the validator.
2291        assert_eq!(
2292            CalendarDate::from_ymd(2024, 3, 1).days_since(CalendarDate::from_ymd(2024, 2, 28)),
2293            2
2294        );
2295        assert_eq!(
2296            CalendarDate::from_ymd(2023, 3, 1).days_since(CalendarDate::from_ymd(2023, 2, 28)),
2297            1
2298        );
2299    }
2300
2301    /// The pinned literals really are calendar days. [`CalendarDate::from_ymd`] is
2302    /// `const` and therefore cannot validate, so this is where that is made good.
2303    #[test]
2304    fn the_pinned_dates_are_real_calendar_days() {
2305        for (year, month, day) in [(2026, 8, 7), (2026, 8, 8), (2026, 11, 5), (2026, 11, 6)] {
2306            CalendarDate::try_from_ymd(year, month, day)
2307                .unwrap_or_else(|err| panic!("{year}-{month}-{day} should be a real day: {err}"));
2308        }
2309    }
2310
2311    /// The S2 boundary refuses what the `const` constructor cannot: `2026-02-30`
2312    /// would otherwise produce March 2nd's day number and an age two days out.
2313    #[test]
2314    fn a_date_that_is_not_a_calendar_day_is_refused() {
2315        for (year, month, day) in [
2316            (2026, 2, 30),
2317            (2026, 13, 1),
2318            (2026, 0, 1),
2319            (2026, 1, 0),
2320            (2026, 1, 32),
2321            (2023, 2, 29),
2322        ] {
2323            let err = CalendarDate::try_from_ymd(year, month, day)
2324                .expect_err("only calendar days date a rate table");
2325            assert_eq!(err.kind(), ErrorKind::InvalidArg, "{year}-{month}-{day}");
2326        }
2327        assert!(
2328            CalendarDate::try_from_ymd(2024, 2, 29).is_ok(),
2329            "a leap day"
2330        );
2331    }
2332
2333    /// `Display` is ISO 8601 because that is what the Python's `retrieved.isoformat()`
2334    /// puts in the report header, and the two clients' output has to be diffable.
2335    #[test]
2336    fn a_date_renders_as_iso_eight_six_oh_one() {
2337        assert_eq!(CalendarDate::from_ymd(2026, 8, 7).to_string(), "2026-08-07");
2338        assert_eq!(
2339            CalendarDate::from_ymd(2026, 12, 31).to_string(),
2340            "2026-12-31"
2341        );
2342    }
2343
2344    // -- measured versus estimated (COST-1, COST-2) -----------------------------
2345
2346    /// COST-1, the runtime half. The compile-fail half is the doctests on
2347    /// [`DurationP`] — this is the part a `match` can see: the label is on the value,
2348    /// so a consumer can refuse to print an estimate as a receipt.
2349    #[test]
2350    fn a_duration_carries_how_it_is_known() {
2351        let timed = DurationP::Measured(Duration::from_secs(60));
2352        let planned = DurationP::Projected(Duration::from_secs(60));
2353        assert_eq!(timed.provenance(), Provenance::Measured);
2354        assert_eq!(planned.provenance(), Provenance::Projected);
2355        assert!(timed.is_measured());
2356        assert!(!planned.is_measured());
2357        // Same span, different claim. The two are not equal, so a set or a comparison
2358        // cannot silently treat a projection as a timing.
2359        assert_ne!(timed, planned);
2360        assert_eq!(timed.duration(), planned.duration());
2361        assert_eq!(timed.to_string(), "60s (measured)");
2362        assert_eq!(planned.to_string(), "60s (projected)");
2363    }
2364
2365    /// An inverted clock would silently render as a credit on the report. The Python
2366    /// checked this in `__post_init__`; here the check is at the f64 boundary, because
2367    /// past it a negative duration is not representable.
2368    #[test]
2369    fn a_negative_or_non_finite_duration_is_refused_at_the_float_boundary() {
2370        for seconds in [-1.0, -0.001, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2371            let err = DurationP::measured_secs_f64(seconds)
2372                .expect_err("only a finite non-negative float is a duration");
2373            assert_eq!(err.kind(), ErrorKind::InvalidArg, "{seconds}");
2374            assert!(
2375                err.to_string().contains("credit on the report"),
2376                "must name why: {err}"
2377            );
2378        }
2379        assert_eq!(
2380            DurationP::measured_secs_f64(60.5)
2381                .expect("a plain float is a duration")
2382                .seconds(),
2383            dec!(60.5)
2384        );
2385    }
2386
2387    /// COST-2's runtime half: the label is in `Display`, because a figure copied out of
2388    /// a terminal loses its type name.
2389    #[test]
2390    fn every_dollar_figure_renders_as_an_estimate() {
2391        assert_eq!(
2392            EstimatedUsd::new(dec!(1.23)).to_string(),
2393            "~$1.23 (estimated)"
2394        );
2395        let report = running_report(SizeClass::Mib2048, DurationP::Measured(MONTH));
2396        let text = report.render();
2397        assert!(text.contains("estimates derived from published"), "{text}");
2398        assert!(text.contains("only Cost Explorer knows the bill"), "{text}");
2399        // The retrieval date travels too, so a pasted figure can be dated.
2400        assert!(text.contains("2026-08-07"), "{text}");
2401    }
2402
2403    /// The asymmetry is the point: we can time a phase exactly and still only infer its
2404    /// price, so there is no `ActualUsd` for a measured duration to produce.
2405    #[test]
2406    fn a_measured_second_still_yields_an_estimated_dollar() {
2407        let report = running_report(
2408            SizeClass::Mib2048,
2409            DurationP::Measured(Duration::from_secs(3600)),
2410        );
2411        assert!(report.fully_measured());
2412        assert!(
2413            report
2414                .items()
2415                .iter()
2416                .all(|item| item.amount.estimate().is_some())
2417        );
2418        assert!(report.total().to_string().contains("estimated"));
2419    }
2420
2421    /// The distinction survives into the report rather than stopping at the duration,
2422    /// which is what lets a consumer refuse to print an estimate as a receipt.
2423    #[test]
2424    fn an_estimate_is_never_fully_measured_and_a_timed_run_is() {
2425        let projected = estimate_run(
2426            SizeClass::Mib2048,
2427            &PlanUsage {
2428                running_seconds: 3600.0,
2429                ..PlanUsage::default()
2430            },
2431            &rates(),
2432            fresh_day(),
2433            "estimate",
2434        )
2435        .expect("a plan prices");
2436        assert!(!projected.fully_measured());
2437        assert!(
2438            running_report(
2439                SizeClass::Mib2048,
2440                DurationP::Measured(Duration::from_secs(3600))
2441            )
2442            .fully_measured()
2443        );
2444    }
2445
2446    /// Display precision: sub-cent figures need six places and monthly ones need two,
2447    /// and one fixed precision cannot show both without lying or shouting. Half-even at
2448    /// the boundary, matching Python's `quantize` default, so the two clients render
2449    /// the same figure identically.
2450    #[test]
2451    fn a_sub_cent_figure_and_a_monthly_one_each_render_readably() {
2452        assert_eq!(
2453            EstimatedUsd::new(dec!(0.0000276944)).amount_string(),
2454            "0.000028"
2455        );
2456        assert_eq!(EstimatedUsd::new(dec!(92.0512345)).amount_string(), "92.05");
2457        // Exactly one dollar crosses to two places: `>= 1`, as the Python has it.
2458        assert_eq!(EstimatedUsd::new(Decimal::ONE).amount_string(), "1.00");
2459        assert_eq!(
2460            EstimatedUsd::new(dec!(0.999999)).amount_string(),
2461            "0.999999"
2462        );
2463        // Half-even, not half-up: 0.125 rounds down to 0.12 and 0.135 rounds up to
2464        // 0.14. `Decimal::rescale` would answer 0.13 for the first.
2465        assert_eq!(EstimatedUsd::new(dec!(1.125)).amount_string(), "1.12");
2466        assert_eq!(EstimatedUsd::new(dec!(1.135)).amount_string(), "1.14");
2467        // Trailing zeros are kept: they say the figure was rounded to the cent rather
2468        // than happening to land there.
2469        assert_eq!(EstimatedUsd::new(dec!(2)).amount_string(), "2.00");
2470    }
2471
2472    // -- unknown is not zero (COST-3, COST-4) ----------------------------------
2473
2474    /// COST-3. AWS does not publish whether the server-side build is billed as
2475    /// compute. The build starts a real MicroVM, so reporting $0.00 would understate
2476    /// the run in the direction that flatters us.
2477    #[test]
2478    fn the_image_build_is_unpriced_rather_than_free() {
2479        let report = run_report(
2480            SizeClass::Mib2048,
2481            &RunUsage {
2482                image_gb: Some(2.0),
2483                image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2484                ..RunUsage::launched()
2485            },
2486            &rates(),
2487            fresh_day(),
2488            "run",
2489        )
2490        .expect("a build prices as unpriced");
2491        let build: Vec<&LineItem> = report.by_phase(CostPhase::ImageBuild).collect();
2492        assert_eq!(build.len(), 1);
2493        let reason = build[0]
2494            .amount
2495            .unpriced_reason()
2496            .expect("the build has no published rate");
2497        assert!(reason.contains("does not publish"), "{reason}");
2498        assert_eq!(
2499            build[0].line, None,
2500            "no billing line exists to attribute it to"
2501        );
2502        // The distinction this requirement is about: not zero dollars.
2503        assert_eq!(build[0].amount.estimate(), None);
2504        assert_ne!(
2505            build[0].amount,
2506            Amount::Estimated(EstimatedUsd::ZERO),
2507            "unpriced is a claim about the documentation, zero is a claim about the bill"
2508        );
2509        assert_eq!(build[0].amount.kind(), "unpriced");
2510    }
2511
2512    /// COST-4. A plain sum cannot express "everything priceable, plus a build whose
2513    /// price AWS withholds", and the natural way to force it to is to drop the line.
2514    /// The `AtLeast` variant is what makes dropping it unrepresentable.
2515    #[test]
2516    fn an_unpriced_line_makes_the_total_a_lower_bound_that_names_it() {
2517        let report = run_report(
2518            SizeClass::Mib2048,
2519            &RunUsage {
2520                image_gb: Some(2.0),
2521                image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2522                ..RunUsage::launched()
2523            },
2524            &rates(),
2525            fresh_day(),
2526            "run",
2527        )
2528        .expect("a build prices");
2529        assert!(!report.is_complete());
2530        let total = report.total();
2531        assert!(total.is_lower_bound());
2532        assert!(matches!(total, Total::AtLeast { .. }));
2533        // The reasons are named, not merely counted (COST-4's "naming its unpriced
2534        // line items").
2535        assert_eq!(total.unpriced_reasons().len(), 1);
2536        assert!(total.unpriced_reasons()[0].contains("does not publish"));
2537        let rendered = total.to_string();
2538        assert!(rendered.starts_with("at least "), "{rendered}");
2539        assert!(rendered.contains("1 unpriced"), "{rendered}");
2540
2541        // The floor is the sum of the priced lines and nothing else: the unpriced line
2542        // is neither summed as zero nor allowed to poison the figure.
2543        let expected: EstimatedUsd = report
2544            .priced()
2545            .map(|item| item.amount.estimate().expect("priced"))
2546            .sum();
2547        assert_eq!(total.floor(), expected);
2548    }
2549
2550    /// The rendered lower bound is the oracle's string, character for character.
2551    ///
2552    /// The Python is the contract here and the two divergences it catches are the ones a
2553    /// re-derivation would not: the parenthetical names **phases** where the Rust used to
2554    /// join reasons — a hundred-and-ninety-character sentence about what AWS does not
2555    /// publish, in the field a reader scans for "which phase" — and the separator is
2556    /// `", "` rather than `"; "`.
2557    ///
2558    /// The expected string is copied from the oracle rather than assembled here. What its
2559    /// `run_report` printed for a 2048 MiB size with 3600 s running, a 300 s measured image
2560    /// build, and a 2 GB image:
2561    ///
2562    /// ```text
2563    /// at least ~$0.166533 (estimated), plus 1 unpriced (image-build)
2564    /// ```
2565    ///
2566    /// A transcript rather than a command: that client was deleted once this one had driven
2567    /// the live suite green, and git history is where the code behind the figure lives.
2568    ///
2569    /// **Falsification** — restore `unpriced.join("; ")` over the reasons in
2570    /// [`Total`]'s `Display` and this is red on the whole parenthetical while every
2571    /// figure, count, and `isLowerBound` still agrees. Verified.
2572    #[test]
2573    fn the_rendered_lower_bound_is_the_oracles_string_verbatim() {
2574        let report = run_report(
2575            SizeClass::Mib2048,
2576            &RunUsage {
2577                running: Some(DurationP::Measured(Duration::from_secs(3600))),
2578                image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2579                image_gb: Some(2.0),
2580                ..RunUsage::launched()
2581            },
2582            &rates(),
2583            fresh_day(),
2584            "run",
2585        )
2586        .expect("a build prices");
2587        assert_eq!(
2588            report.total().to_string(),
2589            "at least ~$0.166533 (estimated), plus 1 unpriced (image-build)"
2590        );
2591
2592        // Two unpriced lines in the same phase name it once, and two phases read sorted
2593        // rather than in report order — `sorted({...})` in the oracle is a set.
2594        let amounts = [
2595            (CostPhase::Running, Amount::unpriced("second")),
2596            (CostPhase::ImageBuild, Amount::unpriced("first")),
2597            (CostPhase::ImageBuild, Amount::unpriced("also first")),
2598        ];
2599        let total = Total::of(amounts.iter().map(|(phase, amount)| (*phase, amount)));
2600        assert_eq!(total.unpriced_phase_names(), ["image-build", "running"]);
2601        assert_eq!(
2602            total.to_string(),
2603            "at least ~$0.000000 (estimated), plus 3 unpriced (image-build, running)",
2604            "three lines, two names — the count is lines and the parenthetical is phases"
2605        );
2606        // The reasons are still reachable, in report order, for the consumer that wants
2607        // the paragraph the total has no room for.
2608        assert_eq!(total.unpriced_reasons(), ["second", "first", "also first"]);
2609    }
2610
2611    /// **Every phase round-trips through its own spelling**, and nothing else parses.
2612    ///
2613    /// The forward direction is the one that matters for the bindings: `by_phase("running")`
2614    /// has to find the same variant the report attributed the line to, so `from_str` must be
2615    /// the exact inverse of `as_str` for all seven. The reverse — an unknown spelling — is
2616    /// refused *with the whole list*, because the failure this replaces was a Python caller
2617    /// typing `"suspending"` and getting an empty result set back rather than an error.
2618    ///
2619    /// `ALL` is asserted to be complete by exhaustion rather than by a length check: the
2620    /// `match` below has no wildcard arm, so adding a variant to [`CostPhase`] fails to
2621    /// compile here until it is added to `ALL` too. A `len() == 7` assertion would only have
2622    /// caught the same mistake after someone changed the number.
2623    ///
2624    /// **Falsification** — drop `CostPhase::Suspend` from `ALL` and the exhaustive match
2625    /// leaves it unreachable, so the round-trip assertion goes red on `"suspend"`; return
2626    /// `Ok(CostPhase::Running)` for an unknown string and the refusal assertion goes red.
2627    /// Verified; see the report at the end of this task.
2628    #[test]
2629    fn every_phase_parses_back_from_its_display_and_nothing_else_does() {
2630        for phase in CostPhase::ALL {
2631            // Both spellings, because `Display` pads and `as_str` does not — a `from_str`
2632            // written against the padded form would fail on every column-formatted string a
2633            // caller copied out of a report.
2634            assert_eq!(
2635                phase.as_str().parse::<CostPhase>().expect("a known phase"),
2636                phase,
2637                "{} did not round-trip through as_str",
2638                phase.as_str()
2639            );
2640            assert_eq!(
2641                phase
2642                    .to_string()
2643                    .parse::<CostPhase>()
2644                    .expect("a known phase"),
2645                phase,
2646                "{phase} did not round-trip through Display"
2647            );
2648            // And the exhaustive match: a variant added to the enum but not to `ALL` fails
2649            // to compile at this arm rather than silently going unparseable.
2650            match phase {
2651                CostPhase::ImageBuild
2652                | CostPhase::ImageStorage
2653                | CostPhase::Launch
2654                | CostPhase::Running
2655                | CostPhase::Suspended
2656                | CostPhase::Suspend
2657                | CostPhase::Resume => {}
2658            }
2659        }
2660        assert_eq!(
2661            CostPhase::ALL.len(),
2662            7,
2663            "the seven phases the billing model has"
2664        );
2665
2666        // The near-misses a caller actually types: a lifecycle state that is not a cost
2667        // phase, the underscore spelling, and the empty string.
2668        for wrong in ["suspending", "image_build", "RUNNING", "", "total"] {
2669            let error = wrong
2670                .parse::<CostPhase>()
2671                .expect_err("only the seven spellings parse");
2672            assert_eq!(error.kind(), ErrorKind::InvalidArg);
2673            let message = error.to_string();
2674            for phase in CostPhase::ALL {
2675                assert!(
2676                    message.contains(phase.as_str()),
2677                    "the refusal has to offer every phase so a typo is self-correcting, \
2678                     {phase} missing from: {message}"
2679                );
2680            }
2681        }
2682    }
2683
2684    /// Every rendered line's amount starts at the same column.
2685    ///
2686    /// The plain-text report is a table, and the phase column is what aligns it. `{:<14}`
2687    /// in [`LineItem`]'s `Display` is not enough on its own: a `Display` impl written as
2688    /// `f.write_str(...)` ignores the formatter's width outright, so the format string
2689    /// reads as padded while the output is ragged — `image-build` and `launch` differ by
2690    /// six characters and the dollar figures walk. The oracle's own render puts the
2691    /// amount at column 44 on all seven lines of this same report, checked with
2692    /// `line.index('~$')` on `cost.py`'s `CostReport.render()`.
2693    ///
2694    /// The assertion is that the columns *agree* rather than that they equal 44, because
2695    /// the widths belong to `LineItem` and a deliberate change there should not have to
2696    /// come here — but a phase that stops padding is not a deliberate change.
2697    ///
2698    /// **Falsification** — put `f.write_str(self.as_str())` back in [`CostPhase`]'s
2699    /// `Display` and this is red with two distinct offsets. Verified.
2700    #[test]
2701    fn every_rendered_line_puts_its_amount_in_the_same_column() {
2702        let report = run_report(
2703            SizeClass::Mib2048,
2704            &RunUsage {
2705                running: Some(DurationP::Measured(Duration::from_secs(3600))),
2706                image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2707                image_gb: Some(2.0),
2708                suspend_resume_cycles: 1,
2709                ..RunUsage::launched()
2710            },
2711            &rates(),
2712            fresh_day(),
2713            "run",
2714        )
2715        .expect("a report");
2716        let text = report.render();
2717        // The phases whose names differ most: `image-storage` is thirteen characters and
2718        // `launch` is six, so an unpadded phase column shows up as a seven-column shift.
2719        let offsets: Vec<(usize, &str)> = text
2720            .lines()
2721            .filter(|line| line.starts_with("  "))
2722            .map(|line| {
2723                let amount = line
2724                    .find("~$")
2725                    .or_else(|| line.find("unpriced"))
2726                    .unwrap_or_else(|| panic!("every line has an amount: {line}"));
2727                (amount, line)
2728            })
2729            .collect();
2730        assert!(offsets.len() >= 7, "{text}");
2731        let (first, first_line) = offsets[0];
2732        for (offset, line) in &offsets {
2733            assert_eq!(
2734                *offset, first,
2735                "the amount column has to agree across lines:\n{first_line}\n{line}"
2736            );
2737        }
2738        // And the phase is genuinely padded rather than the widths happening to cancel.
2739        assert_eq!(
2740            format!("{:<14}|", CostPhase::Launch),
2741            "launch        |",
2742            "a phase in a width-bearing format slot has to pad"
2743        );
2744    }
2745
2746    /// A lower bound that will not say what it is missing is not a value [`Total`]
2747    /// holds: an empty reason list routes to [`Total::Exact`].
2748    #[test]
2749    fn a_total_over_only_priced_lines_is_exact_and_names_nothing() {
2750        let amounts = [Amount::estimated(dec!(1)), Amount::estimated(dec!(2))];
2751        let total = Total::of(amounts.iter().map(|amount| (CostPhase::Running, amount)));
2752        assert_eq!(total, Total::Exact(EstimatedUsd::new(dec!(3))));
2753        assert!(!total.is_lower_bound());
2754        assert!(total.unpriced_reasons().is_empty());
2755        assert_eq!(total.to_string(), "~$3.00 (estimated)");
2756        // The degenerate case: no lines at all is exactly zero, not a lower bound.
2757        assert_eq!(
2758            Total::of(std::iter::empty()),
2759            Total::Exact(EstimatedUsd::ZERO)
2760        );
2761    }
2762
2763    /// The complement of the build test: a run against an existing image pays no build,
2764    /// so incompleteness is a property of the phases present rather than a permanent
2765    /// disclaimer nobody reads.
2766    #[test]
2767    fn a_report_with_no_image_is_complete() {
2768        let report = running_report(
2769            SizeClass::Mib2048,
2770            DurationP::Measured(Duration::from_secs(60)),
2771        );
2772        assert!(report.is_complete());
2773        assert!(!report.total().is_lower_bound());
2774        assert!(matches!(report.total(), Total::Exact(_)));
2775    }
2776
2777    // -- per-phase attribution (COST-5, COST-8) ---------------------------------
2778
2779    /// Two entries rather than one blended GB-second, because that is how the pricing
2780    /// page prices them and a blended figure cannot be reconciled against a Cost
2781    /// Explorer breakdown that keeps them apart.
2782    #[test]
2783    fn running_bills_vcpu_and_memory_as_two_separate_lines() {
2784        let report = running_report(
2785            SizeClass::Mib2048,
2786            DurationP::Measured(Duration::from_secs(3600)),
2787        );
2788        let lines: Vec<Option<BillingLine>> = report
2789            .by_phase(CostPhase::Running)
2790            .map(|item| item.line)
2791            .collect();
2792        assert_eq!(lines, [Some(BillingLine::Vcpu), Some(BillingLine::Memory)]);
2793    }
2794
2795    /// COST-5. `sizing` locks that billing follows the baseline; this locks that cost
2796    /// obeys it. The 2 GB class reports 8 GB in the guest, so reading the peak would
2797    /// overstate the memory line exactly 4x — and the assertion below is against the
2798    /// baseline figure spelled out, so a peak-reading implementation is off by that 4x
2799    /// rather than merely different.
2800    #[test]
2801    fn running_bills_the_baseline_and_never_the_peak() {
2802        let size = SizeClass::Mib2048;
2803        let report = running_report(size, DurationP::Measured(Duration::from_secs(3600)));
2804        let rates = rates();
2805
2806        let memory: Vec<&LineItem> = report
2807            .by_phase(CostPhase::Running)
2808            .filter(|item| item.line == Some(BillingLine::Memory))
2809            .collect();
2810        assert_eq!(
2811            memory[0].amount.estimate().expect("priced").amount(),
2812            dec!(2) * dec!(3600) * rates.gb_second()
2813        );
2814        let vcpu: Vec<&LineItem> = report
2815            .by_phase(CostPhase::Running)
2816            .filter(|item| item.line == Some(BillingLine::Vcpu))
2817            .collect();
2818        assert_eq!(
2819            vcpu[0].amount.estimate().expect("priced").amount(),
2820            dec!(1) * dec!(3600) * rates.vcpu_second()
2821        );
2822
2823        // Named explicitly, so the failure message says which figure was read: the
2824        // peak is 8 GB / 4 vCPU for this class.
2825        assert_eq!(memory[0].quantity, dec!(2) * dec!(3600));
2826        assert_ne!(
2827            memory[0].quantity,
2828            gb_decimal(size.peak_gb()).expect("a documented peak") * dec!(3600),
2829            "the memory line must read baseline_gb, not peak_gb"
2830        );
2831        assert_ne!(
2832            vcpu[0].quantity,
2833            gb_decimal(size.peak_vcpu()).expect("a documented peak") * dec!(3600),
2834            "the vcpu line must read baseline_vcpu, not peak_vcpu"
2835        );
2836    }
2837
2838    /// COST-5 across the whole table, since one class cannot distinguish a baseline
2839    /// read from a peak read that happens to agree. No shipped class's baseline equals
2840    /// another's peak in a way that would let a peak-reading implementation pass.
2841    #[test]
2842    fn every_size_class_bills_its_own_baseline() {
2843        let rates = rates();
2844        for size in SizeClass::ALL {
2845            let report = running_report(size, DurationP::Measured(Duration::from_secs(1)));
2846            let memory: Vec<&LineItem> = report
2847                .by_phase(CostPhase::Running)
2848                .filter(|item| item.line == Some(BillingLine::Memory))
2849                .collect();
2850            let expected = gb_decimal(size.baseline_gb()).expect("a documented baseline");
2851            assert_eq!(memory[0].quantity, expected, "{size:?}");
2852            assert_eq!(
2853                memory[0].amount.estimate().expect("priced").amount(),
2854                expected * rates.gb_second(),
2855                "{size:?}"
2856            );
2857        }
2858    }
2859
2860    /// `docs/PLATFORM.md`: "roughly $100 a month to leave the same VM running at
2861    /// baseline". An order-of-magnitude anchor on the compute rates, independent of the
2862    /// exact rate literals above — the two would have to be wrong together.
2863    #[test]
2864    fn a_month_of_running_costs_about_a_hundred_dollars() {
2865        let report = running_report(SizeClass::Mib2048, DurationP::Projected(MONTH));
2866        let total = report.total().floor().amount();
2867        assert!(
2868            dec!(80) < total && total < dec!(120),
2869            "a month at baseline should be roughly $100, got {total}"
2870        );
2871    }
2872
2873    /// A suspended guest is frozen rather than stopped, so there is no compute line at
2874    /// all — not a compute line multiplied by zero, which would reappear the moment
2875    /// someone changed how a duration is derived.
2876    #[test]
2877    fn a_suspended_vm_pays_storage_and_no_compute() {
2878        let report = run_report(
2879            SizeClass::Mib2048,
2880            &RunUsage {
2881                suspended: Some(DurationP::Measured(MONTH)),
2882                launched: false,
2883                ..RunUsage::default()
2884            },
2885            &rates(),
2886            fresh_day(),
2887            "suspended",
2888        )
2889        .expect("a suspended hold prices");
2890        let lines: Vec<Option<BillingLine>> = report
2891            .by_phase(CostPhase::Suspended)
2892            .map(|item| item.line)
2893            .collect();
2894        assert_eq!(lines, [Some(BillingLine::SnapshotStorage)]);
2895        assert!(
2896            !report
2897                .items()
2898                .iter()
2899                .any(|item| item.line == Some(BillingLine::Vcpu)
2900                    || item.line == Some(BillingLine::Memory)),
2901            "a frozen VM has no compute line at all"
2902        );
2903        // "about $0.16 a month" for a suspended 2 GB VM.
2904        let total = report.total().floor().amount();
2905        assert!(dec!(0.10) < total && total < dec!(0.25), "{total}");
2906    }
2907
2908    /// COST-8. The line item that dominates a create-and-destroy suite.
2909    /// `docs/PLATFORM.md`: a 2 GB image deleted sixty seconds after creation still bills
2910    /// about a week, "roughly four cents". Not applying the minimum understates the
2911    /// floor by four orders of magnitude and makes the compute look like the cost
2912    /// driver.
2913    #[test]
2914    fn image_storage_bills_the_one_week_minimum_for_a_sixty_second_image() {
2915        let rates = rates();
2916        let report = run_report(
2917            SizeClass::Mib2048,
2918            &RunUsage {
2919                image_gb: Some(2.0),
2920                image_build: Some(DurationP::Measured(Duration::from_secs(300))),
2921                image_retained: Some(DurationP::Measured(Duration::from_secs(60))),
2922                launched: false,
2923                ..RunUsage::default()
2924            },
2925            &rates,
2926            fresh_day(),
2927            "build",
2928        )
2929        .expect("a held image prices");
2930        let week = dec!(604800);
2931        assert_eq!(
2932            priced(&report, CostPhase::ImageStorage),
2933            dec!(2) * week / SECONDS_PER_MONTH * rates.storage_gb_month()
2934        );
2935        let amount = priced(&report, CostPhase::ImageStorage);
2936        assert!(
2937            dec!(0.03) < amount && amount < dec!(0.05),
2938            "roughly four cents, got {amount}"
2939        );
2940        // The floor must be visible, not just applied: a reader checking the quantity
2941        // column against the rate table needs to know why it says a week.
2942        let storage: Vec<&LineItem> = report.by_phase(CostPhase::ImageStorage).collect();
2943        assert!(
2944            storage[0].note.contains("minimum retention"),
2945            "{}",
2946            storage[0].note
2947        );
2948        assert!(storage[0].note.contains("604800s"), "{}", storage[0].note);
2949        // And the duration on the line is still the *measured* sixty seconds — the
2950        // floor changes what is billed, not what was observed.
2951        assert_eq!(
2952            storage[0].duration,
2953            Some(DurationP::Measured(Duration::from_secs(60)))
2954        );
2955    }
2956
2957    /// The note's day count is the rate row's own, not a division written beside the
2958    /// message.
2959    ///
2960    /// The two halves are one requirement: `minimum_retention_days` reports what
2961    /// `minimum_retention` holds, and the note quotes *that* rather than a second
2962    /// convention. Asserted against a table whose floor is deliberately **not** a week,
2963    /// which is the only input that separates the field from the literal — a hand-rolled
2964    /// `as_secs() / 86_400` at the call site passes the seven-day case and then keeps
2965    /// saying "7-day" here.
2966    ///
2967    /// **Falsification** — put `rates.minimum_retention().as_secs() / 86_400` back in
2968    /// `storage_line` and this stays green; hard-code either `7` or the week's seconds and
2969    /// it goes red naming the wrong day count. Verified: replacing the accessor's body
2970    /// with `7` fails the first assertion, and writing `"; billed 7-day minimum retention
2971    /// ({floor}s) instead"` fails the note assertion with `7-day` against a 14-day row.
2972    #[test]
2973    fn the_retention_note_reads_its_day_count_off_the_rate_row() {
2974        let week = rates();
2975        assert_eq!(week.minimum_retention_days(), 7);
2976        assert_eq!(
2977            week.minimum_retention(),
2978            Duration::from_secs(week.minimum_retention_days() * 24 * 60 * 60),
2979            "the days accessor and the duration must describe the same window"
2980        );
2981
2982        // A fortnight, which no rate table ships — the point is that nothing but the
2983        // field decides what the note says.
2984        let mut fortnight = week.clone();
2985        fortnight.minimum_retention = Duration::from_secs(14 * 24 * 60 * 60);
2986        assert_eq!(fortnight.minimum_retention_days(), 14);
2987
2988        let item = storage_line(
2989            CostPhase::ImageStorage,
2990            dec!(2),
2991            DurationP::Measured(Duration::from_secs(60)),
2992            &fortnight,
2993        )
2994        .expect("a floored hold prices");
2995        assert!(
2996            item.note.contains("14-day minimum retention"),
2997            "the note must quote the row's own window: {}",
2998            item.note
2999        );
3000        assert!(
3001            !item.note.contains("7-day"),
3002            "a second day-count convention survived in the message: {}",
3003            item.note
3004        );
3005    }
3006
3007    /// The floor is a floor, not a flat fee. A month-long hold that billed one week
3008    /// would understate a warm pool's storage by more than 4x.
3009    #[test]
3010    fn a_long_hold_bills_actual_time_rather_than_the_minimum() {
3011        let rates = rates();
3012        let report = run_report(
3013            SizeClass::Mib2048,
3014            &RunUsage {
3015                image_gb: Some(2.0),
3016                image_retained: Some(DurationP::Measured(MONTH)),
3017                launched: false,
3018                ..RunUsage::default()
3019            },
3020            &rates,
3021            fresh_day(),
3022            "build",
3023        )
3024        .expect("a long hold prices");
3025        assert_eq!(
3026            priced(&report, CostPhase::ImageStorage),
3027            dec!(2) * rates.storage_gb_month(),
3028        );
3029        let storage: Vec<&LineItem> = report.by_phase(CostPhase::ImageStorage).collect();
3030        assert!(
3031            !storage[0].note.contains("minimum retention"),
3032            "the note must not claim a floor that was not applied: {}",
3033            storage[0].note
3034        );
3035    }
3036
3037    /// The floor applies to a suspend snapshot too, because the minimum is on the rate
3038    /// row rather than on the image phase. `docs/PLATFORM.md` demonstrates it only for
3039    /// images, which is what the note distinguishes.
3040    #[test]
3041    fn a_briefly_held_suspend_snapshot_bills_the_same_floor() {
3042        let report = run_report(
3043            SizeClass::Mib2048,
3044            &RunUsage {
3045                suspended: Some(DurationP::Measured(Duration::from_secs(30))),
3046                launched: false,
3047                ..RunUsage::default()
3048            },
3049            &rates(),
3050            fresh_day(),
3051            "suspended",
3052        )
3053        .expect("a brief suspension prices");
3054        let items: Vec<&LineItem> = report.by_phase(CostPhase::Suspended).collect();
3055        assert!(
3056            items[0].note.contains("minimum retention"),
3057            "{}",
3058            items[0].note
3059        );
3060        assert_eq!(
3061            priced(&report, CostPhase::Suspended),
3062            dec!(2) * dec!(604800) / SECONDS_PER_MONTH * rates().storage_gb_month(),
3063        );
3064    }
3065
3066    /// Asymmetric rates — the write is ~2.5x the read — so a cycle costed with one rate
3067    /// twice is wrong in whichever direction it picked.
3068    #[test]
3069    fn a_suspend_resume_cycle_bills_a_write_and_a_read() {
3070        let rates = rates();
3071        let report = run_report(
3072            SizeClass::Mib2048,
3073            &RunUsage {
3074                suspend_resume_cycles: 1,
3075                launched: false,
3076                ..RunUsage::default()
3077            },
3078            &rates,
3079            fresh_day(),
3080            "cycle",
3081        )
3082        .expect("a cycle prices");
3083        assert_eq!(
3084            priced(&report, CostPhase::Suspend),
3085            dec!(2) * rates.snapshot_write_gb()
3086        );
3087        assert_eq!(
3088            priced(&report, CostPhase::Resume),
3089            dec!(2) * rates.snapshot_read_gb()
3090        );
3091        assert_ne!(
3092            rates.snapshot_write_gb(),
3093            rates.snapshot_read_gb(),
3094            "the two rates differ, which is why the two lines must not share one"
3095        );
3096        // "about $0.011 for a 2 GB VM" per cycle.
3097        let total = report.total().floor().amount();
3098        assert!(dec!(0.010) < total && total < dec!(0.012), "{total}");
3099    }
3100
3101    /// The churn argument only works if churn actually accumulates.
3102    #[test]
3103    fn cycles_scale_the_transition_cost() {
3104        let cycles_cost = |count: u32| {
3105            run_report(
3106                SizeClass::Mib2048,
3107                &RunUsage {
3108                    suspend_resume_cycles: count,
3109                    launched: false,
3110                    ..RunUsage::default()
3111                },
3112                &rates(),
3113                fresh_day(),
3114                "cycle",
3115            )
3116            .expect("a cycle prices")
3117            .total()
3118            .floor()
3119            .amount()
3120        };
3121        assert_eq!(cycles_cost(10), cycles_cost(1) * dec!(10));
3122        // Zero cycles is zero lines, not a zero-priced line: nothing happened.
3123        assert_eq!(cycles_cost(0), Decimal::ZERO);
3124    }
3125
3126    /// A smaller baseline is a real cost lever, which is what makes the CLI's
3127    /// adequate-over-cheap default a deliberate trade rather than an oversight. 512 MiB
3128    /// to 8192 MiB is 16x on both vCPU and memory.
3129    #[test]
3130    fn a_smaller_baseline_is_a_real_cost_lever() {
3131        let small = running_report(SizeClass::Mib512, DurationP::Projected(MONTH))
3132            .total()
3133            .floor()
3134            .amount();
3135        let large = running_report(SizeClass::Mib8192, DurationP::Projected(MONTH))
3136            .total()
3137            .floor()
3138            .amount();
3139        assert_eq!(large, small * dec!(16));
3140    }
3141
3142    // -- estimate mode (COST-10) ------------------------------------------------
3143
3144    /// COST-10. An estimate is spent before the money is, so nothing in it can claim to
3145    /// have been timed.
3146    #[test]
3147    fn an_estimate_labels_every_duration_projected() {
3148        let report = estimate_run(
3149            SizeClass::Mib2048,
3150            &PlanUsage {
3151                running_seconds: 3600.0,
3152                suspended_seconds: 600.0,
3153                image_gb: Some(2.0),
3154                ..PlanUsage::launched()
3155            },
3156            &rates(),
3157            fresh_day(),
3158            "estimate",
3159        )
3160        .expect("a plan prices");
3161        let durations: Vec<DurationP> = report
3162            .items()
3163            .iter()
3164            .filter_map(|item| item.duration)
3165            .collect();
3166        assert!(
3167            !durations.is_empty(),
3168            "the estimate must attribute durations, not just totals"
3169        );
3170        for duration in durations {
3171            assert_eq!(
3172                duration.provenance(),
3173                Provenance::Projected,
3174                "every duration in a plan is projected"
3175            );
3176        }
3177        assert!(!report.fully_measured());
3178    }
3179
3180    /// Same rates, same phases: only the labels differ. If the two paths could disagree
3181    /// numerically, the label would be hiding a second implementation.
3182    #[test]
3183    fn an_estimate_and_a_measured_run_agree_on_the_arithmetic() {
3184        let projected = estimate_run(
3185            SizeClass::Mib2048,
3186            &PlanUsage {
3187                running_seconds: 3600.0,
3188                ..PlanUsage::default()
3189            },
3190            &rates(),
3191            fresh_day(),
3192            "estimate",
3193        )
3194        .expect("a plan prices");
3195        let measured = running_report(
3196            SizeClass::Mib2048,
3197            DurationP::Measured(Duration::from_secs(3600)),
3198        );
3199        assert_eq!(projected.total(), measured.total());
3200        // The line items agree on everything except the label, which is the claim.
3201        let strip = |report: &CostReport| -> Vec<(CostPhase, Option<BillingLine>, Decimal)> {
3202            report
3203                .items()
3204                .iter()
3205                .map(|item| (item.phase, item.line, item.quantity))
3206                .collect()
3207        };
3208        assert_eq!(strip(&projected), strip(&measured));
3209        assert!(!projected.fully_measured() && measured.fully_measured());
3210    }
3211
3212    /// A plan's fields are seconds, so there is no field a `Measured` duration could be
3213    /// written into — and a zero-length phase is an absent phase rather than a line
3214    /// claiming a measurement nobody took.
3215    #[test]
3216    fn a_plan_with_no_phases_has_no_durations_at_all() {
3217        let report = estimate_run(
3218            SizeClass::Mib2048,
3219            &PlanUsage::default(),
3220            &rates(),
3221            fresh_day(),
3222            "estimate",
3223        )
3224        .expect("an empty plan prices");
3225        assert!(report.items().is_empty());
3226        assert_eq!(report.total(), Total::Exact(EstimatedUsd::ZERO));
3227        // `fully_measured` is false for a report with no durations, not vacuously true
3228        // — an empty report must not read as a receipt.
3229        assert!(!report.fully_measured());
3230    }
3231
3232    /// An image on a plan still drags the unpriced build line in, because an image that
3233    /// has storage cost has to be built and the build's price is unknown. So a
3234    /// create-and-destroy estimate is never complete either.
3235    #[test]
3236    fn an_estimate_with_an_image_is_a_lower_bound_too() {
3237        let report = estimate_run(
3238            SizeClass::Mib2048,
3239            &PlanUsage {
3240                image_gb: Some(2.0),
3241                ..PlanUsage::launched()
3242            },
3243            &rates(),
3244            fresh_day(),
3245            "estimate",
3246        )
3247        .expect("a plan with an image prices");
3248        assert!(!report.is_complete());
3249        assert!(report.total().is_lower_bound());
3250        // The build line has no duration: a plan has not built anything, so there is
3251        // nothing to have timed, and inventing a projected build time would be a
3252        // number with no source.
3253        let build: Vec<&LineItem> = report.by_phase(CostPhase::ImageBuild).collect();
3254        assert_eq!(build[0].duration, None);
3255        assert_eq!(build[0].unit, "seconds (untimed)");
3256    }
3257
3258    /// A cost figure for a size the platform would refuse never gets produced — it would
3259    /// look like an answer. The refusal is `sizing`'s (TRAP-10) and this is the seam
3260    /// showing it is reachable from here.
3261    #[test]
3262    fn an_off_table_baseline_never_reaches_a_cost_figure() {
3263        let err =
3264            SizeClass::from_baseline_mib(1500).expect_err("1500 MiB is not a documented baseline");
3265        assert_eq!(err.kind(), ErrorKind::InvalidArg);
3266        assert!(
3267            err.to_string()
3268                .contains("not a documented size class baseline")
3269        );
3270        // And the accepted case really does select the class the caller named.
3271        let size = SizeClass::from_baseline_mib(1024).expect("1024 MiB is documented");
3272        assert_eq!(size.baseline_gb(), 1.0);
3273    }
3274
3275    /// A size a caller supplies as a float still has to be a number. The one f64
3276    /// boundary is fallible for this reason (COST-6).
3277    #[test]
3278    fn a_non_finite_or_negative_size_is_refused_at_the_float_boundary() {
3279        for gb in [-1.0, f64::NAN, f64::INFINITY] {
3280            let err = gb_decimal(gb).expect_err("only a finite non-negative size prices");
3281            assert_eq!(err.kind(), ErrorKind::InvalidArg, "{gb}");
3282        }
3283        // A magnitude no decimal can hold is refused rather than saturated: a saturated
3284        // figure is a plausible-looking number, which is the only kind nobody checks.
3285        assert!(gb_decimal(1e30).is_err());
3286        // And the boundary is exact where it succeeds — via the decimal string, not the
3287        // binary value, so 0.1 is 0.1 and not 0.1000000000000000055511151231257827.
3288        assert_eq!(gb_decimal(0.1).expect("0.1 is representable"), dec!(0.1));
3289        assert_eq!(gb_decimal(0.25).expect("0.25 is representable"), dec!(0.25));
3290    }
3291
3292    // -- the ARM-only rule (COST-9) --------------------------------------------
3293
3294    /// The catalog as the Pricing API returned it on 2026-08-07 for us-east-1: seven
3295    /// MicroVM line items, including the two x86 compute rates a MicroVM can never be
3296    /// billed at. They are here precisely so a test can prove we do not reach for them.
3297    fn recorded_catalog() -> RateCatalog {
3298        RateCatalog::new()
3299            .with_entry(
3300                "AWS-Lambda-MicroVM-vCPU-Second-ARM",
3301                "vCPU-Seconds",
3302                dec!(0.0000276944),
3303            )
3304            .with_entry(
3305                "AWS-Lambda-MicroVM-vCPU-Second",
3306                "vCPU-Seconds",
3307                dec!(0.0000326557),
3308            )
3309            .with_entry(
3310                "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
3311                "GB-Seconds",
3312                dec!(0.0000036667),
3313            )
3314            .with_entry(
3315                "AWS-Lambda-MicroVM-Memory-GB-Second",
3316                "GB-Seconds",
3317                dec!(0.0000043235),
3318            )
3319            .with_entry(
3320                "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
3321                "GB-Hours",
3322                dec!(0.0001111111),
3323            )
3324            .with_entry(
3325                "AWS-Lambda-MicroVM-Snapshot-Read-GB",
3326                "GB",
3327                dec!(0.0015467699),
3328            )
3329            .with_entry(
3330                "AWS-Lambda-MicroVM-Snapshot-Write-GB",
3331                "GB",
3332                dec!(0.0037977138),
3333            )
3334    }
3335
3336    /// The same catalog without one group, for the refusal cases.
3337    fn without(group: &str) -> RateCatalog {
3338        let mut catalog = RateCatalog::new();
3339        for line in CatalogLine::ALL {
3340            for candidate in [line.group()].into_iter().chain(line.x86_group()) {
3341                if candidate == group {
3342                    continue;
3343                }
3344                let entry = recorded_catalog()
3345                    .matching(candidate)
3346                    .first()
3347                    .map(|entry| (*entry).clone());
3348                if let Some(entry) = entry {
3349                    catalog = catalog.with_entry(entry.group, entry.unit, entry.usd);
3350                }
3351            }
3352        }
3353        catalog
3354    }
3355
3356    /// The recorded catalog fills a table, and the pinned rates agree with it within the
3357    /// rounding of the pinned figures. This is the offline half of `mise run live:rates`
3358    /// — a hand-edit to [`pinned_rates`] fails here rather than waiting for someone to
3359    /// have credentials.
3360    #[test]
3361    fn the_recorded_catalog_fills_a_table_that_matches_the_pinned_one() {
3362        let fetched = RateTable::from_catalog(
3363            Region::UsEast1,
3364            "aws pricing api",
3365            CalendarDate::from_ymd(2026, 8, 7),
3366            &recorded_catalog(),
3367        )
3368        .expect("the recorded catalog is complete");
3369        // The compute rates are exact: the pinned table holds the API's own figures.
3370        assert_eq!(fetched.vcpu_second(), rates().vcpu_second());
3371        assert_eq!(fetched.gb_second(), rates().gb_second());
3372        // Storage went through the GB-hour to GB-month conversion and lands exactly on
3373        // the pinned literal, which is what makes that literal checkable offline.
3374        assert_eq!(fetched.storage_gb_month(), rates().storage_gb_month());
3375        // The snapshot rates are pinned as three-significant-figure roundings, so they
3376        // agree within the 0.5% drift tolerance rather than exactly.
3377        for (fetched, pinned) in [
3378            (fetched.snapshot_read_gb(), rates().snapshot_read_gb()),
3379            (fetched.snapshot_write_gb(), rates().snapshot_write_gb()),
3380        ] {
3381            let relative = (fetched - pinned).abs() / pinned;
3382            assert!(relative < dec!(0.005), "{fetched} vs {pinned}");
3383            assert_ne!(fetched, pinned, "the pinned figure really is a rounding");
3384        }
3385        // A fetched table is authoritative on rates and still hand-read on rules.
3386        assert_eq!(fetched.minimum_retention(), MINIMUM_RETENTION);
3387    }
3388
3389    /// COST-9, the falsification. The one substitution that would look entirely
3390    /// healthy: the x86 rate parses, is the same shape, and is 17.9% higher. Every
3391    /// estimate would inflate and nothing would say so.
3392    #[test]
3393    fn a_catalog_with_only_the_x86_compute_line_is_refused_not_substituted() {
3394        let catalog = without("AWS-Lambda-MicroVM-vCPU-Second-ARM");
3395        let err = RateTable::from_catalog(
3396            Region::UsEast1,
3397            "aws pricing api",
3398            CalendarDate::from_ymd(2026, 8, 7),
3399            &catalog,
3400        )
3401        .expect_err("a missing ARM line is refused, never substituted");
3402        assert_eq!(err.kind(), ErrorKind::InvalidArg);
3403        let message = err.to_string();
3404        assert!(message.contains("ARM64-only"), "{message}");
3405        assert!(
3406            message.contains("0.0000326557"),
3407            "the error must name the rate it refuses to substitute: {message}"
3408        );
3409        assert!(message.contains("18%"), "{message}");
3410        assert!(message.contains("vcpu_second"), "{message}");
3411    }
3412
3413    /// The same for memory, since two compute lines means two chances to substitute.
3414    #[test]
3415    fn the_memory_line_refuses_its_x86_sibling_too() {
3416        let err = RateTable::from_catalog(
3417            Region::UsEast1,
3418            "aws pricing api",
3419            CalendarDate::from_ymd(2026, 8, 7),
3420            &without("AWS-Lambda-MicroVM-Memory-GB-Second-ARM"),
3421        )
3422        .expect_err("a missing ARM memory line is refused");
3423        let message = err.to_string();
3424        assert!(message.contains("ARM64-only"), "{message}");
3425        assert!(message.contains("0.0000043235"), "{message}");
3426        assert!(message.contains("gb_second"), "{message}");
3427    }
3428
3429    /// The x86 probe is a courtesy, not a requirement: with neither line present the
3430    /// error still has to say which field cannot be filled.
3431    #[test]
3432    fn both_compute_rates_missing_still_names_the_field() {
3433        let mut catalog = without("AWS-Lambda-MicroVM-Memory-GB-Second-ARM");
3434        catalog = {
3435            let mut rebuilt = RateCatalog::new();
3436            for entry in catalog.entries.iter() {
3437                if entry.group != "AWS-Lambda-MicroVM-Memory-GB-Second" {
3438                    rebuilt = rebuilt.with_entry(&*entry.group, &*entry.unit, entry.usd);
3439                }
3440            }
3441            rebuilt
3442        };
3443        let err = RateTable::from_catalog(
3444            Region::UsEast1,
3445            "aws pricing api",
3446            CalendarDate::from_ymd(2026, 8, 7),
3447            &catalog,
3448        )
3449        .expect_err("neither compute line means no table");
3450        let message = err.to_string();
3451        assert!(message.contains("gb_second"), "{message}");
3452        assert!(message.contains("neither"), "{message}");
3453    }
3454
3455    /// Snapshot lines have no architecture variant, so there is nothing to refuse — and
3456    /// the message must not claim otherwise, or a reader goes looking for an ARM line
3457    /// that never existed.
3458    #[test]
3459    fn a_missing_snapshot_rate_names_no_x86_sibling() {
3460        let err = RateTable::from_catalog(
3461            Region::UsEast1,
3462            "aws pricing api",
3463            CalendarDate::from_ymd(2026, 8, 7),
3464            &without("AWS-Lambda-MicroVM-Snapshot-Write-GB"),
3465        )
3466        .expect_err("a missing snapshot rate means no table");
3467        let message = err.to_string();
3468        assert!(message.contains("snapshot_write_gb"), "{message}");
3469        assert!(!message.contains("ARM64-only"), "{message}");
3470    }
3471
3472    /// An empty catalog is the *region*, not the catalog, and it is a different repair:
3473    /// check the region versus work out what AWS renamed. Measured, an unpriced region
3474    /// answers `AccessDeniedException` with a null message, which is why this message
3475    /// carries the IAM caveat.
3476    #[test]
3477    fn an_empty_catalog_reads_as_an_unpriced_region_not_a_renamed_line() {
3478        let err = RateTable::from_catalog(
3479            Region::unlisted("eu-central-1"),
3480            "aws pricing api",
3481            CalendarDate::from_ymd(2026, 8, 7),
3482            &RateCatalog::new(),
3483        )
3484        .expect_err("an empty catalog means no table");
3485        let message = err.to_string();
3486        assert!(message.contains("AccessDeniedException"), "{message}");
3487        assert!(message.contains("null message"), "{message}");
3488        assert!(
3489            message.contains("before auditing a policy"),
3490            "the caveat that saves someone reading an IAM policy that is fine: {message}"
3491        );
3492        assert!(!message.contains("ARM64-only"), "{message}");
3493    }
3494
3495    /// A restated unit is refused rather than silently rescaled. It is the only signal
3496    /// available if AWS restates storage per GB-month: the number moves 730x and every
3497    /// arithmetic check downstream still passes, because they all read the same table.
3498    #[test]
3499    fn a_restated_unit_is_refused_rather_than_silently_rescaled() {
3500        let catalog = without("AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour").with_entry(
3501            "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
3502            "GB-Months",
3503            dec!(0.0001111111),
3504        );
3505        let err = RateTable::from_catalog(
3506            Region::UsEast1,
3507            "aws pricing api",
3508            CalendarDate::from_ymd(2026, 8, 7),
3509            &catalog,
3510        )
3511        .expect_err("a restated unit is refused");
3512        let message = err.to_string();
3513        assert!(message.contains("quoted per \"GB-Months\""), "{message}");
3514        assert!(message.contains("still look plausible"), "{message}");
3515    }
3516
3517    /// A tiered rate cannot be flattened into one number, and picking the first would
3518    /// silently choose one.
3519    #[test]
3520    fn two_products_for_one_group_is_refused_rather_than_arbitrated() {
3521        let catalog = recorded_catalog().with_entry(
3522            "AWS-Lambda-MicroVM-vCPU-Second-ARM",
3523            "vCPU-Seconds",
3524            dec!(0.0000999999),
3525        );
3526        let err = RateTable::from_catalog(
3527            Region::UsEast1,
3528            "aws pricing api",
3529            CalendarDate::from_ymd(2026, 8, 7),
3530            &catalog,
3531        )
3532        .expect_err("two products for one group is refused");
3533        assert!(err.to_string().contains("silently select a rate"), "{err}");
3534    }
3535
3536    /// A fetched table drops straight into a report, which is the point of returning a
3537    /// [`RateTable`] rather than a map: every figure is computed against whatever table
3538    /// it was handed, so a regional table needs no second arithmetic path. Tokyo's
3539    /// compute is 16.4% over Virginia's.
3540    #[test]
3541    fn a_regional_table_needs_no_second_arithmetic_path() {
3542        let tokyo_catalog = RateCatalog::new()
3543            .with_entry(
3544                "AWS-Lambda-MicroVM-vCPU-Second-ARM",
3545                "vCPU-Seconds",
3546                dec!(0.0000322421),
3547            )
3548            .with_entry(
3549                "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
3550                "GB-Seconds",
3551                dec!(0.0000042688),
3552            )
3553            .with_entry(
3554                "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
3555                "GB-Hours",
3556                dec!(0.0001333333),
3557            )
3558            .with_entry(
3559                "AWS-Lambda-MicroVM-Snapshot-Read-GB",
3560                "GB",
3561                dec!(0.0018548941),
3562            )
3563            .with_entry(
3564                "AWS-Lambda-MicroVM-Snapshot-Write-GB",
3565                "GB",
3566                dec!(0.0046556039),
3567            );
3568        let tokyo = RateTable::from_catalog(
3569            Region::ApNortheast1,
3570            "aws pricing api",
3571            fresh_day(),
3572            &tokyo_catalog,
3573        )
3574        .expect("Tokyo's catalog is complete");
3575        assert!(tokyo.vcpu_second() / rates().vcpu_second() > dec!(1.16));
3576        assert!(tokyo.snapshot_write_gb() / rates().snapshot_write_gb() > dec!(1.22));
3577
3578        let here = run_report(
3579            SizeClass::Mib2048,
3580            &RunUsage {
3581                running: Some(DurationP::Measured(Duration::from_secs(3600))),
3582                launched: false,
3583                ..RunUsage::default()
3584            },
3585            &tokyo,
3586            fresh_day(),
3587            "run",
3588        )
3589        .expect("a Tokyo report prices");
3590        let there = running_report(
3591            SizeClass::Mib2048,
3592            DurationP::Measured(Duration::from_secs(3600)),
3593        );
3594        assert!(here.total().floor() > there.total().floor());
3595        assert!(here.render().contains("ap-northeast-1"));
3596    }
3597
3598    /// The catalog line metadata is the Python's `MICROVM_LINES`, field for field, so a
3599    /// drift report names the same thing in both clients.
3600    #[test]
3601    fn the_catalog_lines_match_the_python_rate_line_table() {
3602        let described: Vec<(&str, &str, &str, Option<&str>)> = CatalogLine::ALL
3603            .iter()
3604            .map(|line| (line.field(), line.group(), line.unit(), line.x86_group()))
3605            .collect();
3606        assert_eq!(
3607            described,
3608            [
3609                (
3610                    "vcpu_second",
3611                    "AWS-Lambda-MicroVM-vCPU-Second-ARM",
3612                    "vCPU-Seconds",
3613                    Some("AWS-Lambda-MicroVM-vCPU-Second")
3614                ),
3615                (
3616                    "gb_second",
3617                    "AWS-Lambda-MicroVM-Memory-GB-Second-ARM",
3618                    "GB-Seconds",
3619                    Some("AWS-Lambda-MicroVM-Memory-GB-Second")
3620                ),
3621                (
3622                    "storage_gb_month",
3623                    "AWS-Lambda-MicroVM-Snapshot-Storage-GB-Hour",
3624                    "GB-Hours",
3625                    None
3626                ),
3627                (
3628                    "snapshot_read_gb",
3629                    "AWS-Lambda-MicroVM-Snapshot-Read-GB",
3630                    "GB",
3631                    None
3632                ),
3633                (
3634                    "snapshot_write_gb",
3635                    "AWS-Lambda-MicroVM-Snapshot-Write-GB",
3636                    "GB",
3637                    None
3638                ),
3639            ]
3640        );
3641        // Only the storage line converts, because only it is quoted per hour.
3642        assert!(CatalogLine::SnapshotStorageGbHour.is_per_hour());
3643        assert_eq!(
3644            CatalogLine::ALL
3645                .iter()
3646                .filter(|line| line.is_per_hour())
3647                .count(),
3648            1
3649        );
3650    }
3651
3652    // -- the warm-pool comparison ----------------------------------------------
3653
3654    /// The comparison at the shape every golden figure below was recorded against.
3655    fn month_comparison() -> ResidencyComparison {
3656        compare_residency(SizeClass::Mib2048, MONTH, 1, &rates(), fresh_day())
3657            .expect("a month-long comparison prices")
3658    }
3659
3660    /// The whole argument for a warm suspended pool, and the reason the strategy memo
3661    /// can decline to build the scheduler and still hand over the numbers.
3662    #[test]
3663    fn suspended_is_two_orders_of_magnitude_cheaper_than_running() {
3664        assert!(month_comparison().ratio() > dec!(100));
3665    }
3666
3667    /// Without the per-cycle cost the honest conclusion inverts: "suspend constantly"
3668    /// reads as free when each cycle pays a write plus a read.
3669    #[test]
3670    fn the_comparison_includes_the_per_cycle_cost() {
3671        let comparison = month_comparison();
3672        let rates = rates();
3673        assert_eq!(
3674            comparison.per_cycle().expect("a documented size").amount(),
3675            dec!(2) * (rates.snapshot_write_gb() + rates.snapshot_read_gb())
3676        );
3677        let rendered = comparison.render().expect("a comparison renders");
3678        assert!(rendered.contains("avoid churn"), "{rendered}");
3679        assert!(rendered.contains("break-even hold"), "{rendered}");
3680    }
3681
3682    /// **The golden.** The break-even hold at 2 GB, pinned against the Python oracle
3683    /// rather than against a re-derivation: its `compare_residency(size=2048,
3684    /// hold_seconds=730*3600, cycles=1).break_even_seconds` printed
3685    /// `1371.2916483478837` when it was run in the session that wrote this test.
3686    ///
3687    /// Worth recording that the task packet predicted ≈1357s. The oracle decides, and it
3688    /// says 1371.29 — a re-derivation would have been the wrong check here, because it
3689    /// is exactly what would have agreed with a plausible wrong number.
3690    ///
3691    /// The two clients agree to 22 significant digits and diverge only past that:
3692    /// `rust_decimal` saturates at 28 digits of scale where Python's `Decimal` context
3693    /// is unbounded. So the exact figure is asserted to a tolerance well inside a
3694    /// second, and the f64 form — which is what `cli.py` emits as `breakEvenSeconds` —
3695    /// is asserted to agree with the Python's float exactly.
3696    #[test]
3697    fn the_break_even_hold_at_two_gigabytes_matches_the_python_oracle() {
3698        let comparison = month_comparison();
3699        let exact = comparison.break_even_seconds().expect("a documented size");
3700        let oracle = dec!(1371.291648347883680961978771);
3701        assert!(
3702            (exact - oracle).abs() < dec!(0.000000001),
3703            "the Rust figure {exact} must match the Python oracle {oracle}"
3704        );
3705        // The f64 the CLI emits, byte-identical to the Python's.
3706        assert_eq!(
3707            comparison
3708                .break_even_seconds_f64()
3709                .expect("a documented size"),
3710            1371.2916483478837
3711        );
3712        // And the other recorded figures from the same oracle run, so a change to any
3713        // one of them is visible here rather than only in the break-even.
3714        assert_eq!(
3715            comparison.per_cycle().expect("a documented size").amount(),
3716            dec!(0.01070)
3717        );
3718        assert_eq!(comparison.ratio().round_dp(4), dec!(532.3380));
3719    }
3720
3721    /// The break-even is the number a pool scheduler needs, and this is what makes it
3722    /// meaningful rather than merely reproducible: just under it, a cycle loses money;
3723    /// just over, it saves. Asserting the *verdict* the figure predicts, per
3724    /// `.erpaval/solutions/test-failures/proptest-and-dst-tiers-need-verdict-assertions.md`
3725    /// — a test that only pinned 1371.29 would pass against a formula that computed a
3726    /// number with no relation to the crossover.
3727    #[test]
3728    fn churn_below_break_even_costs_more_than_leaving_the_vm_running() {
3729        let break_even = month_comparison()
3730            .break_even_seconds()
3731            .expect("a documented size");
3732        assert!(break_even > Decimal::ZERO);
3733
3734        let suspended_beats_running = |hold: Duration| {
3735            let comparison = compare_residency(SizeClass::Mib2048, hold, 1, &rates(), fresh_day())
3736                .expect("a comparison prices");
3737            comparison.suspended().total().floor() < comparison.running().total().floor()
3738        };
3739        let at = |factor: Decimal| {
3740            Duration::from_secs_f64(
3741                (break_even * factor)
3742                    .to_f64()
3743                    .expect("the break-even hold fits in an f64"),
3744            )
3745        };
3746        assert!(
3747            !suspended_beats_running(at(dec!(0.9))),
3748            "just under the break-even hold, a cycle must lose money"
3749        );
3750        assert!(
3751            suspended_beats_running(at(dec!(1.1))),
3752            "just over it, a cycle must save"
3753        );
3754    }
3755
3756    /// Image build and storage are identical either way, so including them would shrink
3757    /// the ratio the comparison exists to show — and drag an unpriced build into a figure
3758    /// whose whole job is to be comparable.
3759    #[test]
3760    fn the_comparison_excludes_the_image_so_the_ratio_is_not_diluted() {
3761        let comparison = month_comparison();
3762        for report in [comparison.running(), comparison.suspended()] {
3763            let phases: Vec<CostPhase> = report.items().iter().map(|item| item.phase).collect();
3764            assert!(!phases.contains(&CostPhase::ImageBuild), "{phases:?}");
3765            assert!(!phases.contains(&CostPhase::ImageStorage), "{phases:?}");
3766            assert!(report.is_complete());
3767            // No launch either: a launch read would be paid on the running side only and
3768            // would tilt the ratio it is not part of.
3769            assert!(!phases.contains(&CostPhase::Launch), "{phases:?}");
3770        }
3771    }
3772
3773    /// The hold on a comparison is always projected: a comparison is a hypothetical
3774    /// about a hold nobody has taken yet, so neither side may read as a receipt.
3775    #[test]
3776    fn a_comparison_is_never_a_measured_report() {
3777        let comparison = month_comparison();
3778        assert_eq!(comparison.hold().provenance(), Provenance::Projected);
3779        assert!(!comparison.running().fully_measured());
3780        assert!(!comparison.suspended().fully_measured());
3781    }
3782
3783    /// The ratio's denominator can never be zero, because snapshot storage always has
3784    /// the minimum-retention floor (COST-8). The degenerate input — a zero-length hold —
3785    /// is the case that would divide by zero without it.
3786    #[test]
3787    fn a_zero_length_hold_still_has_a_finite_ratio() {
3788        let comparison =
3789            compare_residency(SizeClass::Mib2048, Duration::ZERO, 1, &rates(), fresh_day())
3790                .expect("a zero hold prices");
3791        assert!(comparison.suspended().total().floor().amount() > Decimal::ZERO);
3792        // Running for zero seconds costs nothing, so the ratio is zero rather than
3793        // undefined — and suspending a VM for no time is a pure loss, which is what the
3794        // break-even figure is about.
3795        assert_eq!(comparison.ratio(), Decimal::ZERO);
3796    }
3797
3798    // -- properties ------------------------------------------------------------
3799
3800    /// A plausible span, in seconds. Weighted rather than uniform, for the reason
3801    /// T-W2-2 measured: a uniform draw over the whole domain essentially never lands in
3802    /// the band where the interesting branch is reachable. Here the band that matters is
3803    /// the minimum-retention window, since that is where the storage floor applies and
3804    /// where the break-even formula changes slope.
3805    fn plausible_seconds() -> impl Strategy<Value = u64> {
3806        prop_oneof![
3807            // Inside the retention window, where the storage floor binds.
3808            4 => 0u64..604_800,
3809            // Past it, where storage grows with the hold.
3810            3 => 604_800u64..(730 * 3600 * 12),
3811            // The neighbourhood of the floor itself, where an off-by-one in the `max`
3812            // would hide.
3813            2 => 604_795u64..=604_805,
3814            // A long tail, so nothing above goes unchecked.
3815            1 => 0u64..u32::MAX as u64,
3816        ]
3817    }
3818
3819    proptest! {
3820        /// COST-4, as a property over every combination of priced and unpriced lines: a
3821        /// total is `AtLeast` exactly when an unpriced line is present, its floor is the
3822        /// sum of the priced ones and nothing else, and every unpriced reason is named.
3823        ///
3824        /// The verdict is computed from the generated input rather than read off the
3825        /// answer, which is what makes this catch a `Total::of` that dropped the
3826        /// unpriced line: such an implementation answers `Exact` where `documented`
3827        /// says `AtLeast`.
3828        ///
3829        /// The parenthetical is checked against a reference built the oracle's way —
3830        /// `", ".join(sorted({phase}))` — so a `Display` that reaches for reasons, report
3831        /// order, or the wrong separator disagrees on some generated input rather than
3832        /// only on the one report a hand-written test happens to build.
3833        #[test]
3834        fn a_total_is_a_lower_bound_exactly_when_something_is_unpriced(
3835            figures in prop::collection::vec(
3836                (
3837                    prop::sample::select(&[
3838                        CostPhase::ImageBuild,
3839                        CostPhase::Running,
3840                        CostPhase::Suspended,
3841                    ][..]),
3842                    prop_oneof![
3843                        (0u64..1_000_000).prop_map(|cents| Amount::estimated(
3844                            Decimal::from(cents) / dec!(100)
3845                        )),
3846                        "[a-z ]{1,20}".prop_map(Amount::unpriced),
3847                    ],
3848                ),
3849                0..12,
3850            ),
3851        ) {
3852            let expected_floor: Decimal = figures
3853                .iter()
3854                .filter_map(|(_, amount)| amount.estimate())
3855                .map(EstimatedUsd::amount)
3856                .sum();
3857            let expected_reasons: Vec<String> = figures
3858                .iter()
3859                .filter_map(|(_, amount)| amount.unpriced_reason())
3860                .map(str::to_string)
3861                .collect();
3862            let expected_names: Vec<&str> = {
3863                let mut names: Vec<&str> = figures
3864                    .iter()
3865                    .filter(|(_, amount)| amount.unpriced_reason().is_some())
3866                    .map(|(phase, _)| phase.as_str())
3867                    .collect();
3868                names.sort_unstable();
3869                names.dedup();
3870                names
3871            };
3872
3873            let total = Total::of(figures.iter().map(|(phase, amount)| (*phase, amount)));
3874            prop_assert_eq!(total.is_lower_bound(), !expected_reasons.is_empty());
3875            prop_assert_eq!(total.floor().amount(), expected_floor);
3876            prop_assert_eq!(total.unpriced_reasons(), expected_reasons.as_slice());
3877            prop_assert_eq!(total.unpriced_phase_names(), expected_names.as_slice());
3878            match &total {
3879                Total::Exact(_) => prop_assert!(expected_reasons.is_empty()),
3880                Total::AtLeast { unpriced, .. } => prop_assert!(!unpriced.is_empty()),
3881            }
3882            // The rendering says which it is, since that is what a reader sees.
3883            prop_assert_eq!(
3884                total.to_string().starts_with("at least "),
3885                total.is_lower_bound()
3886            );
3887            if total.is_lower_bound() {
3888                prop_assert_eq!(
3889                    total.to_string(),
3890                    format!(
3891                        "at least {}, plus {} unpriced ({})",
3892                        total.floor(),
3893                        expected_reasons.len(),
3894                        expected_names.join(", ")
3895                    )
3896                );
3897            }
3898        }
3899
3900        /// COST-6, over the whole arithmetic: a report's figures are exactly what the
3901        /// rate table times the quantities says they are, with no precision lost. The
3902        /// reference is recomputed in `Decimal` from the same inputs, so a float
3903        /// intermediate anywhere in the compute path disagrees.
3904        #[test]
3905        fn compute_figures_are_exactly_the_decimal_reference(
3906            seconds in plausible_seconds(),
3907            class_index in 0usize..5,
3908        ) {
3909            let size = SizeClass::ALL[class_index];
3910            let rates = rates();
3911            let duration = Duration::from_secs(seconds);
3912            let report = run_report(
3913                size,
3914                &RunUsage {
3915                    running: Some(DurationP::Measured(duration)),
3916                    launched: false,
3917                    ..RunUsage::default()
3918                },
3919                &rates,
3920                fresh_day(),
3921                "run",
3922            )?;
3923
3924            let exact_seconds = Decimal::from(seconds);
3925            let vcpu = gb_decimal(size.baseline_vcpu())?;
3926            let memory = gb_decimal(size.baseline_gb())?;
3927            let expected_vcpu = vcpu * exact_seconds * rates.vcpu_second();
3928            let expected_memory = memory * exact_seconds * rates.gb_second();
3929
3930            let mut lines = report.by_phase(CostPhase::Running);
3931            let vcpu_line = lines.next().expect("a vcpu line");
3932            let memory_line = lines.next().expect("a memory line");
3933            prop_assert_eq!(vcpu_line.amount.estimate().expect("priced").amount(), expected_vcpu);
3934            prop_assert_eq!(memory_line.amount.estimate().expect("priced").amount(), expected_memory);
3935            // And the total is the sum of exactly those two, not a re-derivation.
3936            prop_assert_eq!(
3937                report.total(),
3938                Total::Exact(EstimatedUsd::new(expected_vcpu + expected_memory))
3939            );
3940            // COST-5 as a property: the quantity is the baseline times the seconds, and
3941            // for every class the peak figure is a different number.
3942            prop_assert_eq!(memory_line.quantity, memory * exact_seconds);
3943            if seconds > 0 {
3944                prop_assert_ne!(
3945                    memory_line.quantity,
3946                    gb_decimal(size.peak_gb())? * exact_seconds
3947                );
3948            }
3949        }
3950
3951        /// COST-8 as a property: storage bills `max(held, one week)` for every hold,
3952        /// and the note says so exactly when the floor bound. A `max` written the wrong
3953        /// way round passes at long holds and fails inside the window, which is why the
3954        /// generator is weighted towards it.
3955        #[test]
3956        fn storage_bills_the_greater_of_the_hold_and_the_retention_floor(
3957            seconds in plausible_seconds(),
3958        ) {
3959            let rates = rates();
3960            let report = run_report(
3961                SizeClass::Mib2048,
3962                &RunUsage {
3963                    suspended: Some(DurationP::Measured(Duration::from_secs(seconds))),
3964                    launched: false,
3965                    ..RunUsage::default()
3966                },
3967                &rates,
3968                fresh_day(),
3969                "suspended",
3970            )?;
3971            let floor = seconds_of(rates.minimum_retention());
3972            let billed = Decimal::from(seconds).max(floor);
3973            let expected = dec!(2) * billed / SECONDS_PER_MONTH;
3974
3975            let items: Vec<&LineItem> = report.by_phase(CostPhase::Suspended).collect();
3976            prop_assert_eq!(items[0].quantity, expected);
3977            prop_assert_eq!(
3978                items[0].amount.estimate().expect("priced").amount(),
3979                expected * rates.storage_gb_month()
3980            );
3981            // The verdict on the note, computed from the input: it claims the floor
3982            // exactly when the floor was what bound.
3983            prop_assert_eq!(
3984                items[0].note.contains("minimum retention"),
3985                Decimal::from(seconds) < floor
3986            );
3987            // Never below the floor's own charge, which is the failure this requirement
3988            // is about — understating a create-and-destroy suite by four orders of
3989            // magnitude.
3990            prop_assert!(items[0].quantity >= dec!(2) * floor / SECONDS_PER_MONTH);
3991        }
3992
3993        /// COST-10 as a property: whatever a plan contains, every duration in the
3994        /// resulting report is projected and the report is never fully measured.
3995        #[test]
3996        fn every_duration_in_a_plan_is_projected(
3997            running in 0u64..1_000_000,
3998            suspended in 0u64..1_000_000,
3999            retained in prop::option::of(1u64..1_000_000),
4000            cycles in 0u32..5,
4001            image in prop::option::of(0.5f64..64.0),
4002        ) {
4003            let report = estimate_run(
4004                SizeClass::Mib2048,
4005                &PlanUsage {
4006                    running_seconds: running as f64,
4007                    suspended_seconds: suspended as f64,
4008                    image_gb: image,
4009                    image_retained_seconds: retained.map(|seconds| seconds as f64),
4010                    suspend_resume_cycles: cycles,
4011                    snapshot_gb: None,
4012                    launched: true,
4013                },
4014                &rates(),
4015                fresh_day(),
4016                "estimate",
4017            )?;
4018            for item in report.items() {
4019                if let Some(duration) = item.duration {
4020                    prop_assert_eq!(duration.provenance(), Provenance::Projected);
4021                }
4022            }
4023            prop_assert!(!report.fully_measured());
4024            // An image drags the unpriced build in, so the total's shape is decided by
4025            // whether one was named — the verdict, computed from the input.
4026            prop_assert_eq!(report.total().is_lower_bound(), image.is_some());
4027        }
4028
4029        /// The measured and projected paths are one arithmetic. If they could disagree
4030        /// numerically, the label would be hiding a second implementation.
4031        #[test]
4032        fn a_plan_and_a_timed_run_of_the_same_shape_price_identically(
4033            running in 1u64..1_000_000,
4034            cycles in 0u32..5,
4035        ) {
4036            let projected = estimate_run(
4037                SizeClass::Mib2048,
4038                &PlanUsage {
4039                    running_seconds: running as f64,
4040                    suspend_resume_cycles: cycles,
4041                    ..PlanUsage::default()
4042                },
4043                &rates(),
4044                fresh_day(),
4045                "estimate",
4046            )?;
4047            let measured = run_report(
4048                SizeClass::Mib2048,
4049                &RunUsage {
4050                    running: Some(DurationP::Measured(Duration::from_secs(running))),
4051                    suspend_resume_cycles: cycles,
4052                    launched: false,
4053                    ..RunUsage::default()
4054                },
4055                &rates(),
4056                fresh_day(),
4057                "run",
4058            )?;
4059            prop_assert_eq!(projected.total(), measured.total());
4060            prop_assert!(!projected.fully_measured());
4061        }
4062
4063        /// The float boundary is exact where it succeeds (COST-6). Every f64 that has a
4064        /// decimal reading gets that reading, not its binary approximation — which is
4065        /// what `Decimal::try_from(0.1f64)` would give.
4066        #[test]
4067        fn the_float_boundary_reads_the_decimal_spelling_not_the_binary_value(
4068            gb in 0.0f64..1_000_000.0,
4069        ) {
4070            let converted = gb_decimal(gb)?;
4071            prop_assert_eq!(converted.to_string(), gb.to_string());
4072            prop_assert!(converted >= Decimal::ZERO);
4073        }
4074
4075        /// The staleness verdict is a comparison against ninety days and nothing else,
4076        /// over a range of retrieval ages. Computed from the input, so an off-by-one at
4077        /// the boundary fails.
4078        #[test]
4079        fn staleness_is_the_ninety_day_verdict_and_carries_the_age(
4080            age_days in -10i64..400,
4081        ) {
4082            let rates = rates();
4083            let today = CalendarDate::from_day_number(
4084                rates.retrieved().day_number() + age_days
4085            );
4086            prop_assert_eq!(rates.age_days(today), age_days);
4087            prop_assert_eq!(rates.is_stale(today), age_days > STALE_AFTER_DAYS);
4088            match rates.staleness(today) {
4089                Some(warning) => {
4090                    prop_assert!(age_days > STALE_AFTER_DAYS);
4091                    let expected = format!("{age_days} days ago");
4092                    prop_assert!(warning.contains(&expected), "{}", warning);
4093                }
4094                None => prop_assert!(age_days <= STALE_AFTER_DAYS),
4095            }
4096            // And the report carries whatever the table said.
4097            let report = run_report(
4098                SizeClass::Mib2048,
4099                &RunUsage {
4100                    running: Some(DurationP::Measured(Duration::from_secs(60))),
4101                    launched: false,
4102                    ..RunUsage::default()
4103                },
4104                &rates,
4105                today,
4106                "run",
4107            )?;
4108            prop_assert_eq!(report.staleness().is_some(), age_days > STALE_AFTER_DAYS);
4109        }
4110
4111        /// The date round trip, which is what licenses a hand-rolled calendar: every day
4112        /// number is a date whose day number is itself, across four centuries.
4113        #[test]
4114        fn a_day_number_round_trips_through_its_calendar_date(
4115            day_number in -30_000i64..30_000,
4116        ) {
4117            let date = CalendarDate::from_day_number(day_number);
4118            prop_assert_eq!(date.day_number(), day_number);
4119            // And the parts are a real calendar day, so the validator agrees with the
4120            // arithmetic.
4121            prop_assert!(
4122                CalendarDate::try_from_ymd(date.year(), date.month(), date.day()).is_ok(),
4123                "{date} is not a calendar day"
4124            );
4125        }
4126    }
4127}