metrale_bench/benchmarks/serve_matrix/plan.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Turning the box's roster into the rounds this run intends to
4//! cover. A round is planned (the run boots it and it must produce a result),
5//! skipped (the box cannot serve it; counted and reported) or excluded (outside
6//! the operator's filter).
7//!
8//! Owner: bench, serve matrix.
9//! Invariants:
10//! - Every roster entry is one round; skipped and excluded rounds stay in
11//! `Plan::rounds`.
12//! - A round is never both skipped and excluded.
13//! - Rounds are sorted by model id, then quant.
14
15pub use super::host::Absence;
16use super::host::ServeCandidate;
17
18/// 2026-09-26: Why a candidate cannot be served; `None` when it can.
19pub type Skip = Option<Absence>;
20
21/// 2026-09-26: One roster entry, planned, skipped or excluded.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Round {
24 pub model: String,
25 pub quant: String,
26 /// 2026-09-26: Set when the box cannot serve the checkpoint; carries the reason.
27 pub skipped: Skip,
28 /// 2026-09-26: Filtered out by the operator's `include` pattern. Set only on
29 /// a servable checkpoint: one that is unservable and outside the filter
30 /// counts as skipped alone.
31 pub excluded: bool,
32}
33
34impl Round {
35 pub fn is_planned(&self) -> bool {
36 self.skipped.is_none() && !self.excluded
37 }
38
39 /// 2026-09-26: Label used in the table and in a failure line.
40 pub fn label(&self) -> String {
41 match self.quant.trim() {
42 "" | "-" => self.model.clone(),
43 q => format!("{} ยท {q}", self.model),
44 }
45 }
46}
47
48/// 2026-09-26: The whole roster, classified; skipped and excluded rounds are kept
49/// and counted in the result.
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct Plan {
52 pub rounds: Vec<Round>,
53}
54
55impl Plan {
56 /// 2026-09-26: Classify `roster` under an operator filter. `include` is a
57 /// case-insensitive substring of the model id; empty means everything the
58 /// box can serve. Sorted by model id, then quant, so the round order does
59 /// not depend on the roster's order.
60 pub fn build(roster: &[ServeCandidate], include: &str) -> Self {
61 let needle = include.trim().to_lowercase();
62 let mut rounds: Vec<Round> = roster
63 .iter()
64 .map(|c| Round {
65 model: c.model.clone(),
66 quant: c.quant.clone(),
67 skipped: c.absent,
68 excluded: c.absent.is_none()
69 && !needle.is_empty()
70 && !c.model.to_lowercase().contains(&needle),
71 })
72 .collect();
73 rounds.sort_by(|a, b| a.model.cmp(&b.model).then(a.quant.cmp(&b.quant)));
74 Self { rounds }
75 }
76
77 /// 2026-09-26: The rounds that will be booted, in order. The run's cursor and
78 /// `score::tally` both walk this iterator.
79 pub fn planned(&self) -> impl Iterator<Item = &Round> {
80 self.rounds.iter().filter(|r| r.is_planned())
81 }
82
83 pub fn planned_count(&self) -> usize {
84 self.planned().count()
85 }
86
87 /// 2026-09-26: Checkpoints the box cannot serve, each with its reason.
88 pub fn skipped(&self) -> impl Iterator<Item = (&Round, Absence)> {
89 self.rounds
90 .iter()
91 .filter_map(|r| r.skipped.map(|why| (r, why)))
92 }
93
94 pub fn excluded_count(&self) -> usize {
95 self.rounds.iter().filter(|r| r.excluded).count()
96 }
97}
98
99#[cfg(test)]
100#[path = "plan_tests.rs"]
101mod tests;