Skip to main content

keyhog_core/
calibration.rs

1//! Bayesian Beta(α, β) calibration per detector.
2//!
3//! Tier-B moat innovation #4 from audits/legendary-2026-04-26: surface
4//! per-detector reliability based on observed true-positive vs false-
5//! positive history rather than a fixed threshold. Detectors with a long
6//! history of clean hits get a higher confidence multiplier; detectors
7//! that fire-then-suppress repeatedly get downweighted.
8//!
9//! Mathematical model:
10//!     each detector has a Beta(α, β) prior over P(true positive | match).
11//!     α counts confirmed TPs, β counts confirmed FPs (both incremented from
12//!     a starting prior of α=1, β=1 - uniform Beta(1, 1)).
13//!     posterior mean = α / (α + β)  ∈ [0, 1].
14//!
15//! Storage: JSON at `$XDG_CACHE_HOME/keyhog/calibration.json` with a schema
16//! version field. Load returns an empty store on miss / corrupted JSON /
17//! schema mismatch - never poison the cache from a damaged artifact.
18//!
19//! This module ships the DATA layer only. Live integration into the
20//! scanner's confidence-scoring path is a separate change that needs
21//! per-detector lookup at `apply_post_ml_penalties` time.
22
23#![allow(missing_docs)]
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use parking_lot::RwLock;
29use serde::{Deserialize, Serialize};
30
31/// A detector's running Beta posterior counters. Always ≥1 each (Beta(1,1)
32/// uniform prior baseline) to avoid posterior_mean undefined when a detector
33/// has had no observations yet.
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub struct BetaCounters {
36    pub alpha: u32,
37    pub beta: u32,
38}
39
40impl Default for BetaCounters {
41    fn default() -> Self {
42        Self { alpha: 1, beta: 1 }
43    }
44}
45
46impl BetaCounters {
47    /// Posterior mean: α / (α + β). Falls in [0, 1]; the higher, the more
48    /// reliable the detector is historically.
49    pub fn posterior_mean(&self) -> f64 {
50        let total = self.alpha as f64 + self.beta as f64;
51        if total == 0.0 {
52            0.5
53        } else {
54            self.alpha as f64 / total
55        }
56    }
57
58    /// Number of observations (excluding the prior) the posterior is built
59    /// on. Useful for "trust the recent history" UI gates.
60    ///
61    /// kimi-confidence audit: the previous form was
62    /// `alpha.saturating_sub(1) + beta.saturating_sub(1)` - the `+`
63    /// was a plain add and would panic in debug / wrap to 0 in release
64    /// once both counters reached ~`u32::MAX / 2`. Use `saturating_add`
65    /// so the result clamps at `u32::MAX` instead of wrapping. That's
66    /// still a frozen counter at saturation, but the posterior mean
67    /// stays correct and no detector silently gets disabled.
68    pub fn observations(&self) -> u32 {
69        // Subtract the Beta(1, 1) prior baseline.
70        self.alpha
71            .saturating_sub(1)
72            .saturating_add(self.beta.saturating_sub(1))
73    }
74}
75
76/// On-disk format. The version field gates breaking schema changes.
77#[derive(Debug, Serialize, Deserialize)]
78struct OnDisk {
79    version: u32,
80    detectors: HashMap<String, BetaCounters>,
81}
82
83const SCHEMA_VERSION: u32 = 1;
84
85/// Process-wide calibration store. Concurrent updates are serialized via
86/// a single `RwLock` because update events are rare (one per `keyhog
87/// calibrate` invocation or per verifier outcome) and the locked region is
88/// constant-time. We deliberately don't shard via DashMap - the persisted
89/// artifact is small enough that contention is a non-issue.
90#[derive(Debug, Default)]
91pub struct Calibration {
92    inner: RwLock<HashMap<String, BetaCounters>>,
93}
94
95impl Calibration {
96    pub fn empty() -> Self {
97        Self::default()
98    }
99
100    pub fn load(path: &Path) -> Self {
101        let bytes = match std::fs::read(path) {
102            Ok(b) => b,
103            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::empty(),
104            Err(e) => {
105                tracing::warn!(
106                    cache = %path.display(),
107                    error = %e,
108                    "calibration file read failed; treating as cold start"
109                );
110                return Self::empty();
111            }
112        };
113        let on_disk: OnDisk = match serde_json::from_slice(&bytes) {
114            Ok(d) => d,
115            Err(e) => {
116                tracing::warn!(
117                    cache = %path.display(),
118                    error = %e,
119                    "calibration parse failed; treating as cold start"
120                );
121                return Self::empty();
122            }
123        };
124        if on_disk.version != SCHEMA_VERSION {
125            tracing::warn!(
126                cache = %path.display(),
127                version = on_disk.version,
128                expected = SCHEMA_VERSION,
129                "calibration schema mismatch; treating as cold start"
130            );
131            return Self::empty();
132        }
133        Self {
134            inner: RwLock::new(on_disk.detectors),
135        }
136    }
137
138    pub fn save(&self, path: &Path) -> std::io::Result<()> {
139        let detectors = self.inner.read().clone();
140        let on_disk = OnDisk {
141            version: SCHEMA_VERSION,
142            detectors,
143        };
144        let serialized = serde_json::to_vec_pretty(&on_disk)
145            .map_err(|e| std::io::Error::other(format!("calibration encode: {e}")))?;
146        let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
147        std::fs::create_dir_all(parent)?;
148        // Same atomic-write-via-NamedTempFile pattern used by
149        // `merkle_index::save` - see that file's note for rationale.
150        let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
151        std::io::Write::write_all(&mut tmp, &serialized)?;
152        tmp.as_file().sync_all()?;
153        tmp.persist(path).map_err(|e| e.error)?;
154        Ok(())
155    }
156
157    /// Record a true positive for `detector_id` (α += 1).
158    ///
159    /// kimi-confidence audit: bare `alpha += 1` would panic in debug
160    /// and wrap to 0 in release once a single detector accumulates
161    /// 2^32 observations. Wrapping to 0 silently mutes a previously
162    /// reliable detector (posterior mean drops to 0.0/1.0 = 0). Use
163    /// `saturating_add` so the worst case is a frozen counter at
164    /// `u32::MAX`, which keeps the posterior mean correct.
165    pub fn record_true_positive(&self, detector_id: &str) {
166        let mut guard = self.inner.write();
167        let entry = guard.entry(detector_id.to_string()).or_default();
168        entry.alpha = entry.alpha.saturating_add(1);
169    }
170
171    /// Record a false positive for `detector_id` (β += 1). Same
172    /// `saturating_add` rationale as [`record_true_positive`].
173    pub fn record_false_positive(&self, detector_id: &str) {
174        let mut guard = self.inner.write();
175        let entry = guard.entry(detector_id.to_string()).or_default();
176        entry.beta = entry.beta.saturating_add(1);
177    }
178
179    /// Return the posterior mean for `detector_id`, falling back to 0.5
180    /// when no observations exist (uniform prior over a never-calibrated
181    /// detector). Callers MAY use this value as a confidence multiplier
182    /// inside the scanner's confidence-scoring path; the live integration
183    /// is staged separately.
184    pub fn confidence_multiplier(&self, detector_id: &str) -> f64 {
185        self.inner
186            .read()
187            .get(detector_id)
188            .copied()
189            .unwrap_or_default()
190            .posterior_mean()
191    }
192
193    /// Return the full counters for `detector_id` (defaults to Beta(1, 1)).
194    pub fn counters(&self, detector_id: &str) -> BetaCounters {
195        self.inner
196            .read()
197            .get(detector_id)
198            .copied()
199            .unwrap_or_default()
200    }
201
202    /// Iterate every recorded `(detector_id, counters)`. Useful for
203    /// `keyhog calibrate --show`.
204    pub fn entries(&self) -> Vec<(String, BetaCounters)> {
205        let mut out: Vec<_> = self
206            .inner
207            .read()
208            .iter()
209            .map(|(k, v)| (k.clone(), *v))
210            .collect();
211        out.sort_by(|a, b| a.0.cmp(&b.0));
212        out
213    }
214
215    /// Test-only hook for saturation oracle tests in `tests/unit/`.
216    #[doc(hidden)]
217    pub fn test_seed_counters(&self, id: &str, alpha: u32, beta: u32) {
218        let mut guard = self.inner.write();
219        let entry = guard.entry(id.to_string()).or_default();
220        entry.alpha = alpha;
221        entry.beta = beta;
222    }
223}
224
225/// Default cache location: `$XDG_CACHE_HOME/keyhog/calibration.json` (or
226/// the macOS/Windows equivalents via the `dirs` crate).
227pub fn default_cache_path() -> Option<PathBuf> {
228    dirs::cache_dir().map(|d| d.join("keyhog").join("calibration.json"))
229}