keyhog-core 0.5.43

keyhog-core: shared data model and detector specifications for the KeyHog secret scanner
Documentation
//! Bayesian Beta(α, β) *confidence* calibration, per detector.
//!
//! Disambiguation: this is the `keyhog calibrate --tp/--fp` subsystem that
//! shapes a finding's confidence score from operator-confirmed true/false
//! positives. It is unrelated to *autoroute* "calibration" (backend selection
//! timing/parity) in `keyhog_cli`'s `orchestrator::dispatch::backend`; the two
//! share only the English word "calibration".
//!
//! Tier-B moat innovation #4 from the internal design notes: surface
//! per-detector reliability based on observed true-positive vs false-
//! positive history rather than a fixed threshold. Detectors with a long
//! history of clean hits get a higher confidence multiplier; detectors
//! that fire-then-suppress repeatedly get downweighted.
//!
//! Mathematical model:
//!     each detector has a Beta(α, β) prior over P(true positive | match).
//!     α counts confirmed TPs, β counts confirmed FPs (both incremented from
//!     a starting prior of α=1, β=1 - uniform Beta(1, 1)).
//!     posterior mean = α / (α + β)  ∈ [0, 1].
//!
//! Storage: JSON at `$XDG_CACHE_HOME/keyhog/calibration.json` with a schema
//! version field. [`Calibration::try_load`] distinguishes a missing cache from
//! a damaged artifact so operator commands can fail closed instead of
//! overwriting corrupt state as if it were a clean first run. The legacy
//! [`Calibration::load`] API remains tolerant for compatibility; production
//! operator paths must use the strict loader when the cache is explicit.
//!
//! Coherence contract (audit organization/coherence finding): this module is
//! the DATA layer, but it is now LIVE - the scanner's confidence-scoring path
//! (`scanner::confidence::apply_calibration_multiplier`) reads these counters.
//! Because a calibration artifact silently present on one machine but absent on
//! another would make `tuned != benched != shipped`, the integration MUST be
//! opt-in and deterministic: the scoring path only consults a calibration store
//! when one is explicitly supplied, and the default / benchmark / CI scan runs
//! with an [`empty`](Calibration::empty) store so two machines produce identical
//! findings for the same input. A stray `$XDG_CACHE_HOME` artifact on a dev box
//! must never silently alter results - that gating lives in the scanner crate.

#![allow(missing_docs)]

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use thiserror::Error;

const CALIBRATION_TMP_PREFIX: &str = ".tmp.keyhog-calibration-";
use crate::state_file::{self, CALIBRATION_CACHE_FILE_BYTES};
use crate::STALE_TMP_CUTOFF_SECS;

/// A detector's running Beta posterior counters. Always ≥1 each (Beta(1,1)
/// uniform prior baseline) to avoid posterior_mean undefined when a detector
/// has had no observations yet.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BetaCounters {
    pub alpha: u32,
    pub beta: u32,
}

impl Default for BetaCounters {
    fn default() -> Self {
        Self { alpha: 1, beta: 1 }
    }
}

impl BetaCounters {
    /// Posterior mean: α / (α + β). Falls in [0, 1]; the higher, the more
    /// reliable the detector is historically.
    ///
    /// This is the single canonical implementation. The scanner
    /// confidence pipeline and the `keyhog calibrate --show` UI both call
    /// this method rather than re-deriving the arithmetic, so the three
    /// crates can never drift apart.
    pub fn posterior_mean(&self) -> f64 {
        let total = self.alpha as f64 + self.beta as f64;
        if total == 0.0 {
            0.5
        } else {
            self.alpha as f64 / total
        }
    }

    /// Number of observations (excluding the prior) the posterior is built
    /// on. Useful for "trust the recent history" UI gates.
    ///
    /// kimi-confidence audit: the previous form was
    /// `alpha.saturating_sub(1) + beta.saturating_sub(1)` - the `+`
    /// was a plain add and would panic in debug / wrap to 0 in release
    /// once both counters reached ~`u32::MAX / 2`. Use `saturating_add`
    /// so the result clamps at `u32::MAX` instead of wrapping. That's
    /// still a frozen counter at saturation, but the posterior mean
    /// stays correct and no detector silently gets disabled.
    ///
    /// Public for the same single-source-of-truth reason as
    /// [`BetaCounters::posterior_mean`]: the scanner observation gate and
    /// the CLI `--show` table share this one implementation.
    pub fn observations(&self) -> u32 {
        // Subtract the Beta(1, 1) prior baseline.
        self.alpha
            .saturating_sub(1)
            .saturating_add(self.beta.saturating_sub(1))
    }
}

