1use crate::MetricContext;
6use fxhash::FxHashSet;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::sync::OnceLock;
10
11pub use fluxbench_core::Severity;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ResolvedMetric {
17 pub name: String,
19 pub value: f64,
21 pub unit: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Verification {
28 pub id: String,
30 pub expression: String,
32 pub severity: Severity,
34 pub margin: f64,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub enum VerificationStatus {
47 Passed,
49 Failed,
51 Skipped {
53 missing_metrics: String,
55 },
56 Error {
58 message: String,
60 },
61}
62
63impl VerificationStatus {
64 pub fn is_success(&self) -> bool {
66 matches!(self, VerificationStatus::Passed)
67 }
68
69 pub fn is_failure(&self) -> bool {
71 matches!(self, VerificationStatus::Failed)
72 }
73
74 pub fn is_actionable_failure(&self) -> bool {
76 matches!(
77 self,
78 VerificationStatus::Failed | VerificationStatus::Error { .. }
79 )
80 }
81
82 pub fn affects_exit_code(&self, severity: Severity) -> bool {
87 match (self, severity) {
88 (VerificationStatus::Failed, Severity::Critical) => true,
89 (VerificationStatus::Error { .. }, Severity::Critical) => true,
90 (VerificationStatus::Skipped { .. }, _) => false,
92 _ => false,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct VerificationResult {
100 pub id: String,
102 pub expression: String,
104 pub status: VerificationStatus,
106 pub actual_value: Option<f64>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub resolved_metrics: Vec<ResolvedMetric>,
111 pub severity: Severity,
113 pub message: String,
115}
116
117impl VerificationResult {
118 pub fn passed(&self) -> bool {
120 self.status.is_success()
121 }
122}
123
124pub struct VerificationContext<'a> {
126 metrics: &'a MetricContext,
128 unavailable: FxHashSet<String>,
130 metric_units: std::collections::HashMap<String, String>,
132}
133
134impl<'a> VerificationContext<'a> {
135 pub fn new(metrics: &'a MetricContext, unavailable: FxHashSet<String>) -> Self {
137 Self {
138 metrics,
139 unavailable,
140 metric_units: std::collections::HashMap::new(),
141 }
142 }
143
144 pub fn set_unit(&mut self, name: impl Into<String>, unit: impl Into<String>) {
146 self.metric_units.insert(name.into(), unit.into());
147 }
148
149 pub fn check_dependencies(&self, expression: &str) -> Option<String> {
151 let variables = extract_variables(expression);
152
153 let missing: Vec<_> = variables
154 .iter()
155 .filter(|v| self.unavailable.contains(*v))
156 .cloned()
157 .collect();
158
159 if missing.is_empty() {
160 None
161 } else {
162 Some(missing.join(", "))
163 }
164 }
165
166 pub fn check_unknown_variables(&self, expression: &str) -> Option<Vec<String>> {
170 let variables = extract_variables(expression);
171
172 let unknown: Vec<String> = variables
173 .into_iter()
174 .filter(|v| {
175 !self.metrics.has(v) && !self.unavailable.contains(v) && !is_builtin_function(v)
176 })
177 .collect();
178
179 if unknown.is_empty() {
180 None
181 } else {
182 Some(unknown)
183 }
184 }
185
186 pub fn available_metric_names(&self) -> Vec<String> {
188 self.metrics.metric_names().cloned().collect()
189 }
190}
191
192fn extract_variables(expression: &str) -> Vec<String> {
194 static IDENT_RE: OnceLock<Regex> = OnceLock::new();
195 let re = IDENT_RE
197 .get_or_init(|| Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*(?:@[a-zA-Z0-9_]+)*)\b").unwrap());
198
199 re.captures_iter(expression)
200 .map(|c| c[1].to_string())
201 .filter(|s| !is_builtin_function(s))
202 .collect()
203}
204
205fn is_builtin_function(name: &str) -> bool {
206 matches!(
207 name,
208 "min" | "max" | "abs" | "floor" | "ceil" | "round" | "sqrt" | "if" | "len" | "str"
209 )
210}
211
212pub fn run_verifications(
224 verifications: &[Verification],
225 context: &VerificationContext,
226) -> Vec<VerificationResult> {
227 verifications
228 .iter()
229 .map(|v| {
230 if let Some(missing) = context.check_dependencies(&v.expression) {
232 return VerificationResult {
233 id: v.id.clone(),
234 expression: v.expression.clone(),
235 status: VerificationStatus::Skipped {
236 missing_metrics: missing.clone(),
237 },
238 actual_value: None,
239 resolved_metrics: Vec::new(),
240 severity: v.severity,
241 message: format!("Skipped: required metrics unavailable [{}]", missing),
242 };
243 }
244
245 if let Some(unknown) = context.check_unknown_variables(&v.expression) {
247 let mut available = context.available_metric_names();
248 available.sort();
249 return VerificationResult {
250 id: v.id.clone(),
251 expression: v.expression.clone(),
252 status: VerificationStatus::Error {
253 message: format!("unknown variable(s): {}", unknown.join(", ")),
254 },
255 actual_value: None,
256 resolved_metrics: Vec::new(),
257 severity: v.severity,
258 message: format!(
259 "Unknown variable '{}'. Available metrics: [{}]",
260 unknown.join("', '"),
261 available.join(", ")
262 ),
263 };
264 }
265
266 let vars = extract_variables(&v.expression);
268 let resolved: Vec<ResolvedMetric> = vars
269 .iter()
270 .filter_map(|name| {
271 context.metrics.get(name).map(|val| ResolvedMetric {
272 name: name.clone(),
273 value: val,
274 unit: context.metric_units.get(name).cloned(),
275 })
276 })
277 .collect();
278
279 match context.metrics.evaluate(&v.expression) {
281 Ok(value) => {
282 let passed = value != 0.0;
283 VerificationResult {
284 id: v.id.clone(),
285 expression: v.expression.clone(),
286 status: if passed {
287 VerificationStatus::Passed
288 } else {
289 VerificationStatus::Failed
290 },
291 actual_value: Some(value),
292 resolved_metrics: resolved,
293 severity: v.severity,
294 message: if passed {
295 format!("{} = {:.2}", v.expression, value)
296 } else {
297 format!("{} = {:.2} (expected non-zero)", v.expression, value)
298 },
299 }
300 }
301 Err(e) => {
302 let error_msg = e.to_string();
303 VerificationResult {
304 id: v.id.clone(),
305 expression: v.expression.clone(),
306 status: VerificationStatus::Error {
307 message: error_msg.clone(),
308 },
309 actual_value: None,
310 resolved_metrics: resolved,
311 severity: v.severity,
312 message: format!("Evaluation error: {}", error_msg),
313 }
314 }
315 }
316 })
317 .collect()
318}
319
320#[derive(Debug, Default, Serialize, Deserialize)]
322pub struct VerificationSummary {
323 pub passed: usize,
325 pub failed: usize,
327 pub skipped: usize,
329 pub errors: usize,
331 pub critical_failures: usize,
333 pub critical_errors: usize,
335}
336
337impl VerificationSummary {
338 pub fn should_fail_ci(&self) -> bool {
340 self.critical_failures > 0 || self.critical_errors > 0
341 }
342
343 pub fn total_executed(&self) -> usize {
345 self.passed + self.failed + self.errors
346 }
347}
348
349pub fn aggregate_verifications(results: &[VerificationResult]) -> VerificationSummary {
360 let mut summary = VerificationSummary::default();
361
362 for result in results {
363 match &result.status {
364 VerificationStatus::Passed => summary.passed += 1,
365 VerificationStatus::Failed => {
366 summary.failed += 1;
367 if result.severity == Severity::Critical {
368 summary.critical_failures += 1;
369 }
370 }
371 VerificationStatus::Skipped { .. } => summary.skipped += 1,
372 VerificationStatus::Error { .. } => {
373 summary.errors += 1;
374 if result.severity == Severity::Critical {
375 summary.critical_errors += 1;
376 }
377 }
378 }
379 }
380
381 summary
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_extract_variables() {
390 let vars = extract_variables("(raw - overhead) < 50");
391 assert!(vars.contains(&"raw".to_string()));
392 assert!(vars.contains(&"overhead".to_string()));
393 assert!(!vars.contains(&"min".to_string())); }
395
396 #[test]
397 fn test_verification_status_affects_exit() {
398 assert!(VerificationStatus::Failed.affects_exit_code(Severity::Critical));
399 assert!(!VerificationStatus::Failed.affects_exit_code(Severity::Warning));
400 assert!(
401 !VerificationStatus::Skipped {
402 missing_metrics: "x".to_string()
403 }
404 .affects_exit_code(Severity::Critical)
405 );
406 }
407}