Skip to main content

microvms_domain/
sizing.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Size classes: what `minimumMemoryInMiB` actually buys, and what it bills.
3//!
4//! `minimumMemoryInMiB` does not size a VM. It selects a *class*, and the class has
5//! two numbers that differ by 4x — the baseline you are billed for every running
6//! second, and the peak the guest reports in `/proc/meminfo`. Measured 2026-08-07,
7//! us-east-1: requesting 512 produced a guest reporting ~2 GB `MemTotal`; requesting
8//! 2048 produced ~8 GB. Both match AWS's documented sizing table exactly, so the
9//! number the caller writes down is neither the number they get nor, on its own,
10//! enough to predict the bill (`docs/PLATFORM.md`, "`minimumMemoryInMiB` selects a
11//! *baseline*, and the guest reports the *peak*").
12//!
13//! The peak is provisioned from the start (service team, confirmed 2026-08): there
14//! is no scaling event, nothing resizes during a run, and app code never observes a
15//! resource change. The baseline is the bill's floor — always paid while the VM
16//! runs — and usage above it is billed by consumption.
17//!
18//! # Why the table is data (TRAP-13)
19//!
20//! Every documented peak is exactly four times its baseline, which makes
21//! `baseline * 4` look like the obvious simplification. It is the one thing this
22//! module must not do. That regularity is AWS's and not ours: a sixth row, or a
23//! revision to an existing one, that breaks the pattern would silently get the
24//! pattern applied to it, and the report would name a provisioned ceiling the service
25//! does not offer. So [`SIZE_CLASSES`] is the only place any of these numbers appears, and
26//! every accessor reads a row out of it through one lookup — which is also what makes
27//! the guard testable: the test below drives the accessors over a table whose peak is
28//! *not* 4x, and a computed implementation cannot answer it.
29//!
30//! # Why an off-table request is refused (TRAP-10)
31//!
32//! Only the five table values are accepted. What the service does with an off-table
33//! request such as 1500 is undocumented and unmeasured, and the two plausible
34//! behaviours — round up to the enclosing class, or take the literal as a baseline —
35//! differ in both the memory the guest gets and the rate it is billed at. Rejecting
36//! locally costs the caller a second; guessing costs them a wrong bill they have no
37//! way to notice.
38
39use std::fmt;
40
41use crate::error::Error;
42
43/// MiB per GB as AWS's pricing and sizing tables use it.
44///
45/// The guest's `MemTotal` reads in KiB and lands slightly under (2 GB shows as
46/// 2037648 kB), so any comparison against a guest-reported figure is approximate by
47/// nature.
48pub const MIB_PER_GB: u32 = 1024;
49
50/// One row of the documented sizing table: a baseline and its provisioned ceiling.
51///
52/// Plain data with public fields, because that is what it is — a transcription of
53/// someone else's published table, not settings. The accessors on [`SizeClass`] are
54/// where a caller normally reads it; the row is public so the drift gate and the
55/// guard test can compare against the table itself.
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub struct SizeRow {
58    /// What `minimumMemoryInMiB` must be set to in order to select this class.
59    pub baseline_mib: u32,
60    /// Billed per running second, alongside `baseline_vcpu`. Not what the guest
61    /// reports.
62    pub baseline_vcpu: f64,
63    /// What the guest reports as `MemTotal`: a fixed ceiling, provisioned from the
64    /// start and present the whole run. Usage above the baseline is billed by
65    /// consumption, per second.
66    pub peak_mib: u32,
67    pub peak_vcpu: f64,
68}
69
70/// The documented table (`microvms-images.html`), smallest first.
71///
72/// The single definition of all twenty numbers. See the module docs for why nothing
73/// derives one of them from another.
74pub const SIZE_CLASSES: [SizeRow; 5] = [
75    SizeRow {
76        baseline_mib: 512,
77        baseline_vcpu: 0.25,
78        peak_mib: 2048,
79        peak_vcpu: 1.0,
80    },
81    SizeRow {
82        baseline_mib: 1024,
83        baseline_vcpu: 0.5,
84        peak_mib: 4096,
85        peak_vcpu: 2.0,
86    },
87    SizeRow {
88        baseline_mib: 2048,
89        baseline_vcpu: 1.0,
90        peak_mib: 8192,
91        peak_vcpu: 4.0,
92    },
93    SizeRow {
94        baseline_mib: 4096,
95        baseline_vcpu: 2.0,
96        peak_mib: 16384,
97        peak_vcpu: 8.0,
98    },
99    SizeRow {
100        baseline_mib: 8192,
101        baseline_vcpu: 4.0,
102        peak_mib: 32768,
103        peak_vcpu: 16.0,
104    },
105];
106
107/// One of the five documented size classes.
108///
109/// Closed, so an off-table memory figure is not a value that exists — the Python
110/// client's S2 runtime check (`sizing.py:95` `size_class_for`) becomes S1 for every
111/// caller who has a `SizeClass` in hand. [`SizeClass::from_baseline_mib`] is the one
112/// boundary where a bare integer still has to be judged, which is where TRAP-10
113/// lives.
114///
115/// The variants are named for their baseline because that is the number the caller
116/// writes into `minimumMemoryInMiB`; the peak is deliberately not in any name, since
117/// naming both would suggest a caller gets to pick them independently.
118#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
119pub enum SizeClass {
120    Mib512,
121    Mib1024,
122    Mib2048,
123    Mib4096,
124    Mib8192,
125}
126
127impl SizeClass {
128    /// The platform's own default, and ours.
129    ///
130    /// Deliberately not the smallest class, and the reason is the CEILING: picking
131    /// 512 gives the guest a hard 2 GiB total — provisioned from the start, fixed
132    /// for the whole run, nothing resizes past it — and a real test suite breaches
133    /// it. Guest swap is absent (`SwapTotal: 0 kB`), so there is no paging phase to
134    /// absorb the mistake — pressure goes straight to the OOM killer. The default's
135    /// job is a ceiling that survives a real test suite, and 8 GiB does.
136    pub const DEFAULT: SizeClass = SizeClass::Mib2048;
137
138    /// Every class, smallest first, in [`SIZE_CLASSES`] order.
139    pub const ALL: [SizeClass; 5] = [
140        SizeClass::Mib512,
141        SizeClass::Mib1024,
142        SizeClass::Mib2048,
143        SizeClass::Mib4096,
144        SizeClass::Mib8192,
145    ];
146
147    /// The class `minimumMemoryInMiB = mib` selects (TRAP-10).
148    ///
149    /// Rejects anything not in the table rather than snapping to a neighbour: an
150    /// off-table request has two plausible readings that differ in both memory and
151    /// rate, and we have measured neither. This is the boundary a figure from a CLI
152    /// flag or a config file crosses.
153    pub fn from_baseline_mib(mib: u32) -> Result<SizeClass, Error> {
154        if let Some(class) = class_for_baseline_in(&SIZE_CLASSES, mib) {
155            return Ok(class);
156        }
157        let offered = SIZE_CLASSES
158            .iter()
159            .map(|row| row.baseline_mib.to_string())
160            .collect::<Vec<_>>()
161            .join(", ");
162        Err(Error::invalid_arg(format!(
163            "minimumMemoryInMiB={mib} is not a documented size class baseline. The field \
164             selects a class, it does not size a VM; pass one of {offered} MiB. See \
165             docs/PLATFORM.md, '`minimumMemoryInMiB` selects a *baseline*, and the guest \
166             reports the *peak*'."
167        )))
168    }
169
170    /// The smallest class whose **baseline** covers a resource request (BIND-14).
171    ///
172    /// A harness is handed CPUs and memory as requests (Harbor's `cpus` and `memory_mb`). The
173    /// baseline is the covering figure because it is what is billed and always present; the
174    /// peak above it is provisioned too, but choosing by peak would hand a 2-vCPU task the
175    /// 0.5-vCPU class. `None` or zero on an axis is no requirement on it; with no requirement on
176    /// either the answer is [`SizeClass::DEFAULT`], not the smallest class, for the reason
177    /// `DEFAULT` gives. A request no class covers is an invalid-argument refusal naming the
178    /// largest class, and so is a CPU figure that is not a finite non-negative number.
179    pub fn from_request(cpus: Option<f64>, memory_mib: Option<u32>) -> Result<SizeClass, Error> {
180        if let Some(cpus) = cpus
181            && !(cpus.is_finite() && cpus >= 0.0)
182        {
183            return Err(Error::invalid_arg(format!(
184                "a CPU request must be a finite non-negative number of vCPUs, not {cpus}"
185            )));
186        }
187        let cpus = cpus.filter(|cpus| *cpus > 0.0);
188        let memory_mib = memory_mib.filter(|mib| *mib > 0);
189        if cpus.is_none() && memory_mib.is_none() {
190            return Ok(SizeClass::DEFAULT);
191        }
192        let covers = |class: &SizeClass| {
193            cpus.is_none_or(|cpus| class.baseline_vcpu() >= cpus)
194                && memory_mib.is_none_or(|mib| class.baseline_mib() >= mib)
195        };
196        SizeClass::ALL.into_iter().find(covers).ok_or_else(|| {
197            let largest = SizeClass::ALL[SizeClass::ALL.len() - 1];
198            let asked = [
199                cpus.map(|cpus| format!("{cpus} vCPU")),
200                memory_mib.map(|mib| format!("{mib} MiB")),
201            ]
202            .into_iter()
203            .flatten()
204            .collect::<Vec<_>>()
205            .join(" / ");
206            Error::invalid_arg(format!(
207                "a request for {asked} is more than the largest size class covers: {largest}. \
208                 Size classes are chosen by baseline; reduce the request or run the task \
209                 elsewhere."
210            ))
211        })
212    }
213
214    /// This class's row of the documented table.
215    ///
216    /// The one reader of [`SIZE_CLASSES`] for a known class, which is what keeps
217    /// every accessor below reading data rather than deriving it.
218    pub fn row(self) -> &'static SizeRow {
219        row_in(&SIZE_CLASSES, self)
220    }
221
222    /// The value to send as `minimumMemoryInMiB`. Billed per running second.
223    pub fn baseline_mib(self) -> u32 {
224        self.row().baseline_mib
225    }
226
227    /// What the guest reports as `MemTotal`: the provisioned ceiling, present from
228    /// the start of the run.
229    ///
230    /// Read from the table. Never `baseline_mib() * 4` — see the module docs.
231    pub fn peak_mib(self) -> u32 {
232        self.row().peak_mib
233    }
234
235    /// The static headroom above the baseline: peak minus baseline, both read from
236    /// this class's table row.
237    ///
238    /// Fixed at provision time — the headroom is always present, never granted by a
239    /// scaling event — and usage inside it is billed by consumption. Computed as a
240    /// difference of two *table* values, never as `baseline * 3`: the 4x regularity
241    /// is AWS's, not ours, and a row that breaks it must be reported as written
242    /// (TRAP-13, same discipline as [`SizeClass::peak_mib`]).
243    pub fn headroom_mib(self) -> u32 {
244        headroom_in(&SIZE_CLASSES, self)
245    }
246
247    /// Billed per running second, alongside [`SizeClass::baseline_mib`].
248    pub fn baseline_vcpu(self) -> f64 {
249        self.row().baseline_vcpu
250    }
251
252    /// The provisioned vCPU ceiling, present from the start. Read from the table.
253    pub fn peak_vcpu(self) -> f64 {
254        self.row().peak_vcpu
255    }
256
257    /// The figure a GB-second rate multiplies. Always the baseline, never the peak
258    /// (COST-5).
259    pub fn baseline_gb(self) -> f64 {
260        f64::from(self.baseline_mib()) / f64::from(MIB_PER_GB)
261    }
262
263    /// The peak in GB, for a report that shows the caller what the guest will say.
264    pub fn peak_gb(self) -> f64 {
265        f64::from(self.peak_mib()) / f64::from(MIB_PER_GB)
266    }
267
268    /// The position of this class in [`SIZE_CLASSES`].
269    ///
270    /// Private and `const`: it is the identity mapping between the enum and the
271    /// table, and the invariant test below pins it to the baselines so a reordered
272    /// table cannot quietly re-point every variant.
273    const fn index(self) -> usize {
274        match self {
275            SizeClass::Mib512 => 0,
276            SizeClass::Mib1024 => 1,
277            SizeClass::Mib2048 => 2,
278            SizeClass::Mib4096 => 3,
279            SizeClass::Mib8192 => 4,
280        }
281    }
282}
283
284/// One line naming both numbers, for an error message or a CLI.
285///
286/// Both numbers or neither: naming only the peak invites someone to budget for
287/// memory they will not be billed for, and naming only the baseline invites a
288/// memory-pressure test written against a ceiling four times lower than the one the
289/// guest enforces.
290impl fmt::Display for SizeClass {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        let row = self.row();
293        write!(
294            f,
295            "{} GB / {} vCPU baseline (billed while running), with a fixed ceiling of {} GB / \
296             {} vCPU provisioned from the start (what the guest reports)",
297            self.baseline_gb(),
298            row.baseline_vcpu,
299            self.peak_gb(),
300            row.peak_vcpu,
301        )
302    }
303}
304
305/// The row a class occupies in `table`.
306///
307/// Takes the table as a parameter for one reason: it is what makes TRAP-13's guard
308/// falsifiable. Every accessor above goes through here, so a test can hand this
309/// function a table whose peak is not four times its baseline and see whether the
310/// answer follows the data or the pattern.
311fn row_in(table: &[SizeRow], class: SizeClass) -> &SizeRow {
312    &table[class.index()]
313}
314
315/// The headroom `table`'s row carries for `class`: its peak minus its baseline.
316///
317/// Table-parameterized for the same reason as [`row_in`]: a test can hand this a
318/// table whose peak is not four times its baseline and see whether the headroom
319/// follows the data or the pattern. Saturating rather than plain subtraction so a
320/// hostile test table whose peak is below its baseline reads as zero headroom
321/// rather than a panic.
322fn headroom_in(table: &[SizeRow], class: SizeClass) -> u32 {
323    let row = row_in(table, class);
324    row.peak_mib.saturating_sub(row.baseline_mib)
325}
326
327/// The class whose row in `table` carries `baseline_mib`, if any.
328///
329/// Table-parameterized for the same reason as [`row_in`], and a linear scan because
330/// five rows is five rows.
331fn class_for_baseline_in(table: &[SizeRow], baseline_mib: u32) -> Option<SizeClass> {
332    SizeClass::ALL
333        .into_iter()
334        .find(|class| table[class.index()].baseline_mib == baseline_mib)
335}
336
337#[cfg(test)]
338mod tests {
339    use proptest::prelude::*;
340
341    use super::*;
342    use crate::error::ErrorKind;
343
344    /// The measured table, written out as literals. This is the test a table edit
345    /// must fail: the twenty numbers are transcriptions of AWS's published table and
346    /// two of the rows were measured directly, so changing one is a claim about the
347    /// service that needs its own evidence.
348    #[test]
349    fn the_documented_table_carries_the_measured_rows() {
350        let rows: Vec<(u32, f64, u32, f64)> = SIZE_CLASSES
351            .iter()
352            .map(|r| (r.baseline_mib, r.baseline_vcpu, r.peak_mib, r.peak_vcpu))
353            .collect();
354        assert_eq!(
355            rows,
356            [
357                (512, 0.25, 2048, 1.0),
358                (1024, 0.5, 4096, 2.0),
359                (2048, 1.0, 8192, 4.0),
360                (4096, 2.0, 16384, 8.0),
361                (8192, 4.0, 32768, 16.0),
362            ]
363        );
364    }
365
366    /// TRAP-13. The lookup answers from the data, so a table whose peak breaks the
367    /// 4x pattern is reported as written rather than as the pattern predicts.
368    ///
369    /// This is the falsification the spec asks for: replace [`SizeClass::peak_mib`]'s
370    /// body with `self.baseline_mib() * 4` and this row's peak comes back 8192
371    /// instead of 9000. A test that only checked the shipped table could not tell the
372    /// two implementations apart, because every shipped peak *is* 4x its baseline.
373    #[test]
374    fn a_peak_that_is_not_four_times_its_baseline_is_read_not_computed() {
375        let mut table = SIZE_CLASSES;
376        table[SizeClass::Mib2048.index()].peak_mib = 9000;
377        table[SizeClass::Mib2048.index()].peak_vcpu = 3.5;
378
379        let row = row_in(&table, SizeClass::Mib2048);
380        assert_eq!(
381            row.peak_mib, 9000,
382            "the peak must come from the table, not from baseline * 4"
383        );
384        assert_eq!(row.peak_vcpu, 3.5);
385        // The baseline is untouched by the edit, which is what makes the assertion
386        // above about the peak rather than about the row being found at all.
387        assert_eq!(row.baseline_mib, 2048);
388        assert_eq!(row.peak_mib * 4 / 4, 9000);
389        assert_ne!(row.peak_mib, row.baseline_mib * 4);
390    }
391
392    /// The public accessors are that lookup applied to the shipped table, and
393    /// nothing else. Under a computed implementation this passes today and goes red
394    /// the moment a table row stops being 4x — which is exactly the regression
395    /// TRAP-13 names.
396    #[test]
397    fn every_accessor_returns_its_own_row_from_the_shipped_table() {
398        for class in SizeClass::ALL {
399            let row = &SIZE_CLASSES[class.index()];
400            assert_eq!(class.baseline_mib(), row.baseline_mib, "{class:?}");
401            assert_eq!(class.peak_mib(), row.peak_mib, "{class:?}");
402            assert_eq!(class.baseline_vcpu(), row.baseline_vcpu, "{class:?}");
403            assert_eq!(class.peak_vcpu(), row.peak_vcpu, "{class:?}");
404        }
405    }
406
407    /// The enum-to-row mapping is pinned to the baselines rather than to positions,
408    /// so a reordered table fails here instead of quietly re-pointing every variant
409    /// at its neighbour's numbers.
410    #[test]
411    fn each_variant_is_pinned_to_the_baseline_its_name_states() {
412        assert_eq!(SizeClass::Mib512.baseline_mib(), 512);
413        assert_eq!(SizeClass::Mib1024.baseline_mib(), 1024);
414        assert_eq!(SizeClass::Mib2048.baseline_mib(), 2048);
415        assert_eq!(SizeClass::Mib4096.baseline_mib(), 4096);
416        assert_eq!(SizeClass::Mib8192.baseline_mib(), 8192);
417    }
418
419    /// **BIND-14: an exact baseline match selects that class**, on either axis alone and on
420    /// both together.
421    #[test]
422    fn a_request_equal_to_a_baseline_selects_that_class() {
423        for class in SizeClass::ALL {
424            let (vcpu, mib) = (class.baseline_vcpu(), class.baseline_mib());
425            assert_eq!(
426                SizeClass::from_request(Some(vcpu), Some(mib)).unwrap(),
427                class
428            );
429            assert_eq!(SizeClass::from_request(None, Some(mib)).unwrap(), class);
430            assert_eq!(SizeClass::from_request(Some(vcpu), None).unwrap(), class);
431        }
432    }
433
434    /// **BIND-14: the smaller class never wins by covering one axis.** 2 vCPU / 1 GiB needs
435    /// the 4096 MiB class for its CPUs; 0.5 vCPU / 3 GiB needs 4096 MiB for its memory; one
436    /// MiB or a sliver of a vCPU over a baseline moves up a class.
437    #[test]
438    fn the_axis_that_needs_more_decides_and_just_over_moves_up() {
439        let cases = [
440            (Some(2.0), Some(1024), SizeClass::Mib4096),
441            (Some(0.5), Some(3072), SizeClass::Mib4096),
442            (Some(0.25), Some(513), SizeClass::Mib1024),
443            (Some(1.01), None, SizeClass::Mib4096),
444            (None, Some(2049), SizeClass::Mib4096),
445            (Some(0.1), Some(1), SizeClass::Mib512),
446        ];
447        for (cpus, mib, expected) in cases {
448            assert_eq!(
449                SizeClass::from_request(cpus, mib).unwrap(),
450                expected,
451                "{cpus:?} vCPU / {mib:?} MiB"
452            );
453        }
454    }
455
456    /// **BIND-14: nothing requested is the default class**, not the smallest one; a zero on an
457    /// axis is no requirement on it, as the harvester's `size_class` read it.
458    #[test]
459    fn a_request_that_names_nothing_is_the_default_class() {
460        for (cpus, mib) in [
461            (None, None),
462            (Some(0.0), None),
463            (None, Some(0)),
464            (Some(0.0), Some(0)),
465        ] {
466            assert_eq!(
467                SizeClass::from_request(cpus, mib).unwrap(),
468                SizeClass::DEFAULT,
469                "{cpus:?} / {mib:?}"
470            );
471        }
472        assert_eq!(
473            SizeClass::from_request(Some(0.0), Some(600)).unwrap(),
474            SizeClass::Mib1024,
475            "one named axis is a request, and the smallest class covering it answers"
476        );
477    }
478
479    /// **BIND-14: over the largest class is a typed refusal naming it**, on either axis.
480    #[test]
481    fn a_request_over_the_largest_class_is_refused_naming_it() {
482        let largest = SizeClass::Mib8192;
483        for (cpus, mib) in [
484            (Some(4.5), None),
485            (None, Some(8193)),
486            (Some(8.0), Some(16384)),
487            (Some(4.0), Some(u32::MAX)),
488        ] {
489            let error = SizeClass::from_request(cpus, mib).expect_err("nothing covers it");
490            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
491            let message = error.to_string();
492            assert!(message.contains(&largest.to_string()), "{message}");
493            assert!(message.contains("largest size class"), "{message}");
494        }
495    }
496
497    /// A CPU figure that is not a finite non-negative number is refused, not compared.
498    #[test]
499    fn a_cpu_figure_that_is_not_a_quantity_is_refused() {
500        for cpus in [f64::NAN, f64::INFINITY, -1.0, -0.0001] {
501            let error = SizeClass::from_request(Some(cpus), None).expect_err("not a quantity");
502            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{cpus}: {error}");
503        }
504    }
505
506    /// TRAP-10: the five documented baselines are accepted and answer with their own
507    /// class.
508    #[test]
509    fn the_five_documented_baselines_select_their_own_class() {
510        for class in SizeClass::ALL {
511            let selected = SizeClass::from_baseline_mib(class.baseline_mib())
512                .expect("a documented baseline is accepted");
513            assert_eq!(selected, class);
514        }
515    }
516
517    /// TRAP-10's falsification, at the values a caller actually reaches for: a round
518    /// number between two classes, the peak of a class mistaken for a request, and
519    /// zero. 1500 is the case in `sizing.py`'s own docstring — the reading is
520    /// ambiguous in a way that changes the bill, so it is refused rather than
521    /// snapped.
522    #[test]
523    fn an_off_table_baseline_is_refused_naming_the_finding() {
524        for mib in [0, 1, 511, 513, 1500, 3072, 8191, 16384, u32::MAX] {
525            let err = SizeClass::from_baseline_mib(mib)
526                .expect_err("only the five documented baselines are accepted");
527            assert_eq!(err.kind(), ErrorKind::InvalidArg, "{mib}");
528            let message = err.to_string();
529            assert!(
530                message.contains("not a documented size class baseline"),
531                "{message}"
532            );
533            assert!(
534                message.contains("selects a class, it does not size a VM"),
535                "must name the finding: {message}"
536            );
537        }
538    }
539
540    /// A peak is not a baseline. 2048 is both — the peak of the smallest class and
541    /// the baseline of the default — and 8192 is both the peak of the default and the
542    /// largest baseline, so a caller who read a `MemTotal` and passed it back gets a
543    /// silently different class rather than an error. Nothing can be done about that
544    /// (both are legal requests), which is why `Display` always names both numbers.
545    /// 16384 and 32768 are peaks that are *not* baselines and must be refused.
546    #[test]
547    fn a_peak_that_is_not_also_a_baseline_is_refused() {
548        assert_eq!(
549            SizeClass::from_baseline_mib(2048).expect("2048 is a baseline too"),
550            SizeClass::Mib2048
551        );
552        for peak_only in [16384, 32768] {
553            assert!(
554                SizeClass::from_baseline_mib(peak_only).is_err(),
555                "{peak_only}"
556            );
557        }
558    }
559
560    /// The default is the platform's, not the cheapest, and the reason is the
561    /// ceiling: a default of `Mib512` gives the guest a hard 2 GB total —
562    /// provisioned from the start, fixed for the whole run — which a real test
563    /// suite breaches, and there is no swap to absorb the breach. The default's
564    /// job is a ceiling that survives real work.
565    #[test]
566    fn the_default_class_is_two_gigabytes_of_baseline() {
567        assert_eq!(SizeClass::DEFAULT, SizeClass::Mib2048);
568        assert_eq!(SizeClass::DEFAULT.baseline_mib(), 2048);
569        assert_eq!(SizeClass::DEFAULT.baseline_gb(), 2.0);
570        assert_eq!(SizeClass::DEFAULT.peak_gb(), 8.0);
571    }
572
573    /// Both numbers or neither, so nobody budgets for memory they are not billed for
574    /// and nobody writes a pressure test against the wrong ceiling.
575    #[test]
576    fn display_names_the_baseline_and_the_peak_together() {
577        let described = SizeClass::DEFAULT.to_string();
578        assert!(described.contains("2 GB / 1 vCPU baseline"), "{described}");
579        assert!(described.contains("8 GB / 4 vCPU"), "{described}");
580        assert!(described.contains("billed while running"), "{described}");
581        assert!(described.contains("what the guest reports"), "{described}");
582        // The mechanism, stated as it is: a fixed ceiling present from the start,
583        // not a "burst" the service would have to grant.
584        assert!(
585            described.contains("provisioned from the start"),
586            "{described}"
587        );
588        assert!(!described.contains("burst"), "{described}");
589    }
590
591    /// TRAP-13 for the headroom: peak minus baseline, both read from the table.
592    ///
593    /// The falsification: replace [`SizeClass::headroom_mib`]'s body with
594    /// `self.baseline_mib() * 3` — arithmetically identical over the shipped table,
595    /// where every peak is 4x — and this row's headroom comes back 6144 instead of
596    /// 6952. A test over the shipped table alone could not tell the two apart.
597    #[test]
598    fn headroom_follows_a_table_whose_peak_is_not_four_times_its_baseline() {
599        let mut table = SIZE_CLASSES;
600        table[SizeClass::Mib2048.index()].peak_mib = 9000;
601        assert_eq!(
602            headroom_in(&table, SizeClass::Mib2048),
603            9000 - 2048,
604            "headroom must be a difference of table values, not baseline * 3"
605        );
606        // The shipped table's headroom for every class, through the public accessor.
607        for class in SizeClass::ALL {
608            let row = &SIZE_CLASSES[class.index()];
609            assert_eq!(
610                class.headroom_mib(),
611                row.peak_mib - row.baseline_mib,
612                "{class:?}"
613            );
614        }
615    }
616
617    /// The band where an off-table figure is *plausible*, sampled deliberately.
618    ///
619    /// A uniform `u32` draw is the wrong generator for TRAP-10 and measuring it
620    /// proved so: with the whole domain to pick from, essentially every case lands
621    /// above 8192, where the only reachable verdict is "refused" and a
622    /// round-up-to-the-enclosing-class implementation is indistinguishable from a
623    /// refusing one. Snapping can only happen *between* the smallest and largest
624    /// baseline, so the property has to be told to look there. The band is what
625    /// a caller actually types.
626    fn plausible_baseline_mib() -> impl Strategy<Value = u32> {
627        prop_oneof![
628            // The whole domain, so nothing outside the band goes unchecked.
629            2 => any::<u32>(),
630            // The band where an enclosing class exists to snap to.
631            5 => 1u32..=8192,
632            // The neighbourhoods of each documented baseline, where an off-by-one or
633            // a nearest-neighbour rule would hide.
634            3 => prop::sample::select(SIZE_CLASSES.to_vec())
635                .prop_flat_map(|row| (row.baseline_mib.saturating_sub(2))..=(row.baseline_mib + 2)),
636        ]
637    }
638
639    proptest! {
640        /// The verdict, over the whole `u32` domain plus the band where an off-table
641        /// figure is plausible: accepted exactly when the figure is one of the five,
642        /// refused as `ERR_INVALID_ARG` otherwise. It asserts the verdict rather than a
643        /// side effect, because a property that only checked "no panic" would pass against
644        /// a function that accepted everything.
645        ///
646        /// The `Ok` arm's second assertion is the one that catches snapping: a
647        /// rounding implementation returns a class whose baseline is *not* the figure
648        /// it was handed, so `class.baseline_mib() == mib` is what says the answer is
649        /// the caller's request rather than a nearby one chosen for them.
650        #[test]
651        fn from_baseline_mib_accepts_exactly_the_five_documented_baselines(
652            mib in plausible_baseline_mib(),
653        ) {
654            let documented = SIZE_CLASSES.iter().any(|row| row.baseline_mib == mib);
655            match SizeClass::from_baseline_mib(mib) {
656                Ok(class) => {
657                    prop_assert!(documented, "{mib} MiB is not a documented baseline but was accepted");
658                    prop_assert_eq!(class.baseline_mib(), mib);
659                }
660                Err(err) => {
661                    prop_assert!(!documented, "{} MiB is documented but was refused", mib);
662                    prop_assert_eq!(err.kind(), ErrorKind::InvalidArg);
663                    prop_assert_eq!(err.code(), "ERR_INVALID_ARG");
664                }
665            }
666        }
667
668        /// Whatever a table says, the lookup says. Generated peaks are deliberately
669        /// unrelated to their baselines, so a computed implementation disagrees with
670        /// the table on almost every input.
671        #[test]
672        fn the_lookup_reports_whatever_peak_the_table_carries(
673            peak_mib in 1u32..100_000,
674            peak_vcpu in 0.1f64..64.0,
675        ) {
676            let mut table = SIZE_CLASSES;
677            for class in SizeClass::ALL {
678                table[class.index()].peak_mib = peak_mib;
679                table[class.index()].peak_vcpu = peak_vcpu;
680            }
681            for class in SizeClass::ALL {
682                let row = row_in(&table, class);
683                prop_assert_eq!(row.peak_mib, peak_mib);
684                prop_assert_eq!(row.peak_vcpu, peak_vcpu);
685                // The baselines are untouched, so the row is still the right row.
686                prop_assert_eq!(row.baseline_mib, class.baseline_mib());
687                prop_assert_eq!(
688                    class_for_baseline_in(&table, class.baseline_mib()),
689                    Some(class)
690                );
691            }
692        }
693    }
694}