/// On-disk format. The version field gates breaking schema changes.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct OnDisk {
    version: u32,
    detectors: HashMap<String, BetaCounters>,
}

const SCHEMA_VERSION: u32 = 1;

/// Error returned when an existing calibration cache cannot be trusted.
#[derive(Debug, Error)]
pub enum CalibrationLoadError {
    /// The cache file exists but could not be read.
    #[error("calibration cache '{}' could not be read: {source}", path.display())]
    Read {
        path: PathBuf,
        source: std::io::Error,
    },
    /// The cache file exists but is not valid JSON for the calibration schema.
    #[error("calibration cache '{}' is not valid JSON: {source}", path.display())]
    Parse {
        path: PathBuf,
        source: serde_json::Error,
    },
    /// The cache JSON has a version this binary does not understand.
    #[error(
        "calibration cache '{}' has schema version {found}; expected {expected}",
        path.display()
    )]
    SchemaVersion {
        path: PathBuf,
        found: u32,
        expected: u32,
    },
    /// The cache JSON matches the schema shape but violates semantic invariants.
    #[error(
        "calibration cache '{}' has invalid counters for detector '{}': alpha={alpha}, beta={beta}; counters must be >= 1",
        path.display(),
        detector_id
    )]
    InvalidCounters {
        path: PathBuf,
        detector_id: String,
        alpha: u32,
        beta: u32,
    },
    /// Detector identifiers are part of the persisted routing identity.
    #[error("calibration cache '{}' contains an empty detector id", path.display())]
    EmptyDetectorId { path: PathBuf },
    /// The cache file exceeds the bounded read cap for calibration artifacts.
    #[error(
        "calibration cache '{}' exceeds {cap} byte cap; delete the cache file and rerun calibration",
        path.display()
    )]
    TooLarge { path: PathBuf, cap: u64 },
}

/// Process-wide calibration store. Concurrent updates are serialized via
/// a single `RwLock` because update events are rare (one per `keyhog
/// calibrate` invocation or per verifier outcome) and the locked region is
/// constant-time. We deliberately don't shard via DashMap - the persisted
/// artifact is small enough that contention is a non-issue.
#[derive(Debug, Default)]
pub struct Calibration {
    inner: RwLock<HashMap<String, BetaCounters>>,
}

impl Calibration {
    fn empty() -> Self {
        Self::default()
    }

    pub fn try_load(path: &Path) -> Result<Option<Self>, CalibrationLoadError> {
        sweep_stale_calibration_tmp_files(path);
        let bytes = match state_file::read_capped(
            path,
            CALIBRATION_CACHE_FILE_BYTES,
            "calibration cache",
        ) {
            Ok(b) => b,
            Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(source) if source.kind() == std::io::ErrorKind::InvalidData => {
                return Err(CalibrationLoadError::TooLarge {
                    path: path.to_path_buf(),
                    cap: CALIBRATION_CACHE_FILE_BYTES,
                });
            }
            Err(source) => {
                return Err(CalibrationLoadError::Read {
                    path: path.to_path_buf(),
                    source,
                });
            }
        };
        let on_disk: OnDisk =
            serde_json::from_slice(&bytes).map_err(|source| CalibrationLoadError::Parse {
                path: path.to_path_buf(),
                source,
            })?;
        if on_disk.version != SCHEMA_VERSION {
            return Err(CalibrationLoadError::SchemaVersion {
                path: path.to_path_buf(),
                found: on_disk.version,
                expected: SCHEMA_VERSION,
            });
        }
        validate_on_disk(path, &on_disk)?;
        Ok(Some(Self {
            inner: RwLock::new(on_disk.detectors),
        }))
    }

    pub(crate) fn load(path: &Path) -> Self {
        match Self::try_load(path) {
            Ok(Some(calibration)) => calibration,
            Ok(None) => Self::empty(),
            Err(error) => {
                tracing::warn!(
                    cache = %path.display(),
                    error = %error,
                    "calibration cache could not be loaded; treating as cold start"
                );
                Self::empty()
            }
        }
    }

    pub fn save(&self, path: &Path) -> std::io::Result<()> {
        let detectors = self.inner.read().clone();
        let on_disk = OnDisk {
            version: SCHEMA_VERSION,
            detectors,
        };
        let serialized = serde_json::to_vec_pretty(&on_disk)
            .map_err(|e| std::io::Error::other(format!("calibration encode: {e}")))?;
        state_file::write_atomically(path, CALIBRATION_TMP_PREFIX, &serialized)
    }

