1use {
11 crate::{
12 calibrated_tolerance,
13 contract::{Budget, BudgetError, Contract, Direction, Metric, Mode, contract_digest},
14 median,
15 num::to_f64,
16 record::{Measurement, PerformanceRecord, Side, Subject, Trial, TrialOutcome},
17 relative_mad,
18 },
19 serde::{Deserialize, Serialize},
20 std::collections::{BTreeMap, BTreeSet},
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum Status {
27 WithinBudget,
29 Improved,
31 NoisyNotMaterial,
34 MaterialRegression,
36 ExcessVariance,
38 NotBudgeted,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct MetricVerdict {
45 pub reference_median: f64,
47 pub candidate_median: f64,
49 pub worse_change: f64,
52 pub reference_spread: f64,
54 pub candidate_spread: f64,
56 pub allowed_change: f64,
58 pub status: Status,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "kind", rename_all = "snake_case")]
65pub enum Rejection {
66 CorrectnessFailure {
68 cell: String,
70 round: usize,
72 side: Side,
74 reason: String,
76 },
77 MissingTrial {
79 cell: String,
81 round: usize,
83 side: Side,
85 },
86 NotInterleaved {
88 cell: String,
90 round: usize,
92 },
93 MaterialRegression {
95 cell: String,
97 metric: Metric,
99 worse_change: f64,
101 allowed_change: f64,
103 },
104 ExcessVariance {
106 cell: String,
108 metric: Metric,
110 spread: f64,
112 max_spread: f64,
114 },
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct BudgetVerdict {
120 pub contract_name: String,
122 pub contract_sha256: String,
124 pub mode: Mode,
126 pub gate_performance: bool,
128 pub reference: Subject,
130 pub candidate: Subject,
132 pub cells: BTreeMap<String, BTreeMap<Metric, MetricVerdict>>,
134 pub rejections: Vec<Rejection>,
136 pub passed: bool,
138}
139
140#[must_use]
142pub fn metric_value(metric: Metric, measurement: &Measurement) -> f64 {
143 match metric {
144 Metric::RecordsPerSecond => measurement.records_per_second,
145 Metric::LatencyMsP50 => measurement.latency_ms_p50,
146 Metric::LatencyMsP95 => measurement.latency_ms_p95,
147 Metric::LatencyMsP99 => measurement.latency_ms_p99,
148 Metric::CpuMicrosPerRecord => {
149 measurement.cpu_seconds * 1.0e6 / to_f64(measurement.consumed.max(1))
150 }
151 Metric::PeakRssMib => to_f64(measurement.peak_rss_bytes) / 1_048_576.0,
152 }
153}
154
155#[must_use]
157pub fn correctness_failure(outcome: &TrialOutcome, records: u64) -> Option<String> {
158 match outcome {
159 TrialOutcome::Failed { reason } => Some(reason.clone()),
160 TrialOutcome::Completed(measurement) => {
161 let Measurement {
162 sent,
163 consumed,
164 duplicates,
165 errors,
166 ..
167 } = measurement;
168 (*sent != records || *consumed != records || *duplicates != 0 || *errors != 0).then(
169 || {
170 format!(
171 "expected {records} records; sent={sent} consumed={consumed} \
172 duplicates={duplicates} errors={errors}"
173 )
174 },
175 )
176 }
177 }
178}
179
180#[must_use]
184pub fn judge(
185 metric: Metric,
186 reference: &[f64],
187 candidate: &[f64],
188 budget: Option<&Budget>,
189) -> MetricVerdict {
190 let reference_median = median(reference.to_vec());
191 let candidate_median = median(candidate.to_vec());
192 let reference_spread = spread(reference, reference_median);
193 let candidate_spread = spread(candidate, candidate_median);
194 let worse_change = if reference_median == 0.0 {
195 0.0
196 } else {
197 let change = (candidate_median - reference_median) / reference_median;
198 match metric.direction() {
199 Direction::LowerIsBetter => change,
200 Direction::HigherIsBetter => -change,
201 }
202 };
203 let (allowed_change, status) = budget.map_or((0.0, Status::NotBudgeted), |budget| {
204 let materiality = budget.materiality_percent / 100.0;
205 let allowed = calibrated_tolerance(
206 materiality,
207 budget.spread_multiplier,
208 reference_spread,
209 candidate_spread,
210 );
211 let status = if reference_spread.max(candidate_spread) > budget.max_spread_percent / 100.0 {
212 Status::ExcessVariance
213 } else if worse_change > allowed {
214 Status::MaterialRegression
215 } else if worse_change > materiality {
216 Status::NoisyNotMaterial
217 } else if -worse_change > allowed {
218 Status::Improved
219 } else {
220 Status::WithinBudget
221 };
222 (allowed, status)
223 });
224 MetricVerdict {
225 reference_median,
226 candidate_median,
227 worse_change,
228 reference_spread,
229 candidate_spread,
230 allowed_change,
231 status,
232 }
233}
234
235fn spread(values: &[f64], center: f64) -> f64 {
238 if center == 0.0 {
239 0.0
240 } else {
241 relative_mad(values, center).abs()
242 }
243}
244
245pub fn evaluate(
259 contract: &Contract,
260 contract_bytes: &[u8],
261 record: &PerformanceRecord,
262) -> Result<BudgetVerdict, BudgetError> {
263 let digest = contract_digest(contract_bytes);
264 if record.contract_name != contract.name || record.contract_sha256 != digest {
265 return Err(BudgetError::ContractMismatch {
266 recorded: format!("{} ({})", record.contract_name, record.contract_sha256),
267 supplied: format!("{} ({digest})", contract.name),
268 });
269 }
270 let mut trials: BTreeMap<(&str, usize, Side), &Trial> = BTreeMap::new();
271 let mut orders = BTreeSet::new();
272 for trial in &record.trials {
273 if contract.cell(&trial.cell).is_none() {
274 return Err(BudgetError::InvalidRecord(format!(
275 "trial for unknown cell {}",
276 trial.cell
277 )));
278 }
279 if trial.round == 0 || trial.round > contract.rounds {
280 return Err(BudgetError::InvalidRecord(format!(
281 "trial for cell {} has round {} outside 1..={}",
282 trial.cell, trial.round, contract.rounds
283 )));
284 }
285 if !orders.insert(trial.order) {
286 return Err(BudgetError::InvalidRecord(format!(
287 "two trials share position {}",
288 trial.order
289 )));
290 }
291 if trials
292 .insert((trial.cell.as_str(), trial.round, trial.side), trial)
293 .is_some()
294 {
295 return Err(BudgetError::InvalidRecord(format!(
296 "cell {} round {} {} appears twice",
297 trial.cell,
298 trial.round,
299 trial.side.name()
300 )));
301 }
302 }
303
304 let gate_performance = contract
305 .modes
306 .get(&record.mode)
307 .ok_or_else(|| {
308 BudgetError::InvalidRecord(format!(
309 "mode {} is not in the contract",
310 record.mode.name()
311 ))
312 })?
313 .gate_performance;
314 let mut rejections = Vec::new();
315 let mut cells = BTreeMap::new();
316 for cell in &contract.cells {
317 let mut sound = true;
318 let mut values: BTreeMap<Side, Vec<&Measurement>> = BTreeMap::new();
319 for round in 1..=contract.rounds {
320 for side in Side::BOTH {
321 let Some(trial) = trials.get(&(cell.id.as_str(), round, side)) else {
322 sound = false;
323 rejections.push(Rejection::MissingTrial {
324 cell: cell.id.clone(),
325 round,
326 side,
327 });
328 continue;
329 };
330 if let Some(reason) = correctness_failure(&trial.outcome, cell.records) {
331 sound = false;
332 rejections.push(Rejection::CorrectnessFailure {
333 cell: cell.id.clone(),
334 round,
335 side,
336 reason,
337 });
338 } else if let TrialOutcome::Completed(measurement) = &trial.outcome {
339 values.entry(side).or_default().push(measurement);
340 }
341 }
342 }
343 let first_side = |round: usize| -> Option<Side> {
344 let reference = trials.get(&(cell.id.as_str(), round, Side::Reference))?;
345 let candidate = trials.get(&(cell.id.as_str(), round, Side::Candidate))?;
346 Some(if reference.order < candidate.order {
347 Side::Reference
348 } else {
349 Side::Candidate
350 })
351 };
352 for round in 2..=contract.rounds {
353 if let (Some(previous), Some(current)) = (first_side(round - 1), first_side(round))
354 && previous == current
355 {
356 sound = false;
357 rejections.push(Rejection::NotInterleaved {
358 cell: cell.id.clone(),
359 round,
360 });
361 }
362 }
363 if !sound {
364 continue;
365 }
366 let mut metrics = BTreeMap::new();
367 for metric in Metric::ALL {
368 let side_values = |side: Side| -> Vec<f64> {
369 values
370 .get(&side)
371 .into_iter()
372 .flatten()
373 .map(|measurement| metric_value(metric, measurement))
374 .collect()
375 };
376 let budget = contract
377 .budgets
378 .get(&metric)
379 .filter(|_| cell.budgeted.contains(&metric));
380 let verdict = judge(
381 metric,
382 &side_values(Side::Reference),
383 &side_values(Side::Candidate),
384 budget,
385 );
386 match verdict.status {
387 Status::ExcessVariance => rejections.push(Rejection::ExcessVariance {
388 cell: cell.id.clone(),
389 metric,
390 spread: verdict.reference_spread.max(verdict.candidate_spread),
391 max_spread: budget.map_or(0.0, |budget| budget.max_spread_percent / 100.0),
392 }),
393 Status::MaterialRegression if gate_performance => {
394 rejections.push(Rejection::MaterialRegression {
395 cell: cell.id.clone(),
396 metric,
397 worse_change: verdict.worse_change,
398 allowed_change: verdict.allowed_change,
399 });
400 }
401 _ => {}
402 }
403 metrics.insert(metric, verdict);
404 }
405 cells.insert(cell.id.clone(), metrics);
406 }
407 Ok(BudgetVerdict {
408 contract_name: contract.name.clone(),
409 contract_sha256: digest,
410 mode: record.mode,
411 gate_performance,
412 reference: record.reference.clone(),
413 candidate: record.candidate.clone(),
414 cells,
415 passed: rejections.is_empty(),
416 rejections,
417 })
418}