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