Skip to main content

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}