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;