    /// Record an operator-confirmed outcome for `detector_id`.
    pub fn record_outcome(&self, detector_id: &str, true_positive: bool) {
        if true_positive {
            self.record_true_positive(detector_id);
        } else {
            self.record_false_positive(detector_id);
        }
    }

    /// Record a true positive for `detector_id` (α += 1).
    ///
    /// kimi-confidence audit: bare `alpha += 1` would panic in debug
    /// and wrap to 0 in release once a single detector accumulates
    /// 2^32 observations. Wrapping to 0 silently mutes a previously
    /// reliable detector (posterior mean drops to 0.0/1.0 = 0). Use
    /// `saturating_add` so the worst case is a frozen counter at
    /// `u32::MAX`, which keeps the posterior mean correct.
    pub(crate) fn record_true_positive(&self, detector_id: &str) {
        let mut guard = self.inner.write();
        let entry = guard.entry(detector_id.to_string()).or_default();
        entry.alpha = entry.alpha.saturating_add(1);
    }

    /// Record a false positive for `detector_id` (β += 1). Same
    /// `saturating_add` rationale as [`record_true_positive`].
    pub(crate) fn record_false_positive(&self, detector_id: &str) {
        let mut guard = self.inner.write();
        let entry = guard.entry(detector_id.to_string()).or_default();
        entry.beta = entry.beta.saturating_add(1);
    }

    /// Return the posterior mean for `detector_id`, falling back to 0.5
    /// when no observations exist (uniform prior over a never-calibrated
    /// detector). The scanner's confidence-scoring path consumes this value,
    /// but only when calibration is explicitly opted in (see the module-level
    /// coherence contract) so default / benchmark scans stay deterministic.
    pub(crate) fn confidence_multiplier(&self, detector_id: &str) -> f64 {
        self.inner
            .read()
            .get(detector_id)
            .copied()
            .unwrap_or_default() // LAW10: uncalibrated detector => Beta(1,1) uniform prior (posterior mean 0.5); deterministic default, not a swallowed value
            .posterior_mean()
    }

    /// Return the full counters for `detector_id` (defaults to Beta(1, 1)).
    pub fn counters(&self, detector_id: &str) -> BetaCounters {
        self.inner
            .read()
            .get(detector_id)
            .copied()
            .unwrap_or_default() // LAW10: uncalibrated detector => Beta(1,1) uniform prior (posterior mean 0.5); deterministic default, not a swallowed value
    }

    /// Iterate every recorded `(detector_id, counters)`. Useful for
    /// `keyhog calibrate --show`.
    pub fn entries(&self) -> Vec<(String, BetaCounters)> {
        let mut out: Vec<_> = self
            .inner
            .read()
            .iter()
            .map(|(k, v)| (k.clone(), *v))
            .collect();
        out.sort_by(|a, b| a.0.cmp(&b.0));
        out
    }

    /// Test-only hook for saturation oracle tests in `tests/unit/`.
    #[doc(hidden)]
    pub(crate) fn test_seed_counters(&self, id: &str, alpha: u32, beta: u32) {
        let mut guard = self.inner.write();
        let entry = guard.entry(id.to_string()).or_default();
        entry.alpha = alpha;
        entry.beta = beta;
    }
}

/// Default calibration cache location: `$XDG_CACHE_HOME/keyhog/calibration.json`
/// (or the macOS/Windows equivalents via the `dirs` crate).
pub fn calibration_default_cache_path() -> Option<PathBuf> {
    crate::keyhog_cache_root().map(|d| d.join("calibration.json"))
}

fn validate_on_disk(path: &Path, on_disk: &OnDisk) -> Result<(), CalibrationLoadError> {
    for (detector_id, counters) in &on_disk.detectors {
        if detector_id.trim().is_empty() {
            return Err(CalibrationLoadError::EmptyDetectorId {
                path: path.to_path_buf(),
            });
        }
        if counters.alpha == 0 || counters.beta == 0 {
            return Err(CalibrationLoadError::InvalidCounters {
                path: path.to_path_buf(),
                detector_id: detector_id.clone(),
                alpha: counters.alpha,
                beta: counters.beta,
            });
        }
    }
    Ok(())
}

fn sweep_stale_calibration_tmp_files(cache_path: &Path) {
    let swept = state_file::sweep_stale_tmp_siblings(
        cache_path,
        &[CALIBRATION_TMP_PREFIX],
        STALE_TMP_CUTOFF_SECS,
    );
    if swept > 0 {
        if let Some(parent) = cache_path.parent() {
            tracing::debug!(
                count = swept,
                dir = %parent.display(),
                "swept stale calibration cache tmp files left by an interrupted save"
            );
        }
    }
}