1use std::cmp::Ordering;
7
8use crate::config::ListOrAuto;
9
10use super::RunpodTarget;
11use super::types::{Availability, DataCenter, GpuType, NetworkVolume, Stock, Template};
12
13#[derive(Debug, Clone, Default, PartialEq)]
15pub struct GpuFilter {
16 pub min_vram_gb: Option<u32>,
18 pub max_price: Option<f64>,
21 pub data_center: Option<String>,
24 pub in_stock: bool,
26 pub gpu_count: Option<u32>,
28}
29
30impl GpuFilter {
31 #[must_use]
33 pub fn keeps(&self, gpu: &GpuType) -> bool {
34 gpu.on_secure_cloud()
35 && self.min_vram_gb.is_none_or(|min| gpu.memory >= min)
36 && self
37 .max_price
38 .is_none_or(|max| gpu.secure_price().is_some_and(|price| price <= max))
39 && self
40 .gpu_count
41 .is_none_or(|count| gpu.max_count.secure >= count)
42 && self.stock_ok(gpu)
43 }
44
45 fn stock_ok(&self, gpu: &GpuType) -> bool {
46 let stock = match &self.data_center {
47 Some(center) => {
48 if !gpu.data_centers.iter().any(|entry| &entry.id == center) {
49 return false;
50 }
51 gpu.stock_in(center)
52 },
53 None => gpu.availability,
54 };
55 !self.in_stock || stock.is_in_stock()
56 }
57}
58
59#[must_use]
62pub fn by_price(a: &GpuType, b: &GpuType) -> Ordering {
63 let price = match (a.secure_price(), b.secure_price()) {
64 (Some(x), Some(y)) => x.total_cmp(&y),
65 (Some(_), None) => Ordering::Less,
66 (None, Some(_)) => Ordering::Greater,
67 (None, None) => Ordering::Equal,
68 };
69 price
70 .then_with(|| b.memory.cmp(&a.memory))
71 .then_with(|| a.id.cmp(&b.id))
72}
73
74#[must_use]
76pub fn select_gpus(gpus: &[GpuType], filter: &GpuFilter) -> Vec<GpuType> {
77 let mut kept: Vec<GpuType> = gpus
78 .iter()
79 .filter(|gpu| filter.keeps(gpu))
80 .cloned()
81 .collect();
82 kept.sort_by(by_price);
83 kept
84}
85
86#[must_use]
91pub fn gpu_table(gpus: &[GpuType], data_center: Option<&str>) -> Vec<String> {
92 if gpus.is_empty() {
93 return Vec::new();
94 }
95 let rows: Vec<Vec<String>> = gpus
96 .iter()
97 .map(|gpu| {
98 let price = gpu
99 .secure_price()
100 .map_or_else(|| "-".to_string(), |price| format!("{price:.2}"));
101 let stock = data_center.map_or(gpu.availability, |center| gpu.stock_in(center));
102 vec![
103 gpu.id.clone(),
104 gpu.memory.to_string(),
105 price,
106 gpu.max_count.secure.to_string(),
107 stock.name().to_string(),
108 ]
109 })
110 .collect();
111 columns(
112 &["ID", "VRAM GB", "$/H", "MAX COUNT", "STOCK"],
113 &[
114 Align::Left,
115 Align::Right,
116 Align::Right,
117 Align::Right,
118 Align::Left,
119 ],
120 &rows,
121 )
122}
123
124#[must_use]
129pub fn printable(text: &str) -> String {
130 let mut kept = String::with_capacity(text.len());
131 let mut chars = text.chars().peekable();
132 while let Some(c) = chars.next() {
133 match c {
134 '\u{1b}' => match chars.next() {
135 Some('[') => skip_csi(&mut chars),
136 Some(']' | 'P' | 'X' | '^' | '_') => skip_string(&mut chars),
137 _ => {},
138 },
139 '\u{9b}' => skip_csi(&mut chars),
140 '\u{90}' | '\u{98}' | '\u{9d}' | '\u{9e}' | '\u{9f}' => skip_string(&mut chars),
141 c if c.is_control() && c.is_whitespace() => kept.push(' '),
142 c if c.is_control() => {},
143 c => kept.push(c),
144 }
145 }
146 kept
147}
148
149fn skip_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
151 while chars.next_if(|c| matches!(c, ' '..='?')).is_some() {}
152 chars.next_if(|c| matches!(c, '@'..='~'));
153}
154
155fn skip_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
158 while let Some(c) = chars.next() {
159 match c {
160 '\u{7}' | '\u{9c}' => return,
161 '\u{1b}' => {
162 chars.next_if_eq(&'\\');
163 return;
164 },
165 _ => {},
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum Align {
173 Left,
174 Right,
175}
176
177fn columns(header: &[&str], align: &[Align], rows: &[Vec<String>]) -> Vec<String> {
181 let rows: Vec<Vec<String>> = rows
182 .iter()
183 .map(|row| row.iter().map(|cell| printable(cell)).collect())
184 .collect();
185 let mut widths: Vec<usize> = header.iter().map(|title| title.chars().count()).collect();
186 for row in &rows {
187 for (width, cell) in widths.iter_mut().zip(row) {
188 *width = (*width).max(cell.chars().count());
189 }
190 }
191 let line = |cells: Vec<&str>| {
192 let last = cells.len().saturating_sub(1);
193 let padded: Vec<String> = cells
194 .iter()
195 .zip(&widths)
196 .zip(align)
197 .enumerate()
198 .map(|(index, ((cell, width), side))| match side {
199 _ if index == last => (*cell).to_string(),
200 Align::Left => format!("{cell:<width$}"),
201 Align::Right => format!("{cell:>width$}"),
202 })
203 .collect();
204 padded.join(" ")
205 };
206 let mut lines = vec![line(header.to_vec())];
207 lines.extend(
208 rows.iter()
209 .map(|row| line(row.iter().map(String::as_str).collect())),
210 );
211 lines
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct DataCenterStock {
217 pub id: String,
219 pub name: String,
221 pub region: String,
223 pub stock: Vec<Stock>,
226}
227
228impl DataCenterStock {
229 #[must_use]
231 pub fn in_stock(&self) -> bool {
232 self.stock
233 .iter()
234 .any(|entry| entry.availability.is_in_stock())
235 }
236}
237
238#[must_use]
240pub fn data_center_stock<S: AsRef<str>>(
241 data_centers: &[DataCenter],
242 chosen: &[S],
243) -> Vec<DataCenterStock> {
244 let mut rows: Vec<DataCenterStock> = data_centers
245 .iter()
246 .map(|center| DataCenterStock {
247 id: center.id.clone(),
248 name: center.name.clone(),
249 region: center.region.clone(),
250 stock: chosen
251 .iter()
252 .map(|gpu| {
253 let gpu = gpu.as_ref();
254 center
255 .gpu_availability
256 .iter()
257 .find(|entry| entry.id == gpu)
258 .cloned()
259 .unwrap_or_else(|| Stock {
260 id: gpu.to_string(),
261 name: String::new(),
262 availability: Availability::None,
263 })
264 })
265 .collect(),
266 })
267 .collect();
268 rows.sort_by(|a, b| a.id.cmp(&b.id));
269 rows
270}
271
272#[must_use]
276pub fn data_center_table(data_centers: &[DataCenter]) -> Vec<String> {
277 if data_centers.is_empty() {
278 return Vec::new();
279 }
280 let mut rows: Vec<&DataCenter> = data_centers.iter().collect();
281 rows.sort_by(|a, b| a.id.cmp(&b.id));
282 let rows: Vec<Vec<String>> = rows
283 .into_iter()
284 .map(|center| {
285 let in_stock = center
286 .gpu_availability
287 .iter()
288 .filter(|entry| entry.availability.is_in_stock())
289 .count();
290 vec![
291 center.id.clone(),
292 center.name.clone(),
293 center.region.clone(),
294 in_stock.to_string(),
295 ]
296 })
297 .collect();
298 columns(
299 &["ID", "NAME", "REGION", "GPU TYPES IN STOCK"],
300 &[Align::Left; 4],
301 &rows,
302 )
303}
304
305#[must_use]
314pub fn stocked_data_centers<S: AsRef<str>>(gpus: &[GpuType], chosen: &[S]) -> Vec<DataCenterStock> {
315 let chosen_gpus: Vec<&GpuType> = chosen
316 .iter()
317 .filter_map(|id| gpus.iter().find(|gpu| gpu.id == id.as_ref()))
318 .collect();
319 let mut ids: Vec<(String, String)> = Vec::new();
320 for gpu in &chosen_gpus {
321 for entry in &gpu.data_centers {
322 if entry.availability.is_in_stock() && !ids.iter().any(|(id, _)| *id == entry.id) {
323 ids.push((entry.id.clone(), entry.name.clone()));
324 }
325 }
326 }
327 let cheapest = |row: &DataCenterStock| -> Option<&GpuType> {
328 row.stock
329 .iter()
330 .filter(|entry| entry.availability.is_in_stock())
331 .filter_map(|entry| gpus.iter().find(|gpu| gpu.id == entry.id))
332 .min_by(|a, b| by_price(a, b))
333 };
334 let mut rows: Vec<DataCenterStock> = ids
335 .into_iter()
336 .map(|(id, name)| DataCenterStock {
337 stock: chosen
338 .iter()
339 .map(|gpu_id| gpu_stock_at(&chosen_gpus, gpu_id.as_ref(), &id))
340 .collect(),
341 id,
342 name,
343 region: String::new(),
344 })
345 .collect();
346 rows.sort_by(|a, b| {
347 let order = match (cheapest(a), cheapest(b)) {
348 (Some(x), Some(y)) => by_price(x, y),
349 (Some(_), None) => Ordering::Less,
350 (None, Some(_)) => Ordering::Greater,
351 (None, None) => Ordering::Equal,
352 };
353 order.then_with(|| a.id.cmp(&b.id))
354 });
355 rows
356}
357
358fn gpu_stock_at(chosen_gpus: &[&GpuType], gpu_id: &str, center_id: &str) -> Stock {
362 let availability = chosen_gpus
363 .iter()
364 .find(|gpu| gpu.id == gpu_id)
365 .and_then(|gpu| gpu.data_centers.iter().find(|entry| entry.id == center_id))
366 .map_or(Availability::None, |entry| entry.availability);
367 Stock {
368 id: gpu_id.to_string(),
369 name: String::new(),
370 availability,
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
377pub enum ResolveError {
378 #[error("no GPU type in stock on Runpod's Secure Cloud for gpu_types = \"auto\" ({asked})")]
380 NoGpuType {
381 asked: String,
383 },
384 #[error(
387 "no data center has {gpu_types} in stock for data_center_ids = \"auto\" \
388 (gpu_count = {gpu_count})"
389 )]
390 NoDataCenter {
391 gpu_types: String,
393 gpu_count: u32,
395 },
396}
397
398pub fn resolve(target: &RunpodTarget, gpus: &[GpuType]) -> Result<RunpodTarget, ResolveError> {
414 resolve_with_floor(target, gpus, None)
415}
416
417pub fn resolve_with_floor(
427 target: &RunpodTarget,
428 gpus: &[GpuType],
429 floor_gb: Option<u32>,
430) -> Result<RunpodTarget, ResolveError> {
431 let mut resolved = target.clone();
432 let floor = floor_gb.filter(|_| target.min_vram_gb.is_none());
433 if target.gpu_types.is_auto() {
434 let filter = GpuFilter {
435 min_vram_gb: target.min_vram_gb.or(floor),
436 max_price: target.max_price_per_hour,
437 data_center: None,
438 in_stock: true,
439 gpu_count: Some(target.gpu_count),
440 };
441 let listed = target.data_center_ids.list();
442 let chosen: Vec<String> = select_gpus(gpus, &filter)
443 .into_iter()
444 .filter(|gpu| {
445 listed.is_empty()
446 || listed
447 .iter()
448 .any(|center| gpu.stock_in(center).is_in_stock())
449 })
450 .map(|gpu| gpu.id)
451 .collect();
452 if chosen.is_empty() {
453 return Err(ResolveError::NoGpuType {
454 asked: asked(target, floor),
455 });
456 }
457 resolved.gpu_types = ListOrAuto::List(chosen);
458 }
459 if target.data_center_ids.is_auto() {
460 let chosen = resolved.gpu_types.list();
461 let centers: Vec<String> = stocked_data_centers(gpus, chosen)
462 .into_iter()
463 .map(|row| row.id)
464 .collect();
465 if centers.is_empty() {
466 return Err(ResolveError::NoDataCenter {
467 gpu_types: or_list(chosen),
468 gpu_count: target.gpu_count,
469 });
470 }
471 resolved.data_center_ids = ListOrAuto::List(centers);
472 }
473 Ok(resolved)
474}
475
476#[derive(Debug, Clone, PartialEq)]
478pub struct GpuOffer {
479 pub gpu_type: String,
481 pub per_hour: f64,
483}
484
485#[must_use]
491pub fn cheapest_in_stock(
492 target: &RunpodTarget,
493 gpus: &[GpuType],
494 floor_gb: Option<u32>,
495) -> Option<GpuOffer> {
496 let resolved = resolve_with_floor(target, gpus, floor_gb).ok()?;
497 let wanted = resolved.gpu_types.list();
498 let filter = GpuFilter {
499 in_stock: true,
500 gpu_count: Some(target.gpu_count),
501 ..GpuFilter::default()
502 };
503 select_gpus(gpus, &filter)
504 .into_iter()
505 .filter(|gpu| wanted.contains(&gpu.id))
506 .find_map(|gpu| {
507 gpu.secure_price().map(|price| GpuOffer {
508 per_hour: price * f64::from(target.gpu_count),
509 gpu_type: gpu.id,
510 })
511 })
512}
513
514pub(crate) const MAX_OUT_OF_STOCK: usize = 3;
517
518#[derive(Debug, Clone, PartialEq)]
520pub(crate) struct WalkOrder {
521 pub target: RunpodTarget,
524 pub out_of_stock: Vec<String>,
526}
527
528pub(crate) fn walk_order(
547 target: &RunpodTarget,
548 gpus: &[GpuType],
549 floor_gb: Option<u32>,
550 tried: &[String],
551 cap: usize,
552) -> Result<WalkOrder, ResolveError> {
553 let mut resolved = resolve_with_floor(target, gpus, floor_gb)?;
554 if !target.gpu_types.is_auto() {
555 return Ok(WalkOrder {
556 target: resolved,
557 out_of_stock: Vec::new(),
558 });
559 }
560 let in_stock = resolved.gpu_types.list().to_vec();
561 let untried: Vec<String> = in_stock
562 .iter()
563 .filter(|id| !tried.contains(id))
564 .cloned()
565 .collect();
566 let floor = floor_gb.filter(|_| target.min_vram_gb.is_none());
567 let Some(cheapest) = untried.first() else {
568 return Err(ResolveError::NoGpuType {
569 asked: asked(target, floor),
570 });
571 };
572 let below = gpus
573 .iter()
574 .find(|gpu| &gpu.id == cheapest)
575 .and_then(GpuType::secure_price);
576 let out_of_stock: Vec<String> = match below {
577 None => Vec::new(),
578 Some(below) => {
579 let filter = GpuFilter {
580 min_vram_gb: target.min_vram_gb.or(floor),
581 max_price: target.max_price_per_hour,
582 data_center: None,
583 in_stock: false,
584 gpu_count: Some(target.gpu_count),
585 };
586 let listed = target.data_center_ids.list();
587 select_gpus(gpus, &filter)
588 .into_iter()
589 .filter(|gpu| !in_stock.contains(&gpu.id) && !tried.contains(&gpu.id))
590 .filter(|gpu| gpu.secure_price().is_some_and(|price| price < below))
591 .filter(|gpu| {
592 listed.is_empty()
593 || gpu.data_centers.is_empty()
594 || gpu
595 .data_centers
596 .iter()
597 .any(|entry| listed.contains(&entry.id))
598 })
599 .take(cap)
600 .map(|gpu| gpu.id)
601 .collect()
602 },
603 };
604 if target.data_center_ids.is_auto() {
605 let centers: Vec<String> = stocked_data_centers(gpus, &untried)
606 .into_iter()
607 .map(|row| row.id)
608 .collect();
609 if centers.is_empty() {
610 return Err(ResolveError::NoDataCenter {
611 gpu_types: or_list(&untried),
612 gpu_count: target.gpu_count,
613 });
614 }
615 resolved.data_center_ids = ListOrAuto::List(centers);
616 }
617 resolved.gpu_types = ListOrAuto::List(out_of_stock.iter().chain(&untried).cloned().collect());
618 Ok(WalkOrder {
619 target: resolved,
620 out_of_stock,
621 })
622}
623
624fn asked(target: &RunpodTarget, floor: Option<u32>) -> String {
627 let mut asked = vec![format!("gpu_count = {}", target.gpu_count)];
628 if let Some(gb) = target.min_vram_gb {
629 asked.push(format!("min_vram_gb = {gb}"));
630 }
631 if let Some(gb) = floor {
632 asked.push(format!("at least {gb} GB of VRAM estimated for the model"));
633 }
634 if let Some(price) = target.max_price_per_hour {
635 asked.push(format!("max_price_per_hour = {price}"));
636 }
637 if let ListOrAuto::List(centers) = &target.data_center_ids
638 && !centers.is_empty()
639 {
640 asked.push(format!("data_center_ids = {}", centers.join(", ")));
641 }
642 asked.join(", ")
643}
644
645fn or_list(items: &[String]) -> String {
647 match items.split_last() {
648 None => String::new(),
649 Some((last, [])) => last.clone(),
650 Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
651 }
652}
653
654#[must_use]
657pub fn volume_table(volumes: &[NetworkVolume]) -> Vec<String> {
658 if volumes.is_empty() {
659 return Vec::new();
660 }
661 let mut rows: Vec<&NetworkVolume> = volumes.iter().collect();
662 rows.sort_by(|a, b| a.name.cmp(&b.name));
663 let rows: Vec<Vec<String>> = rows
664 .into_iter()
665 .map(|volume| {
666 vec![
667 volume.id.clone(),
668 volume.name.clone(),
669 volume.size.to_string(),
670 volume.data_center.clone(),
671 ]
672 })
673 .collect();
674 columns(
675 &["ID", "NAME", "SIZE GB", "DATA CENTER"],
676 &[Align::Left, Align::Left, Align::Right, Align::Left],
677 &rows,
678 )
679}
680
681#[must_use]
684pub fn template_table(templates: &[Template]) -> Vec<String> {
685 if templates.is_empty() {
686 return Vec::new();
687 }
688 let mut rows: Vec<&Template> = templates.iter().collect();
689 rows.sort_by(|a, b| a.name.cmp(&b.name));
690 let rows: Vec<Vec<String>> = rows
691 .into_iter()
692 .map(|template| {
693 vec![
694 template.id.clone(),
695 template.name.clone(),
696 template.image.clone(),
697 ]
698 })
699 .collect();
700 columns(&["ID", "NAME", "IMAGE"], &[Align::Left; 3], &rows)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::config::ListOrAuto;
707 use crate::runpod::types::{GpuMaxCount, GpuPrice};
708
709 fn stock(id: &str, availability: Availability) -> Stock {
710 Stock {
711 id: id.to_string(),
712 name: String::new(),
713 availability,
714 }
715 }
716
717 fn gpu(id: &str, memory: u32, price: Option<f64>) -> GpuType {
718 GpuType {
719 id: id.to_string(),
720 memory,
721 price: GpuPrice {
722 secure: price,
723 community: None,
724 },
725 max_count: GpuMaxCount {
726 secure: 8,
727 community: 0,
728 },
729 availability: Availability::High,
730 ..GpuType::default()
731 }
732 }
733
734 fn target(gpu_types: ListOrAuto, data_center_ids: ListOrAuto) -> RunpodTarget {
736 RunpodTarget {
737 gpu_types,
738 min_vram_gb: None,
739 max_price_per_hour: None,
740 gpu_count: 1,
741 image: "img".to_string(),
742 venv: Some("/venv".to_string()),
743 container_disk_gb: 50,
744 max_hours: 1.0,
745 max_cost_usd: None,
746 boot_grace: std::time::Duration::from_mins(30),
747 retrieve_grace: std::time::Duration::from_secs(3600),
748 data_center_ids,
749 network_volume_id: None,
750 max_volume_gb: None,
751 ssh_client: crate::config::SshClient::Openssh,
752 }
753 }
754
755 fn list(items: &[&str]) -> ListOrAuto {
756 ListOrAuto::List(items.iter().map(|item| (*item).to_string()).collect())
757 }
758
759 fn ids(gpus: &[GpuType]) -> Vec<&str> {
760 gpus.iter().map(|gpu| gpu.id.as_str()).collect()
761 }
762
763 fn center(id: &str, gpus: Vec<Stock>) -> DataCenter {
764 DataCenter {
765 id: id.to_string(),
766 name: format!("{id} name"),
767 region: "EUROPE".to_string(),
768 gpu_availability: gpus,
769 }
770 }
771
772 #[test]
773 fn gpus_are_cheapest_first_ties_by_more_vram_then_id() {
774 let gpus = [
775 gpu("none", 80, None),
776 gpu("b", 24, Some(0.5)),
777 gpu("big", 48, Some(0.5)),
778 gpu("a", 24, Some(0.5)),
779 gpu("cheap", 16, Some(0.2)),
780 ];
781 let sorted = select_gpus(&gpus, &GpuFilter::default());
782 assert_eq!(ids(&sorted), vec!["cheap", "big", "a", "b", "none"]);
783 }
784
785 #[test]
786 fn a_zero_price_is_unknown_neither_first_nor_under_a_limit() {
787 let gpus = [gpu("free", 24, Some(0.0)), gpu("paid", 24, Some(0.5))];
788 let sorted = select_gpus(&gpus, &GpuFilter::default());
789 assert_eq!(ids(&sorted), vec!["paid", "free"]);
790 let capped = GpuFilter {
791 max_price: Some(1.0),
792 ..GpuFilter::default()
793 };
794 assert_eq!(ids(&select_gpus(&gpus, &capped)), vec!["paid"]);
795 }
796
797 #[test]
798 fn vram_and_price_limits_filter() {
799 let gpus = [
800 gpu("small", 16, Some(0.2)),
801 gpu("mid", 48, Some(0.8)),
802 gpu("dear", 80, Some(2.5)),
803 gpu("unpriced", 80, None),
804 ];
805 let filter = GpuFilter {
806 min_vram_gb: Some(40),
807 max_price: Some(1.0),
808 ..GpuFilter::default()
809 };
810 assert_eq!(ids(&select_gpus(&gpus, &filter)), vec!["mid"]);
811 let vram_only = GpuFilter {
812 min_vram_gb: Some(48),
813 ..GpuFilter::default()
814 };
815 assert_eq!(
816 ids(&select_gpus(&gpus, &vram_only)),
817 vec!["mid", "dear", "unpriced"]
818 );
819 }
820
821 #[test]
822 fn stock_and_gpu_count_filter() {
823 let mut out = gpu("out", 24, Some(0.1));
824 out.availability = Availability::None;
825 let mut unknown = gpu("unknown", 24, Some(0.2));
826 unknown.availability = Availability::Unknown;
827 let mut single = gpu("single", 24, Some(0.3));
828 single.max_count.secure = 1;
829 let gpus = [out, unknown, single, gpu("ok", 24, Some(0.4))];
830 let in_stock = GpuFilter {
831 in_stock: true,
832 ..GpuFilter::default()
833 };
834 assert_eq!(ids(&select_gpus(&gpus, &in_stock)), vec!["single", "ok"]);
835 let two = GpuFilter {
836 in_stock: true,
837 gpu_count: Some(2),
838 ..GpuFilter::default()
839 };
840 assert_eq!(ids(&select_gpus(&gpus, &two)), vec!["ok"]);
841 }
842
843 #[test]
844 fn community_only_gpus_are_dropped() {
845 let mut community = gpu("community", 24, Some(0.1));
846 community.secure = Some(false);
847 let gpus = [community, gpu("secure", 24, Some(0.2))];
848 assert_eq!(
849 ids(&select_gpus(&gpus, &GpuFilter::default())),
850 vec!["secure"]
851 );
852 }
853
854 #[test]
855 fn a_data_center_filter_judges_stock_there() {
856 let mut here = gpu("here", 24, Some(0.3));
857 here.data_centers = vec![stock("EU-RO-1", Availability::Low)];
858 let mut empty_here = gpu("empty-here", 24, Some(0.2));
859 empty_here.data_centers = vec![stock("EU-RO-1", Availability::None)];
860 let mut elsewhere = gpu("elsewhere", 24, Some(0.1));
861 elsewhere.data_centers = vec![stock("US-KS-2", Availability::High)];
862 let gpus = [here, empty_here, elsewhere];
863 let offered = GpuFilter {
864 data_center: Some("EU-RO-1".to_string()),
865 ..GpuFilter::default()
866 };
867 assert_eq!(
868 ids(&select_gpus(&gpus, &offered)),
869 vec!["empty-here", "here"]
870 );
871 let stocked = GpuFilter {
872 in_stock: true,
873 ..offered
874 };
875 assert_eq!(ids(&select_gpus(&gpus, &stocked)), vec!["here"]);
876 }
877
878 #[test]
879 fn data_centers_show_the_chosen_gpus_stock_in_order() {
880 let centers = [
881 center("US-KS-2", vec![stock("A", Availability::High)]),
882 center(
883 "EU-RO-1",
884 vec![
885 stock("B", Availability::Medium),
886 stock("A", Availability::None),
887 ],
888 ),
889 ];
890 let rows = data_center_stock(¢ers, &["B", "A", "C"]);
891 let row_ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
892 assert_eq!(row_ids, vec!["EU-RO-1", "US-KS-2"]);
893 let bands: Vec<Availability> = rows[0].stock.iter().map(|s| s.availability).collect();
894 assert_eq!(
895 bands,
896 vec![Availability::Medium, Availability::None, Availability::None]
897 );
898 assert_eq!(rows[1].stock[0].id, "B");
899 assert!(rows[0].in_stock() && rows[1].in_stock());
900 assert!(!data_center_stock(¢ers, &["C"])[0].in_stock());
901 assert_eq!(rows[0].name, "EU-RO-1 name");
902 assert_eq!(rows[0].region, "EUROPE");
903 }
904
905 #[test]
906 fn stocked_data_centers_are_ordered_by_their_cheapest_chosen_gpu() {
907 let mut cheap = gpu("cheap", 24, Some(0.2));
908 cheap.data_centers = vec![
909 stock("B-EMPTY", Availability::None),
910 stock("C-CHEAP", Availability::Low),
911 stock("D-CHEAP", Availability::High),
912 ];
913 let mut dear = gpu("dear", 80, Some(1.5));
914 dear.data_centers = vec![
915 stock("A-DEAR", Availability::High),
916 stock("B-EMPTY", Availability::None),
917 stock("C-CHEAP", Availability::High),
918 ];
919 let gpus = [cheap, dear];
922 let rows = stocked_data_centers(&gpus, &["dear", "cheap"]);
923 let row_ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
924 assert_eq!(row_ids, vec!["C-CHEAP", "D-CHEAP", "A-DEAR"]);
925 }
926
927 #[test]
928 fn listed_choices_are_kept_as_they_are() {
929 let listed = target(list(&["B", "A"]), list(&["EU-RO-1"]));
930 assert_eq!(resolve(&listed, &[]), Ok(listed.clone()));
931 let any = target(list(&["A"]), ListOrAuto::default());
932 assert_eq!(resolve(&any, &[]), Ok(any.clone()));
933 }
934
935 #[test]
936 fn auto_gpus_are_those_in_stock_cheapest_first_ties_by_more_vram() {
937 let mut out = gpu("out", 80, Some(0.1));
938 out.availability = Availability::None;
939 let mut single = gpu("single", 80, Some(0.2));
940 single.max_count.secure = 1;
941 let mut community = gpu("community", 80, Some(0.2));
942 community.secure = Some(false);
943 let gpus = [
944 out,
945 single,
946 community,
947 gpu("dear", 80, Some(1.0)),
948 gpu("a", 24, Some(0.5)),
949 gpu("big", 48, Some(0.5)),
950 ];
951 let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
952 auto.gpu_count = 2;
953 let resolved = resolve(&auto, &gpus);
954 assert_eq!(
955 resolved.map(|target| target.gpu_types),
956 Ok(list(&["big", "a", "dear"]))
957 );
958 }
959
960 #[test]
964 fn the_cheapest_gpu_in_stock_prices_the_pod() {
965 let mut out = gpu("out", 80, Some(0.1));
966 out.availability = Availability::None;
967 let gpus = [
968 out,
969 gpu("small", 16, Some(0.2)),
970 gpu("mid", 48, Some(0.8)),
971 gpu("unpriced", 80, None),
972 ];
973 let offer = |gpu_type: &str, per_hour| {
974 Some(GpuOffer {
975 gpu_type: gpu_type.to_string(),
976 per_hour,
977 })
978 };
979 let auto = target(ListOrAuto::Auto, ListOrAuto::default());
980 assert_eq!(cheapest_in_stock(&auto, &gpus, None), offer("small", 0.2));
981 assert_eq!(cheapest_in_stock(&auto, &gpus, Some(20)), offer("mid", 0.8));
982 let mut listed = target(list(&["out", "unpriced", "mid"]), ListOrAuto::default());
983 listed.gpu_count = 2;
984 assert_eq!(cheapest_in_stock(&listed, &gpus, None), offer("mid", 1.6));
985 let sold_out = target(list(&["out", "unpriced"]), ListOrAuto::default());
986 assert_eq!(cheapest_in_stock(&sold_out, &gpus, None), None);
987 }
988
989 #[test]
990 fn auto_gpus_meet_the_vram_and_price_limits() {
991 let gpus = [
992 gpu("small", 16, Some(0.2)),
993 gpu("mid", 48, Some(0.8)),
994 gpu("dear", 80, Some(2.5)),
995 gpu("unpriced", 80, None),
996 ];
997 let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
998 auto.min_vram_gb = Some(40);
999 assert_eq!(
1000 resolve(&auto, &gpus).map(|target| target.gpu_types),
1001 Ok(list(&["mid", "dear", "unpriced"]))
1002 );
1003 auto.max_price_per_hour = Some(1.0);
1004 assert_eq!(
1005 resolve(&auto, &gpus).map(|target| target.gpu_types),
1006 Ok(list(&["mid"]))
1007 );
1008 }
1009
1010 #[test]
1011 fn the_estimated_floor_applies_only_without_min_vram_gb() {
1012 let gpus = [
1013 gpu("small", 16, Some(0.2)),
1014 gpu("mid", 48, Some(0.8)),
1015 gpu("dear", 80, Some(2.5)),
1016 ];
1017 let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
1018 let picked = |target: &RunpodTarget, floor| {
1019 resolve_with_floor(target, &gpus, floor).map(|target| target.gpu_types)
1020 };
1021 assert_eq!(picked(&auto, Some(20)), Ok(list(&["mid", "dear"])));
1022 assert_eq!(picked(&auto, None), Ok(list(&["small", "mid", "dear"])));
1023 assert_eq!(
1025 resolve_with_floor(&auto, &gpus, Some(20)).map(|target| target.min_vram_gb),
1026 Ok(None)
1027 );
1028 auto.min_vram_gb = Some(10);
1029 assert_eq!(picked(&auto, Some(60)), Ok(list(&["small", "mid", "dear"])));
1030 auto.min_vram_gb = None;
1031 assert_eq!(
1032 picked(&auto, Some(100)),
1033 Err(ResolveError::NoGpuType {
1034 asked: "gpu_count = 1, at least 100 GB of VRAM estimated for the model".into()
1035 })
1036 );
1037 let listed = target(list(&["small"]), ListOrAuto::default());
1038 assert_eq!(picked(&listed, Some(60)), Ok(list(&["small"])));
1039 }
1040
1041 #[test]
1042 fn auto_gpus_are_in_stock_in_a_listed_data_center() {
1043 let mut here = gpu("here", 24, Some(0.3));
1044 here.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1045 let mut empty_here = gpu("empty-here", 24, Some(0.2));
1046 empty_here.data_centers = vec![stock("EU-RO-1", Availability::None)];
1047 let mut elsewhere = gpu("elsewhere", 24, Some(0.1));
1048 elsewhere.data_centers = vec![stock("US-KS-2", Availability::High)];
1049 let gpus = [here, empty_here, elsewhere];
1050 let auto = target(ListOrAuto::Auto, list(&["EU-RO-1"]));
1051 let resolved = resolve(&auto, &gpus);
1052 assert_eq!(
1053 resolved.clone().map(|target| target.gpu_types),
1054 Ok(list(&["here"]))
1055 );
1056 assert_eq!(
1057 resolved.map(|target| target.data_center_ids),
1058 Ok(list(&["EU-RO-1"]))
1059 );
1060 }
1061
1062 #[test]
1063 fn auto_data_centers_have_a_chosen_gpu_in_stock_cheapest_first() {
1064 let mut cheap = gpu("cheap", 24, Some(0.2));
1065 cheap.data_centers = vec![
1066 stock("B-EMPTY", Availability::None),
1067 stock("C-CHEAP", Availability::Low),
1068 ];
1069 let mut dear = gpu("dear", 80, Some(1.5));
1070 dear.data_centers = vec![stock("A-DEAR", Availability::High)];
1071 let gpus = [cheap, dear];
1072 let listed = target(list(&["dear", "cheap"]), ListOrAuto::Auto);
1073 let resolved = resolve(&listed, &gpus);
1074 assert_eq!(
1075 resolved.clone().map(|target| target.data_center_ids),
1076 Ok(list(&["C-CHEAP", "A-DEAR"]))
1077 );
1078 assert_eq!(
1079 resolved.map(|target| target.gpu_types),
1080 Ok(list(&["dear", "cheap"]))
1081 );
1082 let both = target(ListOrAuto::Auto, ListOrAuto::Auto);
1083 let resolved = resolve(&both, &gpus);
1084 assert_eq!(
1085 resolved.clone().map(|target| target.gpu_types),
1086 Ok(list(&["cheap", "dear"]))
1087 );
1088 assert_eq!(
1089 resolved.map(|target| target.data_center_ids),
1090 Ok(list(&["C-CHEAP", "A-DEAR"]))
1091 );
1092 }
1093
1094 #[test]
1095 fn nothing_in_stock_says_what_was_asked() {
1096 let mut out = gpu("out", 80, Some(0.1));
1097 out.availability = Availability::None;
1098 let gpus = [out, gpu("small", 16, Some(0.2))];
1099 let mut auto = target(ListOrAuto::Auto, list(&["EU-RO-1", "US-KS-2"]));
1100 auto.gpu_count = 2;
1101 auto.min_vram_gb = Some(48);
1102 auto.max_price_per_hour = Some(0.5);
1103 assert_eq!(
1104 resolve(&auto, &gpus).map_err(|error| error.to_string()),
1105 Err(
1106 "no GPU type in stock on Runpod's Secure Cloud for gpu_types = \"auto\" \
1107 (gpu_count = 2, min_vram_gb = 48, max_price_per_hour = 0.5, \
1108 data_center_ids = EU-RO-1, US-KS-2)"
1109 .to_string()
1110 )
1111 );
1112 let plain = target(ListOrAuto::Auto, ListOrAuto::default());
1113 assert_eq!(
1114 resolve(&plain, &[]).map_err(|error| error.to_string()),
1115 Err(
1116 "no GPU type in stock on Runpod's Secure Cloud for gpu_types = \"auto\" \
1117 (gpu_count = 1)"
1118 .to_string()
1119 )
1120 );
1121 let mut a = gpu("A", 24, Some(0.3));
1122 a.data_centers = vec![stock("EU-RO-1", Availability::None)];
1123 let listed = target(list(&["A", "B"]), ListOrAuto::Auto);
1124 assert_eq!(
1125 resolve(&listed, &[a]).map_err(|error| error.to_string()),
1126 Err(
1127 "no data center has A or B in stock for data_center_ids = \"auto\" \
1128 (gpu_count = 1)"
1129 .to_string()
1130 )
1131 );
1132 }
1133
1134 #[test]
1135 fn gpu_table_is_empty_without_rows() {
1136 assert_eq!(gpu_table(&[], None), [] as [String; 0]);
1137 }
1138
1139 #[test]
1140 fn gpu_table_lists_price_vram_count_and_overall_stock() {
1141 let mut a40 = gpu("NVIDIA A40", 48, Some(0.4));
1142 a40.availability = Availability::High;
1143 let mut cheap = gpu("cheap", 16, None);
1144 cheap.availability = Availability::Low;
1145 let lines = gpu_table(&[a40, cheap], None);
1146 assert_eq!(lines.len(), 3, "{lines:?}");
1147 assert!(
1148 lines[0].starts_with("ID") && lines[0].ends_with("STOCK"),
1149 "{}",
1150 lines[0]
1151 );
1152 assert!(
1153 lines[1].starts_with("NVIDIA A40")
1154 && lines[1].contains("48")
1155 && lines[1].contains("0.40")
1156 && lines[1].ends_with("HIGH"),
1157 "{}",
1158 lines[1]
1159 );
1160 assert!(
1161 lines[2].starts_with("cheap") && lines[2].ends_with("LOW"),
1162 "{}",
1163 lines[2]
1164 );
1165 }
1166
1167 #[test]
1168 fn gpu_table_shows_stock_at_a_data_center_when_given() {
1169 let mut a40 = gpu("NVIDIA A40", 48, Some(0.4));
1170 a40.availability = Availability::High;
1171 a40.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1172 let lines = gpu_table(&[a40], Some("EU-RO-1"));
1173 assert_eq!(lines.len(), 2, "{lines:?}");
1174 assert!(lines[1].ends_with("LOW"), "{}", lines[1]);
1175 }
1176
1177 #[test]
1178 fn data_center_table_is_empty_without_rows() {
1179 assert_eq!(data_center_table(&[]), [] as [String; 0]);
1180 }
1181
1182 #[test]
1183 fn data_center_table_counts_gpu_types_in_stock_sorted_by_id() {
1184 let centers = [
1185 center(
1186 "B-ID",
1187 vec![
1188 stock("x", Availability::High),
1189 stock("y", Availability::None),
1190 ],
1191 ),
1192 center("A-ID", vec![stock("x", Availability::Low)]),
1193 ];
1194 let lines = data_center_table(¢ers);
1195 assert_eq!(lines.len(), 3, "{lines:?}");
1196 assert!(
1197 lines[0].starts_with("ID") && lines[0].ends_with("GPU TYPES IN STOCK"),
1198 "{}",
1199 lines[0]
1200 );
1201 assert!(
1202 lines[1].starts_with("A-ID") && lines[1].ends_with('1'),
1203 "{}",
1204 lines[1]
1205 );
1206 assert!(
1207 lines[2].starts_with("B-ID") && lines[2].ends_with('1'),
1208 "{}",
1209 lines[2]
1210 );
1211 }
1212
1213 #[test]
1214 fn volume_table_is_empty_without_rows() {
1215 assert_eq!(volume_table(&[]), [] as [String; 0]);
1216 }
1217
1218 #[test]
1219 fn volume_table_is_sorted_by_name() {
1220 let volumes = [
1221 NetworkVolume {
1222 id: "v2".to_string(),
1223 name: "zeta".to_string(),
1224 size: 50,
1225 data_center: "EU-RO-1".to_string(),
1226 },
1227 NetworkVolume {
1228 id: "v1".to_string(),
1229 name: "alpha".to_string(),
1230 size: 100,
1231 data_center: "US-KS-2".to_string(),
1232 },
1233 ];
1234 let lines = volume_table(&volumes);
1235 assert_eq!(lines.len(), 3, "{lines:?}");
1236 assert!(
1237 lines[0].starts_with("ID") && lines[0].ends_with("DATA CENTER"),
1238 "{}",
1239 lines[0]
1240 );
1241 assert!(
1242 lines[1].contains("v1") && lines[1].contains("alpha") && lines[1].ends_with("US-KS-2"),
1243 "{}",
1244 lines[1]
1245 );
1246 assert!(
1247 lines[2].contains("v2") && lines[2].contains("zeta") && lines[2].ends_with("EU-RO-1"),
1248 "{}",
1249 lines[2]
1250 );
1251 }
1252
1253 #[test]
1254 fn template_table_is_empty_without_rows() {
1255 assert_eq!(template_table(&[]), [] as [String; 0]);
1256 }
1257
1258 #[test]
1259 fn template_table_is_sorted_by_name() {
1260 let templates = [
1261 Template {
1262 id: "t2".to_string(),
1263 name: "zeta".to_string(),
1264 image: "img/z:1".to_string(),
1265 serverless: false,
1266 },
1267 Template {
1268 id: "t1".to_string(),
1269 name: "alpha".to_string(),
1270 image: "img/a:1".to_string(),
1271 serverless: false,
1272 },
1273 ];
1274 let lines = template_table(&templates);
1275 assert_eq!(lines.len(), 3, "{lines:?}");
1276 assert!(
1277 lines[0].starts_with("ID") && lines[0].ends_with("IMAGE"),
1278 "{}",
1279 lines[0]
1280 );
1281 assert!(
1282 lines[1].contains("t1") && lines[1].ends_with("img/a:1"),
1283 "{}",
1284 lines[1]
1285 );
1286 assert!(
1287 lines[2].contains("t2") && lines[2].ends_with("img/z:1"),
1288 "{}",
1289 lines[2]
1290 );
1291 }
1292
1293 const LONG: &str = "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition";
1295
1296 fn at(line: &str, word: &str) -> Option<usize> {
1299 line.find(word)
1300 }
1301
1302 #[test]
1303 fn gpu_table_widens_its_id_column_to_the_longest_id() {
1304 let lines = gpu_table(&[gpu(LONG, 32, Some(0.9)), gpu("a", 8, Some(0.1))], None);
1305 assert!(lines[1].starts_with(&format!("{LONG} ")), "{}", lines[1]);
1306 for line in &lines[1..] {
1307 assert_eq!(at(line, "HIGH"), at(&lines[0], "STOCK"), "{lines:#?}");
1308 }
1309 assert_eq!(
1310 at(&lines[2], "0.10").map(|at| at + 4),
1311 at(&lines[0], "$/H").map(|at| at + 3),
1312 "{lines:#?}"
1313 );
1314 }
1315
1316 #[test]
1317 fn data_center_table_widens_its_columns_to_the_longest_values() {
1318 let mut long = center("A", vec![stock("x", Availability::High)]);
1319 long.name = LONG.to_string();
1320 let lines = data_center_table(&[long, center("B", Vec::new())]);
1321 for line in &lines[1..] {
1322 assert_eq!(
1323 line.rfind(" ").map(|at| at + 2),
1324 at(&lines[0], "GPU TYPES"),
1325 "{lines:#?}"
1326 );
1327 }
1328 assert_eq!(
1329 at(&lines[2], "EUROPE"),
1330 at(&lines[0], "REGION"),
1331 "{lines:#?}"
1332 );
1333 }
1334
1335 #[test]
1336 fn volume_and_template_tables_widen_their_name_columns() {
1337 let volume = |id: &str, name: &str| NetworkVolume {
1338 id: id.to_string(),
1339 name: name.to_string(),
1340 size: 50,
1341 data_center: "EU-RO-1".to_string(),
1342 };
1343 let lines = volume_table(&[volume("v1", LONG), volume("v2", "z")]);
1344 for line in &lines[1..] {
1345 assert_eq!(
1346 at(line, "EU-RO-1"),
1347 at(&lines[0], "DATA CENTER"),
1348 "{lines:#?}"
1349 );
1350 }
1351 let template = |id: &str, name: &str| Template {
1352 id: id.to_string(),
1353 name: name.to_string(),
1354 image: "img/a:1".to_string(),
1355 serverless: false,
1356 };
1357 let lines = template_table(&[template("t1", LONG), template("t2", "z")]);
1358 for line in &lines[1..] {
1359 assert_eq!(at(line, "img/"), at(&lines[0], "IMAGE"), "{lines:#?}");
1360 }
1361 }
1362
1363 #[test]
1364 fn printable_strips_control_characters_and_escape_sequences() {
1365 assert_eq!(printable("A40\u{1b}[2J\nname"), "A40 name");
1366 assert_eq!(printable("a\nb\tc\r\nd e"), "a b c d e", "no collapsing");
1367 assert_eq!(
1368 printable("a\u{1b}]0;title\u{7}b\u{1b}]8;;x\u{1b}\\c"),
1369 "abc"
1370 );
1371 assert_eq!(printable("a\u{1b}Mb\u{7f}c\u{9b}31md\te"), "abcd e");
1372 assert_eq!(
1373 printable("RTX 4090 é"),
1374 "RTX 4090 é",
1375 "printable text stays"
1376 );
1377 }
1378
1379 #[test]
1380 fn string_controls_are_skipped_through_their_terminator() {
1381 for introducer in ['P', 'X', '^', '_'] {
1382 let st = format!("a\u{1b}{introducer}bad\u{1b}\\b");
1383 assert_eq!(printable(&st), "ab", "ESC {introducer}");
1384 let bel = format!("a\u{1b}{introducer}bad\u{7}b");
1385 assert_eq!(printable(&bel), "ab", "ESC {introducer} ended by BEL");
1386 let open = format!("a\u{1b}{introducer}bad and more");
1387 assert_eq!(printable(&open), "a", "ESC {introducer} unterminated");
1388 }
1389 for introducer in ['\u{90}', '\u{98}', '\u{9e}', '\u{9f}'] {
1390 let st = format!("a{introducer}bad\u{9c}b");
1391 assert_eq!(printable(&st), "ab", "{introducer:?}");
1392 let open = format!("a{introducer}bad and more");
1393 assert_eq!(printable(&open), "a", "{introducer:?} unterminated");
1394 }
1395 }
1396
1397 #[test]
1398 fn every_table_prints_api_strings_without_control_characters() {
1399 let odd = "odd\u{1b}[2J\nname";
1400 let mut named = center("EU-RO-1", Vec::new());
1401 named.name = odd.to_string();
1402 let volume = NetworkVolume {
1403 id: "v1".to_string(),
1404 name: odd.to_string(),
1405 size: 50,
1406 data_center: "EU-RO-1".to_string(),
1407 };
1408 let template = Template {
1409 id: "t1".to_string(),
1410 name: odd.to_string(),
1411 image: "img\u{1b}[31m:1".to_string(),
1412 serverless: false,
1413 };
1414 let tables = [
1415 gpu_table(&[gpu(odd, 48, Some(0.4))], None),
1416 data_center_table(&[named]),
1417 volume_table(&[volume]),
1418 template_table(&[template]),
1419 ];
1420 for lines in tables {
1421 let text = lines.join("\n");
1422 assert!(text.contains("odd name"), "{text:?}");
1423 assert_eq!(lines.len(), 2, "{lines:?}");
1424 assert!(
1425 !text.chars().any(|c| c.is_control() && c != '\n'),
1426 "{text:?}"
1427 );
1428 }
1429 }
1430
1431 #[test]
1432 fn a_gpu_id_from_the_api_reaches_a_resolve_error_clean() -> Result<(), serde_json::Error> {
1433 let gpus: Vec<GpuType> = serde_json::from_value(serde_json::json!([
1434 {"id": "odd\u{1b}[2J\ngpu", "memory": 48, "price": {"secure": 0.3},
1435 "maxCount": {"secure": 8}, "availability": "HIGH",
1436 "dataCenters": [{"id": "EU\u{1b}[31m-RO-1", "availability": "NONE"}]}
1437 ]))?;
1438 assert_eq!(gpus[0].id, "odd gpu", "cleaned when parsed");
1439 assert_eq!(gpus[0].data_centers[0].id, "EU-RO-1");
1440 let error = resolve(&target(list(&["odd gpu"]), ListOrAuto::Auto), &gpus)
1441 .err()
1442 .map(|error| error.to_string());
1443 assert_eq!(
1444 error.as_deref(),
1445 Some(
1446 "no data center has odd gpu in stock for data_center_ids = \"auto\" (gpu_count = 1)"
1447 )
1448 );
1449 Ok(())
1450 }
1451
1452 fn out_of_stock(id: &str, memory: u32, price: f64) -> GpuType {
1453 GpuType {
1454 availability: Availability::None,
1455 ..gpu(id, memory, Some(price))
1456 }
1457 }
1458
1459 fn strings(items: &[&str]) -> Vec<String> {
1460 items.iter().map(|item| (*item).to_string()).collect()
1461 }
1462
1463 fn order(
1464 target: &RunpodTarget,
1465 gpus: &[GpuType],
1466 floor: Option<u32>,
1467 tried: &[&str],
1468 cap: usize,
1469 ) -> Result<(ListOrAuto, Vec<String>), ResolveError> {
1470 walk_order(target, gpus, floor, &strings(tried), cap)
1471 .map(|order| (order.target.gpu_types, order.out_of_stock))
1472 }
1473
1474 #[test]
1475 fn cheaper_types_out_of_stock_come_first_cheapest_first_capped() {
1476 let gpus = [
1477 gpu("stocked", 24, Some(0.5)),
1478 gpu("dearer", 24, Some(0.9)),
1479 out_of_stock("same", 24, 0.5),
1480 out_of_stock("c", 24, 0.3),
1481 out_of_stock("a", 24, 0.1),
1482 out_of_stock("b", 24, 0.2),
1483 out_of_stock("d", 24, 0.4),
1484 ];
1485 let auto = target(ListOrAuto::Auto, ListOrAuto::default());
1486 assert_eq!(
1487 order(&auto, &gpus, None, &[], MAX_OUT_OF_STOCK),
1488 Ok((
1489 list(&["a", "b", "c", "stocked", "dearer"]),
1490 strings(&["a", "b", "c"])
1491 ))
1492 );
1493 assert_eq!(
1495 order(&auto, &gpus, None, &["a", "stocked"], 1),
1496 Ok((list(&["b", "dearer"]), strings(&["b"])))
1497 );
1498 assert_eq!(
1499 order(&auto, &gpus, None, &[], 0),
1500 Ok((list(&["stocked", "dearer"]), Vec::new()))
1501 );
1502 }
1503
1504 #[test]
1505 fn types_out_of_stock_keep_every_limit() {
1506 let mut single = out_of_stock("single", 80, 0.1);
1507 single.max_count.secure = 1;
1508 let mut community = out_of_stock("community", 80, 0.1);
1509 community.secure = Some(false);
1510 let gpus = [
1511 gpu("stocked", 48, Some(0.8)),
1512 single,
1513 community,
1514 out_of_stock("small", 16, 0.1),
1515 out_of_stock("over", 80, 0.7),
1516 out_of_stock("fits", 48, 0.2),
1517 ];
1518 let mut auto = target(ListOrAuto::Auto, ListOrAuto::default());
1519 auto.gpu_count = 2;
1520 auto.max_price_per_hour = Some(0.6);
1521 assert!(matches!(
1523 order(&auto, &gpus, Some(40), &[], 3),
1524 Err(ResolveError::NoGpuType { .. })
1525 ));
1526 auto.max_price_per_hour = Some(0.8);
1527 assert_eq!(
1530 order(&auto, &gpus, Some(40), &[], 3),
1531 Ok((
1532 list(&["fits", "over", "stocked"]),
1533 strings(&["fits", "over"])
1534 ))
1535 );
1536 auto.min_vram_gb = Some(60);
1537 assert!(order(&auto, &gpus, Some(10), &[], 3).is_err());
1539 }
1540
1541 #[test]
1542 fn nothing_in_stock_or_nothing_left_untried_is_an_error() {
1543 let auto = target(ListOrAuto::Auto, ListOrAuto::default());
1544 let none = [out_of_stock("a", 24, 0.1)];
1545 assert_eq!(
1546 order(&auto, &none, None, &[], 3),
1547 Err(ResolveError::NoGpuType {
1548 asked: "gpu_count = 1".into()
1549 })
1550 );
1551 let gpus = [gpu("stocked", 24, Some(0.5)), out_of_stock("a", 24, 0.1)];
1552 assert!(matches!(
1553 order(&auto, &gpus, None, &["stocked"], 3),
1554 Err(ResolveError::NoGpuType { .. })
1555 ));
1556 }
1557
1558 #[test]
1559 fn listed_data_centers_bound_the_types_out_of_stock() {
1560 let mut stocked = gpu("stocked", 24, Some(0.5));
1561 stocked.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1562 let mut here = out_of_stock("here", 24, 0.1);
1563 here.data_centers = vec![stock("EU-RO-1", Availability::None)];
1564 let mut elsewhere = out_of_stock("elsewhere", 24, 0.1);
1565 elsewhere.data_centers = vec![stock("US-KS-2", Availability::None)];
1566 let unknown = out_of_stock("unknown", 24, 0.2);
1567 let gpus = [stocked, here, elsewhere, unknown];
1568 let listed = target(ListOrAuto::Auto, list(&["EU-RO-1"]));
1569 let walk = walk_order(&listed, &gpus, None, &[], 3);
1570 assert_eq!(
1571 walk.as_ref().map(|order| &order.target.gpu_types),
1572 Ok(&list(&["here", "unknown", "stocked"]))
1573 );
1574 assert_eq!(
1575 walk.map(|order| order.target.data_center_ids),
1576 Ok(list(&["EU-RO-1"]))
1577 );
1578 }
1579
1580 #[test]
1581 fn auto_data_centers_come_from_the_untried_types_in_stock() {
1582 let mut cheap = gpu("cheap", 24, Some(0.3));
1583 cheap.data_centers = vec![stock("EU-RO-1", Availability::Low)];
1584 let mut dear = gpu("dear", 24, Some(0.6));
1585 dear.data_centers = vec![stock("US-KS-2", Availability::High)];
1586 let gpus = [cheap, dear, out_of_stock("out", 24, 0.1)];
1587 let auto = target(ListOrAuto::Auto, ListOrAuto::Auto);
1588 let centers = |tried: &[&str]| {
1589 walk_order(&auto, &gpus, None, &strings(tried), 3)
1590 .map(|order| order.target.data_center_ids)
1591 };
1592 assert_eq!(centers(&[]), Ok(list(&["EU-RO-1", "US-KS-2"])));
1593 assert_eq!(centers(&["cheap"]), Ok(list(&["US-KS-2"])));
1594 }
1595
1596 #[test]
1597 fn listed_types_have_no_walk_order_of_their_own() {
1598 let gpus = [gpu("stocked", 24, Some(0.5)), out_of_stock("a", 24, 0.1)];
1599 let listed = target(list(&["x", "y"]), ListOrAuto::default());
1600 assert_eq!(
1601 order(&listed, &gpus, None, &["x"], 3),
1602 Ok((list(&["x", "y"]), Vec::new()))
1603 );
1604 }
1605}