1use super::CuContract;
5use super::CuPrediction;
6use super::CuProfile;
7use super::propose::rate_deficit;
8use alloc::collections::BTreeMap;
9use alloc::string::String;
10use alloc::vec::Vec;
11use core::fmt;
12use cu29_traits::CuError;
13use cu29_traits::CuResult;
14use serde::Deserialize;
15use serde::Serialize;
16
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct CuScoreRow {
21 pub candidate: String,
22 pub score: Vec<f64>,
25 pub chains: BTreeMap<String, CuChainScore>,
26 pub sources: BTreeMap<String, f64>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub predicted: Option<CuPrediction>,
31}
32
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CuChainScore {
36 pub deadline_ms: u32,
37 pub samples: u64,
38 pub p50_ns: u64,
39 pub p99_ns: u64,
40 pub max_ns: u64,
41 pub misses: u64,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub predicted_ns: Option<u64>,
44}
45
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct CuScoreTable {
50 pub rows: Vec<CuScoreRow>,
51}
52
53impl CuScoreTable {
54 pub fn new(
58 contract: &CuContract,
59 predictions: &BTreeMap<String, CuPrediction>,
60 measured: &BTreeMap<String, CuProfile>,
61 ) -> CuResult<Self> {
62 if let Some((first, reference)) = measured.iter().next() {
63 for (candidate, profile) in measured {
64 if profile.config_signature != reference.config_signature
65 || profile.mission != reference.mission
66 {
67 return Err(CuError::from(format!(
68 "Profiles of '{first}' and '{candidate}' were recorded on different graphs or missions"
69 )));
70 }
71 }
72 }
73 let mut rows = Vec::with_capacity(measured.len());
74 for (candidate, profile) in measured {
75 let predicted = predictions
77 .get(candidate)
78 .or_else(|| predictions.get(candidate.split('/').next().unwrap_or(candidate)));
79 let mut chains = BTreeMap::new();
80 let mut ratios = Vec::with_capacity(contract.chains.len());
81 for chain in &contract.chains {
82 let stats = profile.chains.get(&chain.id).ok_or_else(|| {
83 CuError::from(format!(
84 "Profile of '{candidate}' has no chain '{}'",
85 chain.id
86 ))
87 })?;
88 if stats.latency.samples == 0 {
89 return Err(CuError::from(format!(
90 "Profile of '{candidate}' has no samples for chain '{}'",
91 chain.id
92 )));
93 }
94 let deadline_ns = u64::from(chain.deadline_ms) * 1_000_000;
95 ratios.push(stats.latency.p99_ns as f64 / deadline_ns as f64);
96 chains.insert(
97 chain.id.clone(),
98 CuChainScore {
99 deadline_ms: chain.deadline_ms,
100 samples: stats.latency.samples,
101 p50_ns: stats.latency.p50_ns,
102 p99_ns: stats.latency.p99_ns,
103 max_ns: stats.latency.max_ns,
104 misses: stats.misses,
105 predicted_ns: predicted
106 .and_then(|p| p.chains.get(&chain.id))
107 .map(|c| c.latency_ns),
108 },
109 );
110 }
111 let mut sources = BTreeMap::new();
112 let mut rate_deficit = 0.0f64;
113 for source in &contract.sources {
114 let rate = profile
115 .sources
116 .get(&source.task)
117 .ok_or_else(|| {
118 CuError::from(format!(
119 "Profile of '{candidate}' has no source rate for '{}'",
120 source.task
121 ))
122 })?
123 .delivered_rate;
124 rate_deficit += self::rate_deficit(rate);
125 sources.insert(source.task.clone(), rate);
126 }
127 let sum: f64 = ratios.iter().sum();
128 let score = vec![
129 round6(rate_deficit),
130 ratios.iter().filter(|&&r| r > 1.0).count() as f64,
131 ratios
132 .iter()
133 .filter(|&&r| r > 1.0 - contract.headroom)
134 .count() as f64,
135 round6(sum),
136 ];
137 rows.push(CuScoreRow {
138 candidate: candidate.clone(),
139 score,
140 chains,
141 sources,
142 predicted: predicted.cloned(),
143 });
144 }
145 rows.sort_by(|a, b| {
146 a.score
147 .iter()
148 .zip(&b.score)
149 .map(|(x, y)| x.total_cmp(y))
150 .find(|o| o.is_ne())
151 .unwrap_or_else(|| a.candidate.cmp(&b.candidate))
152 });
153 Ok(Self { rows })
154 }
155
156 pub fn best(&self) -> Option<&CuScoreRow> {
158 self.rows.first()
159 }
160
161 pub fn serialize_ron(&self) -> CuResult<String> {
162 ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
163 .map_err(|e| CuError::new_with_cause("Could not serialize the score table", e))
164 }
165}
166
167fn round6(value: f64) -> f64 {
168 (value * 1e6 + 0.5) as u64 as f64 / 1e6
169}
170
171fn ms(ns: u64) -> f64 {
172 ns as f64 / 1e6
173}
174
175impl fmt::Display for CuScoreTable {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 for (rank, row) in self.rows.iter().enumerate() {
180 writeln!(f, "{}. {} score {:?}", rank + 1, row.candidate, row.score)?;
181 for (id, chain) in &row.chains {
182 let predicted = chain
183 .predicted_ns
184 .map(|ns| format!("{:.2}", ms(ns)))
185 .unwrap_or_else(|| "-".into());
186 writeln!(
187 f,
188 " {id:<32} predicted {predicted:>8} | p50 {:>8.2} p99 {:>8.2} max {:>8.2} ms | {} misses / {} of {} ms",
189 ms(chain.p50_ns),
190 ms(chain.p99_ns),
191 ms(chain.max_ns),
192 chain.misses,
193 chain.samples,
194 chain.deadline_ms
195 )?;
196 }
197 for (task, rate) in &row.sources {
198 let predicted = row
199 .predicted
200 .as_ref()
201 .and_then(|p| p.sources.get(task))
202 .map(|r| format!("{r:.3}"))
203 .unwrap_or_else(|| "-".into());
204 writeln!(
205 f,
206 " {task:<32} rate predicted {predicted:>8} | delivered {rate:.3}"
207 )?;
208 }
209 }
210 Ok(())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::planner::CuChain;
218 use crate::planner::CuChainProfile;
219 use crate::planner::CuCostStats;
220 use crate::planner::CuSourceProfile;
221 use crate::planner::CuSourceRate;
222
223 fn contract() -> CuContract {
224 CuContract::deserialize_ron(
225 r#"(
226 chains: [(id: "hot", source: "src", sink: "sink", deadline_ms: 10)],
227 sources: [(task: "src", period_ms: 5)],
228 cpus: [0],
229 max_in_flight: 1,
230 )"#,
231 )
232 .unwrap()
233 }
234
235 fn profile(p99_ms: u64, rate: f64) -> CuProfile {
236 let mut profile = CuProfile::new("sig".into(), "default".into());
237 let mut samples = vec![p99_ms * 1_000_000; 4];
238 samples[0] /= 2;
239 profile.chains.insert(
240 "hot".into(),
241 CuChainProfile {
242 deadline_ms: 10,
243 latency: CuCostStats::from_samples(&mut samples),
244 misses: u64::from(p99_ms > 10),
245 },
246 );
247 profile.sources.insert(
248 "src".into(),
249 CuSourceProfile {
250 period_ms: 5,
251 fired: 10,
252 expected: 10.0,
253 delivered_rate: rate,
254 },
255 );
256 profile
257 }
258
259 #[test]
260 fn ranks_measured_candidates_by_deadlines_and_keeps_predictions() {
261 let contract = contract();
262 let _ = (CuChain::clone, CuSourceRate::clone);
263 let measured: BTreeMap<String, CuProfile> = [
264 ("late".to_string(), profile(12, 1.0)),
265 ("tight".to_string(), profile(9, 1.0)),
266 ("dropping".to_string(), profile(4, 0.5)),
267 ]
268 .into_iter()
269 .collect();
270 let table = CuScoreTable::new(&contract, &BTreeMap::new(), &measured).unwrap();
271 let order: Vec<&str> = table.rows.iter().map(|r| r.candidate.as_str()).collect();
272 assert_eq!(order, ["tight", "late", "dropping"]);
273 assert_eq!(table.best().unwrap().score, vec![0.0, 0.0, 1.0, 0.9]);
274 assert_eq!(table.rows[2].score[0], 0.5);
275 assert!(table.to_string().contains("tight score"));
276 let text = table.serialize_ron().unwrap();
277 assert!(text.contains("dropping"));
278 }
279}