Skip to main content

cu_profiler_core/baseline/
fingerprint.rs

1//! Deterministic fingerprints used to decide whether a baseline still applies.
2//!
3//! Hashing uses FNV-1a: small, dependency-free, and stable across Rust versions
4//! and platforms, which matters because fingerprints are persisted in baseline
5//! files and compared later.
6
7use serde::{Deserialize, Serialize};
8
9const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
10const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
11
12/// FNV-1a hash of `bytes`, rendered as lowercase hex.
13#[must_use]
14pub fn hash_bytes(bytes: &[u8]) -> String {
15    let mut h = FNV_OFFSET;
16    for &b in bytes {
17        h ^= u64::from(b);
18        h = h.wrapping_mul(FNV_PRIME);
19    }
20    format!("{h:016x}")
21}
22
23/// FNV-1a hash of a string.
24#[must_use]
25pub fn hash_str(s: &str) -> String {
26    hash_bytes(s.as_bytes())
27}
28
29/// The fingerprint of the inputs that produced a measurement. If any component
30/// changes, a stored baseline is considered stale for the affected reason.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Fingerprint {
33    /// Hash of the scenario definition.
34    pub scenario_hash: String,
35    /// Hash of the account/fixture inputs.
36    pub fixture_hash: String,
37    /// Hash of the effective configuration.
38    pub config_hash: String,
39    /// Hash of the program binary, if available.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub program_binary_hash: Option<String>,
42}
43
44impl Fingerprint {
45    /// Build a fingerprint from the raw input strings.
46    #[must_use]
47    pub fn new(scenario: &str, fixture: &str, config: &str, program_binary: Option<&[u8]>) -> Self {
48        Self {
49            scenario_hash: hash_str(scenario),
50            fixture_hash: hash_str(fixture),
51            config_hash: hash_str(config),
52            program_binary_hash: program_binary.map(hash_bytes),
53        }
54    }
55
56    /// List the reasons `self` differs from `other` (empty means they match).
57    #[must_use]
58    pub fn staleness_reasons(&self, other: &Fingerprint) -> Vec<String> {
59        let mut reasons = Vec::new();
60        if self.scenario_hash != other.scenario_hash {
61            reasons.push("scenario definition changed".to_string());
62        }
63        if self.fixture_hash != other.fixture_hash {
64            reasons.push("fixture hash changed".to_string());
65        }
66        if self.config_hash != other.config_hash {
67            reasons.push("config hash changed".to_string());
68        }
69        if self.program_binary_hash != other.program_binary_hash {
70            reasons.push("program binary hash changed".to_string());
71        }
72        reasons
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn hash_is_stable_and_distinct() {
82        assert_eq!(hash_str("abc"), hash_str("abc"));
83        assert_ne!(hash_str("abc"), hash_str("abd"));
84    }
85
86    #[test]
87    fn detects_changed_fixture() {
88        let a = Fingerprint::new("s", "fix1", "cfg", None);
89        let b = Fingerprint::new("s", "fix2", "cfg", None);
90        let reasons = a.staleness_reasons(&b);
91        assert_eq!(reasons, vec!["fixture hash changed".to_string()]);
92    }
93}