Skip to main content

cu_profiler_core/
profiler.rs

1//! The profiler engine: runs scenarios through a backend, parses the logs, and
2//! assembles a [`Report`] — applying budget policies, baseline comparison,
3//! confidence scoring and diagnostics along the way.
4
5use crate::backend::ExecutionBackend;
6use crate::baseline::{BaselineComparison, BaselineStore, Fingerprint};
7use crate::budget::{self, PolicyResult};
8use crate::confidence::{self, Confidence, ConfidenceFactors};
9use crate::diagnostics::{self, Context};
10use crate::metadata::RunMetadata;
11use crate::model::{InstructionMeasurement, Measurement, Report, ScenarioReport, Status};
12use crate::parser::{self, ParseAnalysis};
13use crate::program_registry::ProgramRegistry;
14use crate::scenario::{ExpectedResult, Scenario};
15
16/// Configurable profiler engine.
17#[derive(Debug, Clone)]
18pub struct Profiler {
19    registry: ProgramRegistry,
20    config_repr: String,
21    include_raw_logs: bool,
22}
23
24impl Default for Profiler {
25    fn default() -> Self {
26        Self {
27            registry: ProgramRegistry::with_builtins(),
28            config_repr: String::new(),
29            include_raw_logs: false,
30        }
31    }
32}
33
34impl Profiler {
35    /// A profiler with the built-in program registry.
36    #[must_use]
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Replace the program registry (e.g. extended from config labels).
42    #[must_use]
43    pub fn with_registry(mut self, registry: ProgramRegistry) -> Self {
44        self.registry = registry;
45        self
46    }
47
48    /// Set a string representation of the effective config, hashed into
49    /// fingerprints so baselines go stale when configuration changes.
50    #[must_use]
51    pub fn with_config_repr(mut self, repr: impl Into<String>) -> Self {
52        self.config_repr = repr.into();
53        self
54    }
55
56    /// Include raw logs in each scenario report (they can be large).
57    #[must_use]
58    pub fn include_raw_logs(mut self, yes: bool) -> Self {
59        self.include_raw_logs = yes;
60        self
61    }
62
63    /// The fingerprint of a scenario under the current config.
64    #[must_use]
65    pub fn fingerprint(&self, scenario: &Scenario) -> Fingerprint {
66        Fingerprint::new(
67            &format!("{scenario:?}"),
68            &scenario.name,
69            &self.config_repr,
70            None,
71        )
72    }
73
74    /// Run all `scenarios` through `backend`, comparing against `baseline` when
75    /// provided, and assemble a [`Report`].
76    #[must_use]
77    pub fn run(
78        &self,
79        backend: &dyn ExecutionBackend,
80        scenarios: &[Scenario],
81        baseline: Option<&BaselineStore>,
82        metadata: RunMetadata,
83    ) -> Report {
84        let reports = scenarios
85            .iter()
86            .map(|s| self.profile_one(backend, s, baseline))
87            .collect();
88        Report::new(reports, metadata)
89    }
90
91    fn profile_one(
92        &self,
93        backend: &dyn ExecutionBackend,
94        scenario: &Scenario,
95        baseline: Option<&BaselineStore>,
96    ) -> ScenarioReport {
97        match backend.run(scenario) {
98            Ok(output) => {
99                let analysis = parser::analyze(&output.logs, &self.registry);
100                self.assemble(scenario, analysis, output.success, output.logs, baseline)
101            }
102            Err(e) => self.simulation_error_report(scenario, &e.to_string()),
103        }
104    }
105
106    fn assemble(
107        &self,
108        scenario: &Scenario,
109        analysis: ParseAnalysis,
110        sim_success: bool,
111        logs: Vec<String>,
112        baseline: Option<&BaselineStore>,
113    ) -> ScenarioReport {
114        // Each top-level (depth-1) invocation is one transaction instruction.
115        let per_instruction: Vec<InstructionMeasurement> = analysis
116            .call_tree
117            .children
118            .iter()
119            .enumerate()
120            .map(|(index, node)| InstructionMeasurement {
121                index,
122                program_id: node.program_id.clone(),
123                label: node.label.clone(),
124                consumed: node.units_consumed,
125            })
126            .collect();
127
128        let measurement = Measurement {
129            total_cu: analysis.total_cu,
130            consumed: analysis.total_cu,
131            requested_limit: analysis.requested_limit,
132            over_requested: analysis.over_requested,
133            cpi_count: analysis.cpi_count,
134            cpi_depth: analysis.cpi_depth,
135            unattributed_pct: analysis.unattributed_pct,
136            instrumentation_overhead_pct: None,
137            per_instruction,
138            simulation_success: sim_success && analysis.simulation_success,
139        };
140
141        // Baseline comparison.
142        let current_fp = self.fingerprint(scenario);
143        let comparison = baseline
144            .and_then(|store| store.get(&scenario.name))
145            .map(|record| {
146                BaselineComparison::compute(
147                    record.actual_units,
148                    &record.fingerprint,
149                    &measurement,
150                    &current_fp,
151                )
152            });
153        let baseline_units = comparison
154            .as_ref()
155            .filter(|c| c.matched)
156            .map(|c| c.baseline_units);
157
158        // Budget policy.
159        let policy_results: Vec<PolicyResult> =
160            budget::evaluate(&measurement, &scenario.budget, baseline_units);
161
162        // Confidence.
163        let confidence = self.score_confidence(&analysis, comparison.as_ref());
164
165        // Status.
166        let status = self.derive_status(&measurement, &policy_results, scenario.expected);
167
168        // Diagnostics.
169        let ctx = Context {
170            scenario: &scenario.name,
171            measurement: &measurement,
172            policy_results: &policy_results,
173            baseline: comparison.as_ref(),
174            confidence: &confidence,
175            expected: scenario.expected,
176            scope_count: analysis.scope_marker_count,
177            log_line_count: analysis.log_line_count,
178            late_validation: analysis.validation_after_cpi,
179        };
180        let diags = diagnostics::evaluate(&ctx);
181
182        ScenarioReport {
183            name: scenario.name.clone(),
184            status,
185            measurement,
186            call_tree: Some(analysis.call_tree),
187            scopes: analysis.scopes,
188            policy_results,
189            diagnostics: diags,
190            confidence,
191            baseline_comparison: comparison,
192            parser_warnings: analysis.warnings,
193            raw_logs: self.include_raw_logs.then_some(logs),
194        }
195    }
196
197    fn score_confidence(
198        &self,
199        analysis: &ParseAnalysis,
200        comparison: Option<&BaselineComparison>,
201    ) -> Confidence {
202        // Unattributed CU only counts against confidence when the user opted
203        // into scope attribution; otherwise it is just normal program work.
204        let unattributed_pct = if analysis.scope_marker_count > 0 {
205            analysis.unattributed_pct
206        } else {
207            0.0
208        };
209        let factors = ConfidenceFactors {
210            simulation_ok: analysis.simulation_success,
211            logs_complete: analysis.logs_complete,
212            parser_warnings: analysis.warnings.len(),
213            baseline_matched: comparison.map(|c| c.matched),
214            unattributed_pct,
215            scope_markers: analysis.scope_marker_count,
216            metadata_available: true,
217        };
218        confidence::score(&factors)
219    }
220
221    fn derive_status(
222        &self,
223        measurement: &Measurement,
224        policy_results: &[PolicyResult],
225        expected: ExpectedResult,
226    ) -> Status {
227        // An unexpected simulation outcome is a failure regardless of budgets.
228        let outcome_ok = match expected {
229            ExpectedResult::Success => measurement.simulation_success,
230            ExpectedResult::Failure => !measurement.simulation_success,
231        };
232        if !outcome_ok {
233            return Status::Fail;
234        }
235        Status::from_policy(budget::overall_status(policy_results))
236    }
237
238    fn simulation_error_report(&self, scenario: &Scenario, error: &str) -> ScenarioReport {
239        ScenarioReport {
240            name: scenario.name.clone(),
241            status: Status::Unknown,
242            measurement: Measurement {
243                simulation_success: false,
244                ..Measurement::empty()
245            },
246            call_tree: None,
247            scopes: Vec::new(),
248            policy_results: Vec::new(),
249            diagnostics: Vec::new(),
250            confidence: Confidence::unknown(format!("simulation error: {error}")),
251            baseline_comparison: None,
252            parser_warnings: vec![format!("simulation error: {error}")],
253            raw_logs: None,
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::backend::RecordedLogsBackend;
262    use crate::budget::BudgetPolicy;
263
264    fn backend() -> RecordedLogsBackend {
265        let mut b = RecordedLogsBackend::new();
266        b.insert_blob(
267            "swap",
268            "Program User111 invoke [1]\n\
269             Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]\n\
270             Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3000 of 197000 compute units\n\
271             Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success\n\
272             Program User111 consumed 96000 of 200000 compute units\n\
273             Program User111 success",
274            true,
275        );
276        b
277    }
278
279    fn swap_scenario(max: u64) -> Scenario {
280        let mut s = Scenario::new("swap");
281        s.budget = BudgetPolicy {
282            absolute_max_cu: Some(max),
283            warn_at_budget_pct: Some(90.0),
284            ..Default::default()
285        };
286        s
287    }
288
289    #[test]
290    fn end_to_end_pass() {
291        let report = Profiler::new().run(
292            &backend(),
293            &[swap_scenario(200_000)],
294            None,
295            RunMetadata::recorded("0.1.0"),
296        );
297        assert_eq!(report.scenarios[0].status, Status::Pass);
298        assert_eq!(report.scenarios[0].measurement.total_cu, 96_000);
299        assert_eq!(
300            report.scenarios[0].confidence.level,
301            confidence::ConfidenceLevel::High
302        );
303
304        // The single top-level program invocation is recorded as one instruction.
305        let per = &report.scenarios[0].measurement.per_instruction;
306        assert_eq!(per.len(), 1);
307        assert_eq!(per[0].index, 0);
308        assert_eq!(per[0].program_id, "User111");
309        assert_eq!(per[0].consumed, Some(96_000));
310    }
311
312    #[test]
313    fn end_to_end_warn_near_budget() {
314        let report = Profiler::new().run(
315            &backend(),
316            &[swap_scenario(100_000)],
317            None,
318            RunMetadata::recorded("0.1.0"),
319        );
320        assert_eq!(report.scenarios[0].status, Status::Warn);
321        assert!(
322            report.scenarios[0]
323                .diagnostics
324                .iter()
325                .any(|d| d.id == "near_budget_limit")
326        );
327    }
328
329    #[test]
330    fn missing_scenario_yields_unknown() {
331        let report = Profiler::new().run(
332            &RecordedLogsBackend::new(),
333            &[Scenario::new("ghost")],
334            None,
335            RunMetadata::recorded("0.1.0"),
336        );
337        assert_eq!(report.scenarios[0].status, Status::Unknown);
338        assert!(report.has_failures());
339    }
340}