Skip to main content

fluxbench_logic/
verification.rs

1//! Verification Execution
2//!
3//! Runs performance assertions with explicit status handling for missing dependencies.
4
5use crate::MetricContext;
6use fxhash::FxHashSet;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::sync::OnceLock;
10
11// Re-export the single canonical Severity from fluxbench-core
12pub use fluxbench_core::Severity;
13
14/// A resolved metric value with its unit
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ResolvedMetric {
17    /// Metric name
18    pub name: String,
19    /// Metric value
20    pub value: f64,
21    /// Unit (e.g., "ns", "req/s", "MB/s"). None means nanoseconds (default).
22    pub unit: Option<String>,
23}
24
25/// Verification definition
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Verification {
28    /// Unique identifier for the verification
29    pub id: String,
30    /// Expression to evaluate (e.g., "(raw - overhead) < 50")
31    pub expression: String,
32    /// Severity level (Critical, Warning, Info)
33    pub severity: Severity,
34    /// Tolerance margin for numeric comparisons
35    pub margin: f64,
36}
37
38/// Verification execution status with explicit states for all outcomes.
39///
40/// **Critical Design Decision**: Simple pass/fail boolean is insufficient.
41/// We need explicit states to distinguish:
42/// - Skipped: Dependency benchmark crashed or was filtered out
43/// - Error: Expression evaluation failed (typo, type mismatch)
44/// - Passed/Failed: Actual verification result
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub enum VerificationStatus {
47    /// Verification passed (expression evaluated to non-zero/true)
48    Passed,
49    /// Verification failed (expression evaluated to zero/false)
50    Failed,
51    /// Verification could not run - dependency data missing
52    Skipped {
53        /// Which metric(s) were unavailable
54        missing_metrics: String,
55    },
56    /// Verification encountered an error during evaluation
57    Error {
58        /// Error message
59        message: String,
60    },
61}
62
63impl VerificationStatus {
64    /// Returns true if this is a Passed status.
65    pub fn is_success(&self) -> bool {
66        matches!(self, VerificationStatus::Passed)
67    }
68
69    /// Returns true if this is a Failed status.
70    pub fn is_failure(&self) -> bool {
71        matches!(self, VerificationStatus::Failed)
72    }
73
74    /// Returns true if this is either Failed or Error (requires action).
75    pub fn is_actionable_failure(&self) -> bool {
76        matches!(
77            self,
78            VerificationStatus::Failed | VerificationStatus::Error { .. }
79        )
80    }
81
82    /// Returns true if this status should cause CI to fail given the severity level.
83    ///
84    /// Only critical failures and critical errors affect exit code; skipped verifications
85    /// do not fail CI since the underlying dependency crash already did.
86    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            // Skipped never fails CI - the dependency crash already did
91            (VerificationStatus::Skipped { .. }, _) => false,
92            _ => false,
93        }
94    }
95}
96
97/// Result of a verification check
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct VerificationResult {
100    /// Unique identifier for the verification
101    pub id: String,
102    /// Expression that was evaluated
103    pub expression: String,
104    /// Status of the verification (Passed, Failed, Skipped, or Error)
105    pub status: VerificationStatus,
106    /// Actual computed value from the expression, if available
107    pub actual_value: Option<f64>,
108    /// Resolved metric values used in the expression
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub resolved_metrics: Vec<ResolvedMetric>,
111    /// Severity level of the verification
112    pub severity: Severity,
113    /// Human-readable message describing the result
114    pub message: String,
115}
116
117impl VerificationResult {
118    /// Convenience method for backward compatibility
119    pub fn passed(&self) -> bool {
120        self.status.is_success()
121    }
122}
123
124/// Context for verification with explicit missing metric tracking
125pub struct VerificationContext<'a> {
126    /// Reference to available metrics for expression evaluation
127    metrics: &'a MetricContext,
128    /// Metrics that are unavailable (crashed/filtered benchmarks)
129    unavailable: FxHashSet<String>,
130    /// Unit info for metrics (metric name → unit string). Absent means nanoseconds.
131    metric_units: std::collections::HashMap<String, String>,
132}
133
134impl<'a> VerificationContext<'a> {
135    /// Creates a new verification context with the given metrics and unavailable metric names.
136    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    /// Set the unit for a metric (e.g., "req/s", "MB/s")
145    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    /// Check if expression references any unavailable metrics
150    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    /// Check if expression references any unknown variables (typos/renames).
167    /// Returns `Some(list)` of unknown variable names that are neither in metrics,
168    /// unavailable set, nor builtin functions.
169    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    /// Get available metric names for error hints
187    pub fn available_metric_names(&self) -> Vec<String> {
188        self.metrics.metric_names().cloned().collect()
189    }
190}
191
192/// Extract variable names from an evalexpr expression
193fn extract_variables(expression: &str) -> Vec<String> {
194    static IDENT_RE: OnceLock<Regex> = OnceLock::new();
195    // Safety: this regex literal is guaranteed to compile
196    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
212/// Run all verifications
213///
214/// Evaluates each verification expression against available metrics. Handles missing dependencies,
215/// unknown variables, and expression evaluation errors explicitly in the results.
216///
217/// # Arguments
218/// * `verifications` - List of verification definitions to evaluate
219/// * `context` - Context containing available metrics and tracking unavailable ones
220///
221/// # Returns
222/// Vector of verification results with status and messages for each verification
223pub fn run_verifications(
224    verifications: &[Verification],
225    context: &VerificationContext,
226) -> Vec<VerificationResult> {
227    verifications
228        .iter()
229        .map(|v| {
230            // Step 1: Check if dependencies are available
231            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            // Step 2: Check for unknown variables (typos/renames) before evaluation
246            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            // Resolve metric values used in this expression
267            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            // Step 3: Evaluate the expression
280            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/// Summary of verification results
321#[derive(Debug, Default, Serialize, Deserialize)]
322pub struct VerificationSummary {
323    /// Number of verifications that passed
324    pub passed: usize,
325    /// Number of verifications that failed
326    pub failed: usize,
327    /// Number of verifications that were skipped (due to missing dependencies)
328    pub skipped: usize,
329    /// Number of verifications that had evaluation errors
330    pub errors: usize,
331    /// Number of critical verifications that failed
332    pub critical_failures: usize,
333    /// Number of critical verifications that had errors
334    pub critical_errors: usize,
335}
336
337impl VerificationSummary {
338    /// Should CI fail based on verification results?
339    pub fn should_fail_ci(&self) -> bool {
340        self.critical_failures > 0 || self.critical_errors > 0
341    }
342
343    /// Total verifications that ran (excludes skipped)
344    pub fn total_executed(&self) -> usize {
345        self.passed + self.failed + self.errors
346    }
347}
348
349/// Aggregate verification results for CI reporting
350///
351/// Counts all verification statuses and identifies critical failures/errors that should
352/// cause CI to fail.
353///
354/// # Arguments
355/// * `results` - Verification results to summarize
356///
357/// # Returns
358/// Summary with counts and critical status information
359pub 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())); // builtin
394    }
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}