Skip to main content

cu_profiler_core/baseline/
mod.rs

1//! Baseline records and their on-disk store.
2//!
3//! A baseline stores not just a CU figure but the fingerprint metadata needed to
4//! decide whether a later comparison is still valid.
5
6mod compare;
7mod fingerprint;
8
9pub use compare::BaselineComparison;
10pub use fingerprint::{Fingerprint, hash_bytes, hash_str};
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::confidence::ConfidenceLevel;
17use crate::metadata::InstrumentationMode;
18
19/// A single scenario's recorded baseline.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct BaselineRecord {
22    /// Scenario name.
23    pub scenario: String,
24    /// Recorded CU.
25    pub actual_units: u64,
26    /// Budget at record time, if any.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub budget: Option<u64>,
29    /// RFC3339 timestamp, if recorded.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub timestamp: Option<String>,
32    /// Git commit, if known.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub git_commit: Option<String>,
35    /// Fingerprint of the inputs.
36    pub fingerprint: Fingerprint,
37    /// Solana/Agave crate versions, if known.
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub solana_versions: Vec<String>,
40    /// Profiler version that produced the baseline.
41    pub profiler_version: String,
42    /// Instrumentation mode at record time.
43    pub instrumentation: InstrumentationMode,
44    /// Confidence at record time.
45    pub confidence: ConfidenceLevel,
46    /// Whether the record has been explicitly approved.
47    #[serde(default)]
48    pub approved: bool,
49}
50
51/// A collection of baseline records keyed by scenario name. Serialized as a
52/// stable, sorted JSON object.
53#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
54pub struct BaselineStore {
55    /// Schema version for forward compatibility.
56    #[serde(default = "default_version")]
57    pub version: u32,
58    /// Records by scenario name (BTreeMap keeps output deterministic).
59    #[serde(default)]
60    pub records: BTreeMap<String, BaselineRecord>,
61}
62
63fn default_version() -> u32 {
64    1
65}
66
67impl BaselineStore {
68    /// An empty store at the current schema version.
69    #[must_use]
70    pub fn new() -> Self {
71        Self {
72            version: default_version(),
73            records: BTreeMap::new(),
74        }
75    }
76
77    /// Insert or replace a record.
78    pub fn insert(&mut self, record: BaselineRecord) {
79        self.records.insert(record.scenario.clone(), record);
80    }
81
82    /// Look up a record by scenario name.
83    #[must_use]
84    pub fn get(&self, scenario: &str) -> Option<&BaselineRecord> {
85        self.records.get(scenario)
86    }
87
88    /// Mark a scenario's record as approved. Returns `false` if absent.
89    pub fn approve(&mut self, scenario: &str) -> bool {
90        match self.records.get_mut(scenario) {
91            Some(r) => {
92                r.approved = true;
93                true
94            }
95            None => false,
96        }
97    }
98
99    /// Serialize to pretty JSON.
100    #[cfg(feature = "json")]
101    pub fn to_json(&self) -> crate::Result<String> {
102        Ok(serde_json::to_string_pretty(self)?)
103    }
104
105    /// Parse from JSON.
106    #[cfg(feature = "json")]
107    pub fn from_json(s: &str) -> crate::Result<Self> {
108        Ok(serde_json::from_str(s)?)
109    }
110
111    /// Load a store from a file, returning an empty store if it does not exist.
112    #[cfg(feature = "json")]
113    pub fn load(path: &std::path::Path) -> crate::Result<Self> {
114        match std::fs::read_to_string(path) {
115            Ok(s) => Self::from_json(&s),
116            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
117            Err(e) => Err(e.into()),
118        }
119    }
120
121    /// Persist the store to a file, creating parent directories.
122    #[cfg(feature = "json")]
123    pub fn save(&self, path: &std::path::Path) -> crate::Result<()> {
124        if let Some(parent) = path.parent() {
125            std::fs::create_dir_all(parent)?;
126        }
127        std::fs::write(path, self.to_json()?)?;
128        Ok(())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn record(name: &str, cu: u64) -> BaselineRecord {
137        BaselineRecord {
138            scenario: name.into(),
139            actual_units: cu,
140            budget: Some(100_000),
141            timestamp: None,
142            git_commit: None,
143            fingerprint: Fingerprint::new(name, "fix", "cfg", None),
144            solana_versions: Vec::new(),
145            profiler_version: "0.1.0".into(),
146            instrumentation: InstrumentationMode::Off,
147            confidence: ConfidenceLevel::High,
148            approved: false,
149        }
150    }
151
152    #[test]
153    fn insert_get_approve() {
154        let mut store = BaselineStore::new();
155        store.insert(record("swap", 95_000));
156        assert_eq!(store.get("swap").map(|r| r.actual_units), Some(95_000));
157        assert!(store.approve("swap"));
158        assert!(store.get("swap").unwrap().approved);
159        assert!(!store.approve("missing"));
160    }
161
162    #[cfg(feature = "json")]
163    #[test]
164    fn json_round_trip_is_stable() {
165        let mut store = BaselineStore::new();
166        store.insert(record("b", 2));
167        store.insert(record("a", 1));
168        let json = store.to_json().unwrap();
169        // BTreeMap ⇒ "a" serializes before "b".
170        assert!(json.find("\"a\"").unwrap() < json.find("\"b\"").unwrap());
171        let back = BaselineStore::from_json(&json).unwrap();
172        assert_eq!(store, back);
173    }
174}