Skip to main content

cu_profiler_core/
model.rs

1//! The report data model: the raw, serializable result of a profiling run.
2//!
3//! This module owns *data*. Rendering to table/JSON/Markdown/JUnit lives in the
4//! `cu-profiler-report` crate, keeping raw data and presentation separate.
5
6use serde::{Deserialize, Serialize};
7
8use crate::baseline::BaselineComparison;
9use crate::budget::PolicyResult;
10use crate::confidence::Confidence;
11use crate::diagnostics::Diagnostic;
12use crate::metadata::RunMetadata;
13use crate::parser::{CallNode, ScopeResult};
14
15/// Headline pass/warn/fail/unknown status of a scenario.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Status {
19    /// All policies satisfied.
20    Pass,
21    /// A soft threshold tripped.
22    Warn,
23    /// A hard limit breached, or an unexpected outcome.
24    Fail,
25    /// Not enough information to judge.
26    Unknown,
27}
28
29impl Status {
30    /// Uppercase label used in tables and JUnit.
31    #[must_use]
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::Pass => "PASS",
35            Self::Warn => "WARN",
36            Self::Fail => "FAIL",
37            Self::Unknown => "UNKNOWN",
38        }
39    }
40
41    /// Derive a status from a rolled-up budget [`crate::budget::PolicyStatus`].
42    #[must_use]
43    pub fn from_policy(p: crate::budget::PolicyStatus) -> Self {
44        match p {
45            crate::budget::PolicyStatus::Pass => Self::Pass,
46            crate::budget::PolicyStatus::Warn => Self::Warn,
47            crate::budget::PolicyStatus::Fail => Self::Fail,
48        }
49    }
50}
51
52/// Compute attributed to a single instruction within a transaction.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct InstructionMeasurement {
55    /// Zero-based index in the transaction.
56    pub index: usize,
57    /// Program that owns the instruction.
58    pub program_id: String,
59    /// Resolved label, if known.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub label: Option<String>,
62    /// CU consumed, if the logs reported it.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub consumed: Option<u64>,
65}
66
67/// Distribution of `total_cu` across multiple measurement samples.
68///
69/// Only present when a scenario was run more than once on a *non-deterministic*
70/// backend (`Scenario::samples > 1`); the recorded backend is deterministic, so it
71/// never produces this — the tool never fabricates a spread it did not observe.
72#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
73pub struct SampleStats {
74    /// Number of samples taken.
75    pub count: u32,
76    /// Smallest observed `total_cu`.
77    pub min: u64,
78    /// Median observed `total_cu`.
79    pub median: u64,
80    /// Largest observed `total_cu`.
81    pub max: u64,
82    /// Population variance of `total_cu`.
83    pub variance: f64,
84    /// Standard deviation of `total_cu` (`sqrt(variance)`).
85    pub std_dev: f64,
86    /// Coefficient of variation (`std_dev / mean`); `0.0` when the mean is `0`.
87    pub cv: f64,
88}
89
90impl SampleStats {
91    /// Compute statistics over the per-sample `total_cu` values, or `None` for
92    /// fewer than two samples (a single run has no distribution).
93    #[must_use]
94    pub fn from_samples(totals: &[u64]) -> Option<Self> {
95        if totals.len() < 2 {
96            return None;
97        }
98        let count = totals.len() as u32;
99        let min = *totals.iter().min()?;
100        let max = *totals.iter().max()?;
101
102        let mut sorted = totals.to_vec();
103        sorted.sort_unstable();
104        let mid = sorted.len() / 2;
105        let median = if sorted.len() % 2 == 1 {
106            sorted[mid]
107        } else {
108            (sorted[mid - 1] + sorted[mid]) / 2
109        };
110
111        let n = totals.len() as f64;
112        let mean = totals.iter().map(|&x| x as f64).sum::<f64>() / n;
113        let variance = totals
114            .iter()
115            .map(|&x| {
116                let d = x as f64 - mean;
117                d * d
118            })
119            .sum::<f64>()
120            / n;
121        let std_dev = variance.sqrt();
122        let cv = if mean == 0.0 { 0.0 } else { std_dev / mean };
123
124        Some(Self {
125            count,
126            min,
127            median,
128            max,
129            variance,
130            std_dev,
131            cv,
132        })
133    }
134}
135
136/// The quantitative core of a scenario result.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct Measurement {
139    /// Total CU consumed across the transaction.
140    pub total_cu: u64,
141    /// CU actually consumed (equals `total_cu` for single-tx scenarios).
142    pub consumed: u64,
143    /// Compute-budget requested limit, if a `SetComputeUnitLimit` was present.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub requested_limit: Option<u64>,
146    /// CU requested but unused (`requested_limit - consumed`), if known.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub over_requested: Option<u64>,
149    /// Number of CPI invocations observed.
150    pub cpi_count: u32,
151    /// Maximum CPI invoke depth observed.
152    pub cpi_depth: u32,
153    /// Percentage of total CU not attributed to any scope (0..=100).
154    pub unattributed_pct: f64,
155    /// Instrumentation overhead as a percentage of total CU, if estimable.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub instrumentation_overhead_pct: Option<f64>,
158    /// Per-instruction breakdown.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub per_instruction: Vec<InstructionMeasurement>,
161    /// Distribution of `total_cu` across samples, when multi-sampled (>1 run on a
162    /// non-deterministic backend). Absent for single-sample / recorded runs.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub sample_stats: Option<SampleStats>,
165    /// Whether the simulation completed successfully.
166    pub simulation_success: bool,
167}
168
169impl Measurement {
170    /// A zeroed measurement (successful, no compute) — a useful base for tests
171    /// and for `..Measurement::empty()` struct-update syntax.
172    #[must_use]
173    pub fn empty() -> Self {
174        Self {
175            total_cu: 0,
176            consumed: 0,
177            requested_limit: None,
178            over_requested: None,
179            cpi_count: 0,
180            cpi_depth: 0,
181            unattributed_pct: 0.0,
182            instrumentation_overhead_pct: None,
183            per_instruction: Vec::new(),
184            sample_stats: None,
185            simulation_success: true,
186        }
187    }
188}
189
190/// The full result for one scenario.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct ScenarioReport {
193    /// Scenario name.
194    pub name: String,
195    /// Headline status.
196    pub status: Status,
197    /// Quantitative measurement.
198    pub measurement: Measurement,
199    /// Reconstructed CPI call tree, if logs allowed it.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub call_tree: Option<CallNode>,
202    /// Scope attribution results.
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub scopes: Vec<ScopeResult>,
205    /// Budget policy results.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub policy_results: Vec<PolicyResult>,
208    /// Diagnostics raised for this scenario.
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub diagnostics: Vec<Diagnostic>,
211    /// Confidence in this measurement.
212    pub confidence: Confidence,
213    /// Baseline comparison, if a baseline was available.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub baseline_comparison: Option<BaselineComparison>,
216    /// Non-fatal parser warnings.
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub parser_warnings: Vec<String>,
219    /// Raw logs, included only when the caller opts in (they can be large).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub raw_logs: Option<Vec<String>>,
222}
223
224/// Aggregate counts across all scenarios.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226pub struct Summary {
227    /// Number of scenarios profiled.
228    pub total_scenarios: usize,
229    /// How many passed.
230    pub passed: usize,
231    /// How many warned.
232    pub warned: usize,
233    /// How many failed.
234    pub failed: usize,
235    /// Sum of total CU across scenarios.
236    pub total_cu: u64,
237}
238
239/// The complete profiling report.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct Report {
242    /// Aggregate summary.
243    pub summary: Summary,
244    /// Per-scenario results.
245    pub scenarios: Vec<ScenarioReport>,
246    /// Run metadata.
247    pub metadata: RunMetadata,
248}
249
250impl Report {
251    /// Assemble a report from scenario results, computing the summary.
252    #[must_use]
253    pub fn new(scenarios: Vec<ScenarioReport>, metadata: RunMetadata) -> Self {
254        let mut summary = Summary {
255            total_scenarios: scenarios.len(),
256            passed: 0,
257            warned: 0,
258            failed: 0,
259            total_cu: 0,
260        };
261        for s in &scenarios {
262            summary.total_cu = summary.total_cu.saturating_add(s.measurement.total_cu);
263            match s.status {
264                Status::Pass => summary.passed += 1,
265                Status::Warn => summary.warned += 1,
266                Status::Fail | Status::Unknown => summary.failed += 1,
267            }
268        }
269        Self {
270            summary,
271            scenarios,
272            metadata,
273        }
274    }
275
276    /// Did any scenario fail?
277    #[must_use]
278    pub fn has_failures(&self) -> bool {
279        self.summary.failed > 0
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::confidence::Confidence;
287    use crate::metadata::RunMetadata;
288
289    #[test]
290    fn sample_stats_needs_at_least_two_samples() {
291        assert!(SampleStats::from_samples(&[]).is_none());
292        assert!(SampleStats::from_samples(&[42]).is_none());
293    }
294
295    #[test]
296    fn sample_stats_computes_spread() {
297        let s = SampleStats::from_samples(&[100, 120, 110]).expect("two+ samples");
298        assert_eq!(s.count, 3);
299        assert_eq!(s.min, 100);
300        assert_eq!(s.max, 120);
301        assert_eq!(s.median, 110);
302        // mean 110, population variance ((100)+(100)+0)/3 = 66.67, std ~8.16.
303        assert!((s.variance - 66.6667).abs() < 0.01);
304        assert!((s.std_dev - 8.165).abs() < 0.01);
305        assert!((s.cv - 0.0742).abs() < 0.001);
306    }
307
308    #[test]
309    fn sample_stats_identical_samples_have_zero_variance() {
310        let s = SampleStats::from_samples(&[500, 500, 500, 500]).unwrap();
311        assert_eq!(s.variance, 0.0);
312        assert_eq!(s.cv, 0.0);
313        assert_eq!(s.median, 500);
314    }
315
316    #[test]
317    fn sample_stats_even_count_medians_the_middle_pair() {
318        let s = SampleStats::from_samples(&[10, 20, 30, 40]).unwrap();
319        assert_eq!(s.median, 25); // (20 + 30) / 2
320    }
321
322    fn scenario(name: &str, status: Status, cu: u64) -> ScenarioReport {
323        ScenarioReport {
324            name: name.into(),
325            status,
326            measurement: Measurement {
327                total_cu: cu,
328                ..Measurement::empty()
329            },
330            call_tree: None,
331            scopes: Vec::new(),
332            policy_results: Vec::new(),
333            diagnostics: Vec::new(),
334            confidence: Confidence::high(),
335            baseline_comparison: None,
336            parser_warnings: Vec::new(),
337            raw_logs: None,
338        }
339    }
340
341    #[test]
342    fn summary_counts_and_totals() {
343        let r = Report::new(
344            vec![
345                scenario("a", Status::Pass, 100),
346                scenario("b", Status::Warn, 200),
347                scenario("c", Status::Fail, 300),
348            ],
349            RunMetadata::recorded("0.1.0"),
350        );
351        assert_eq!(r.summary.passed, 1);
352        assert_eq!(r.summary.warned, 1);
353        assert_eq!(r.summary.failed, 1);
354        assert_eq!(r.summary.total_cu, 600);
355        assert!(r.has_failures());
356    }
357}