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