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}