Skip to main content

microvms_domain/
cost.rs

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