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}