keyhog_core/calibration.rs
1//! Bayesian Beta(α, β) *confidence* calibration, per detector.
2//!
3//! Disambiguation: this is the `keyhog calibrate --tp/--fp` subsystem that
4//! shapes a finding's confidence score from operator-confirmed true/false
5//! positives. It is unrelated to *autoroute* "calibration" (backend selection
6//! timing/parity) in `keyhog_cli`'s `orchestrator::dispatch::backend`; the two
7//! share only the English word "calibration".
8//!
9//! Tier-B moat innovation #4 from the internal design notes: surface
10//! per-detector reliability based on observed true-positive vs false-
11//! positive history rather than a fixed threshold. Detectors with a long
12//! history of clean hits get a higher confidence multiplier; detectors
13//! that fire-then-suppress repeatedly get downweighted.
14//!
15//! Mathematical model:
16//! each detector has a Beta(α, β) prior over P(true positive | match).
17//! α counts confirmed TPs, β counts confirmed FPs (both incremented from
18//! a starting prior of α=1, β=1 - uniform Beta(1, 1)).
19//! posterior mean = α / (α + β) ∈ [0, 1].
20//!
21//! Storage: JSON at `$XDG_CACHE_HOME/keyhog/calibration.json` with a schema
22//! version field. [`Calibration::try_load`] distinguishes a missing cache from
23//! a damaged artifact so operator commands can fail closed instead of
24//! overwriting corrupt state as if it were a clean first run. The legacy
25//! [`Calibration::load`] API remains tolerant for compatibility; production
26//! operator paths must use the strict loader when the cache is explicit.
27//!
28//! Coherence contract (audit organization/coherence finding): this module is
29//! the DATA layer, but it is now LIVE - the scanner's confidence-scoring path
30//! (`scanner::confidence::apply_calibration_multiplier`) reads these counters.
31//! Because a calibration artifact silently present on one machine but absent on
32//! another would make `tuned != benched != shipped`, the integration MUST be
33//! opt-in and deterministic: the scoring path only consults a calibration store
34//! when one is explicitly supplied, and the default / benchmark / CI scan runs
35//! with an [`empty`](Calibration::empty) store so two machines produce identical
36//! findings for the same input. A stray `$XDG_CACHE_HOME` artifact on a dev box
37//! must never silently alter results - that gating lives in the scanner crate.
38
39#![allow(missing_docs)]
40
41use std::collections::HashMap;
42use std::path::{Path, PathBuf};
43
44use parking_lot::RwLock;
45use serde::{Deserialize, Serialize};
46use thiserror::Error;
47
48const CALIBRATION_TMP_PREFIX: &str = ".tmp.keyhog-calibration-";
49use crate::state_file::{self, CALIBRATION_CACHE_FILE_BYTES};
50use crate::STALE_TMP_CUTOFF_SECS;
51
52/// A detector's running Beta posterior counters. Always ≥1 each (Beta(1,1)
53/// uniform prior baseline) to avoid posterior_mean undefined when a detector
54/// has had no observations yet.
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(deny_unknown_fields)]
57pub struct BetaCounters {
58 pub alpha: u32,
59 pub beta: u32,
60}
61
62impl Default for BetaCounters {
63 fn default() -> Self {
64 Self { alpha: 1, beta: 1 }
65 }
66}
67
68impl BetaCounters {
69 /// Posterior mean: α / (α + β). Falls in [0, 1]; the higher, the more
70 /// reliable the detector is historically.
71 ///
72 /// This is the single canonical implementation. The scanner
73 /// confidence pipeline and the `keyhog calibrate --show` UI both call
74 /// this method rather than re-deriving the arithmetic, so the three
75 /// crates can never drift apart.
76 pub fn posterior_mean(&self) -> f64 {
77 let total = self.alpha as f64 + self.beta as f64;
78 if total == 0.0 {
79 0.5
80 } else {
81 self.alpha as f64 / total
82 }
83 }
84
85 /// Number of observations (excluding the prior) the posterior is built
86 /// on. Useful for "trust the recent history" UI gates.
87 ///
88 /// kimi-confidence audit: the previous form was
89 /// `alpha.saturating_sub(1) + beta.saturating_sub(1)` - the `+`
90 /// was a plain add and would panic in debug / wrap to 0 in release
91 /// once both counters reached ~`u32::MAX / 2`. Use `saturating_add`
92 /// so the result clamps at `u32::MAX` instead of wrapping. That's
93 /// still a frozen counter at saturation, but the posterior mean
94 /// stays correct and no detector silently gets disabled.
95 ///
96 /// Public for the same single-source-of-truth reason as
97 /// [`BetaCounters::posterior_mean`]: the scanner observation gate and
98 /// the CLI `--show` table share this one implementation.
99 pub fn observations(&self) -> u32 {
100 // Subtract the Beta(1, 1) prior baseline.
101 self.alpha
102 .saturating_sub(1)
103 .saturating_add(self.beta.saturating_sub(1))
104 }
105}
106
107/// On-disk format. The version field gates breaking schema changes.
108#[derive(Debug, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110struct OnDisk {
111 version: u32,
112 detectors: HashMap<String, BetaCounters>,
113}
114
115const SCHEMA_VERSION: u32 = 1;
116
117/// Error returned when an existing calibration cache cannot be trusted.
118#[derive(Debug, Error)]
119pub enum CalibrationLoadError {
120 /// The cache file exists but could not be read.
121 #[error("calibration cache '{}' could not be read: {source}", path.display())]
122 Read {
123 path: PathBuf,
124 source: std::io::Error,
125 },
126 /// The cache file exists but is not valid JSON for the calibration schema.
127 #[error("calibration cache '{}' is not valid JSON: {source}", path.display())]
128 Parse {
129 path: PathBuf,
130 source: serde_json::Error,
131 },
132 /// The cache JSON has a version this binary does not understand.
133 #[error(
134 "calibration cache '{}' has schema version {found}; expected {expected}",
135 path.display()
136 )]
137 SchemaVersion {
138 path: PathBuf,
139 found: u32,
140 expected: u32,
141 },
142 /// The cache JSON matches the schema shape but violates semantic invariants.
143 #[error(
144 "calibration cache '{}' has invalid counters for detector '{}': alpha={alpha}, beta={beta}; counters must be >= 1",
145 path.display(),
146 detector_id
147 )]
148 InvalidCounters {
149 path: PathBuf,
150 detector_id: String,
151 alpha: u32,
152 beta: u32,
153 },
154 /// Detector identifiers are part of the persisted routing identity.
155 #[error("calibration cache '{}' contains an empty detector id", path.display())]
156 EmptyDetectorId { path: PathBuf },
157 /// The cache file exceeds the bounded read cap for calibration artifacts.
158 #[error(
159 "calibration cache '{}' exceeds {cap} byte cap; delete the cache file and rerun calibration",
160 path.display()
161 )]
162 TooLarge { path: PathBuf, cap: u64 },
163}
164
165/// Process-wide calibration store. Concurrent updates are serialized via
166/// a single `RwLock` because update events are rare (one per `keyhog
167/// calibrate` invocation or per verifier outcome) and the locked region is
168/// constant-time. We deliberately don't shard via DashMap - the persisted
169/// artifact is small enough that contention is a non-issue.
170#[derive(Debug, Default)]
171pub struct Calibration {
172 inner: RwLock<HashMap<String, BetaCounters>>,
173}
174
175impl Calibration {
176 fn empty() -> Self {
177 Self::default()
178 }
179
180 pub fn try_load(path: &Path) -> Result<Option<Self>, CalibrationLoadError> {
181 sweep_stale_calibration_tmp_files(path);
182 let bytes = match state_file::read_capped(
183 path,
184 CALIBRATION_CACHE_FILE_BYTES,
185 "calibration cache",
186 ) {
187 Ok(b) => b,
188 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
189 Err(source) if source.kind() == std::io::ErrorKind::InvalidData => {
190 return Err(CalibrationLoadError::TooLarge {
191 path: path.to_path_buf(),
192 cap: CALIBRATION_CACHE_FILE_BYTES,
193 });
194 }
195 Err(source) => {
196 return Err(CalibrationLoadError::Read {
197 path: path.to_path_buf(),
198 source,
199 });
200 }
201 };
202 let on_disk: OnDisk =
203 serde_json::from_slice(&bytes).map_err(|source| CalibrationLoadError::Parse {
204 path: path.to_path_buf(),
205 source,
206 })?;
207 if on_disk.version != SCHEMA_VERSION {
208 return Err(CalibrationLoadError::SchemaVersion {
209 path: path.to_path_buf(),
210 found: on_disk.version,
211 expected: SCHEMA_VERSION,
212 });
213 }
214 validate_on_disk(path, &on_disk)?;
215 Ok(Some(Self {
216 inner: RwLock::new(on_disk.detectors),
217 }))
218 }
219
220 pub(crate) fn load(path: &Path) -> Self {
221 match Self::try_load(path) {
222 Ok(Some(calibration)) => calibration,
223 Ok(None) => Self::empty(),
224 Err(error) => {
225 tracing::warn!(
226 cache = %path.display(),
227 error = %error,
228 "calibration cache could not be loaded; treating as cold start"
229 );
230 Self::empty()
231 }
232 }
233 }
234
235 pub fn save(&self, path: &Path) -> std::io::Result<()> {
236 let detectors = self.inner.read().clone();
237 let on_disk = OnDisk {
238 version: SCHEMA_VERSION,
239 detectors,
240 };
241 let serialized = serde_json::to_vec_pretty(&on_disk)
242 .map_err(|e| std::io::Error::other(format!("calibration encode: {e}")))?;
243 state_file::write_atomically(path, CALIBRATION_TMP_PREFIX, &serialized)
244 }
245
246 /// Record an operator-confirmed outcome for `detector_id`.
247 pub fn record_outcome(&self, detector_id: &str, true_positive: bool) {
248 if true_positive {
249 self.record_true_positive(detector_id);
250 } else {
251 self.record_false_positive(detector_id);
252 }
253 }
254
255 /// Record a true positive for `detector_id` (α += 1).
256 ///
257 /// kimi-confidence audit: bare `alpha += 1` would panic in debug
258 /// and wrap to 0 in release once a single detector accumulates
259 /// 2^32 observations. Wrapping to 0 silently mutes a previously
260 /// reliable detector (posterior mean drops to 0.0/1.0 = 0). Use
261 /// `saturating_add` so the worst case is a frozen counter at
262 /// `u32::MAX`, which keeps the posterior mean correct.
263 pub(crate) fn record_true_positive(&self, detector_id: &str) {
264 let mut guard = self.inner.write();
265 let entry = guard.entry(detector_id.to_string()).or_default();
266 entry.alpha = entry.alpha.saturating_add(1);
267 }
268
269 /// Record a false positive for `detector_id` (β += 1). Same
270 /// `saturating_add` rationale as [`record_true_positive`].
271 pub(crate) fn record_false_positive(&self, detector_id: &str) {
272 let mut guard = self.inner.write();
273 let entry = guard.entry(detector_id.to_string()).or_default();
274 entry.beta = entry.beta.saturating_add(1);
275 }
276
277 /// Return the posterior mean for `detector_id`, falling back to 0.5
278 /// when no observations exist (uniform prior over a never-calibrated
279 /// detector). The scanner's confidence-scoring path consumes this value,
280 /// but only when calibration is explicitly opted in (see the module-level
281 /// coherence contract) so default / benchmark scans stay deterministic.
282 pub(crate) fn confidence_multiplier(&self, detector_id: &str) -> f64 {
283 self.inner
284 .read()
285 .get(detector_id)
286 .copied()
287 .unwrap_or_default() // LAW10: uncalibrated detector => Beta(1,1) uniform prior (posterior mean 0.5); deterministic default, not a swallowed value
288 .posterior_mean()
289 }
290
291 /// Return the full counters for `detector_id` (defaults to Beta(1, 1)).
292 pub fn counters(&self, detector_id: &str) -> BetaCounters {
293 self.inner
294 .read()
295 .get(detector_id)
296 .copied()
297 .unwrap_or_default() // LAW10: uncalibrated detector => Beta(1,1) uniform prior (posterior mean 0.5); deterministic default, not a swallowed value
298 }
299
300 /// Iterate every recorded `(detector_id, counters)`. Useful for
301 /// `keyhog calibrate --show`.
302 pub fn entries(&self) -> Vec<(String, BetaCounters)> {
303 let mut out: Vec<_> = self
304 .inner
305 .read()
306 .iter()
307 .map(|(k, v)| (k.clone(), *v))
308 .collect();
309 out.sort_by(|a, b| a.0.cmp(&b.0));
310 out
311 }
312
313 /// Test-only hook for saturation oracle tests in `tests/unit/`.
314 #[doc(hidden)]
315 pub(crate) fn test_seed_counters(&self, id: &str, alpha: u32, beta: u32) {
316 let mut guard = self.inner.write();
317 let entry = guard.entry(id.to_string()).or_default();
318 entry.alpha = alpha;
319 entry.beta = beta;
320 }
321}
322
323/// Default calibration cache location: `$XDG_CACHE_HOME/keyhog/calibration.json`
324/// (or the macOS/Windows equivalents via the `dirs` crate).
325pub fn calibration_default_cache_path() -> Option<PathBuf> {
326 crate::keyhog_cache_root().map(|d| d.join("calibration.json"))
327}
328
329fn validate_on_disk(path: &Path, on_disk: &OnDisk) -> Result<(), CalibrationLoadError> {
330 for (detector_id, counters) in &on_disk.detectors {
331 if detector_id.trim().is_empty() {
332 return Err(CalibrationLoadError::EmptyDetectorId {
333 path: path.to_path_buf(),
334 });
335 }
336 if counters.alpha == 0 || counters.beta == 0 {
337 return Err(CalibrationLoadError::InvalidCounters {
338 path: path.to_path_buf(),
339 detector_id: detector_id.clone(),
340 alpha: counters.alpha,
341 beta: counters.beta,
342 });
343 }
344 }
345 Ok(())
346}
347
348fn sweep_stale_calibration_tmp_files(cache_path: &Path) {
349 let swept = state_file::sweep_stale_tmp_siblings(
350 cache_path,
351 &[CALIBRATION_TMP_PREFIX],
352 STALE_TMP_CUTOFF_SECS,
353 );
354 if swept > 0 {
355 if let Some(parent) = cache_path.parent() {
356 tracing::debug!(
357 count = swept,
358 dir = %parent.display(),
359 "swept stale calibration cache tmp files left by an interrupted save"
360 );
361 }
362 }
363}