Skip to main content

hikmah_kernel/
threshold.rs

1//! Data-driven Truth Gate threshold.
2//!
3//! The hook can record every engine probability as a `prediction` trace (`HIKMAH_HOOK_RECORD`);
4//! a person or CI later records whether that completion claim was false (`hikmah outcome
5//! --observed true` means it *was* a false completion). [`MemoryStore::gate_threshold`] pairs
6//! the two and picks the threshold `t` (the engine blocks when `p >= t`) with the highest recall
7//! of false completions whose empirical false-block rate (blocked true completions / all true
8//! completions) stays within a budget. Ties in recall go to the higher threshold. Only observed
9//! probabilities are candidates, because the rates change only there.
10//!
11//! This covers the engine path alone. The deterministic rules still block on their own, so the
12//! gate's overall false-block rate can be higher by the rules' own false blocks.
13use crate::calibration::{MIN_OUTCOMES, Z_CRITICAL};
14use crate::error::{KernelError, Result};
15use crate::hook::{DEFAULT_ENGINE_THRESHOLD, GATE_FAMILY};
16use crate::ledger::MemoryStore;
17use crate::trace::TraceStatus;
18use serde::Serialize;
19use std::collections::BTreeSet;
20
21/// The engine-path rates at one threshold.
22#[derive(Debug, Clone, PartialEq, Serialize)]
23pub struct ThresholdPoint {
24    pub threshold: f64,
25    /// Blocked false completions / all false completions.
26    pub recall: f64,
27    /// Blocked true completions / all true completions.
28    pub false_block_rate: f64,
29    /// True completions that would be blocked.
30    pub false_blocks: usize,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct GateThresholdReport {
35    pub family: String,
36    pub engines: Vec<String>,
37    /// Resolved predictions with a probability.
38    pub n: usize,
39    /// Outcomes `true`: the completion claim was false.
40    pub false_completions: usize,
41    /// Outcomes `false`: the completion claim held.
42    pub true_completions: usize,
43    pub max_false_block: f64,
44    /// The chosen threshold, or `None` when no observed threshold meets the budget.
45    pub threshold: Option<f64>,
46    pub recall: f64,
47    pub false_block_rate: f64,
48    pub false_blocks: usize,
49    /// Wilson 95% interval for the false-block rate at the chosen threshold.
50    pub false_block_rate_ci95: [f64; 2],
51    /// The same rates at the shipped default threshold, for comparison.
52    pub at_default: ThresholdPoint,
53    pub note: String,
54}
55
56/// Rates at threshold `t` for `(p, was_false_completion)` points.
57pub fn rates_at(points: &[(f64, bool)], t: f64) -> ThresholdPoint {
58    let positives = points.iter().filter(|(_, y)| *y).count();
59    let negatives = points.len() - positives;
60    let caught = points.iter().filter(|(p, y)| *y && *p >= t).count();
61    let false_blocks = points.iter().filter(|(p, y)| !*y && *p >= t).count();
62    let share = |k: usize, n: usize| if n == 0 { 0.0 } else { k as f64 / n as f64 };
63    ThresholdPoint {
64        threshold: t,
65        recall: share(caught, positives),
66        false_block_rate: share(false_blocks, negatives),
67        false_blocks,
68    }
69}
70
71/// Highest-recall threshold among the observed probabilities whose false-block rate is at most
72/// `max_false_block`; ties in recall go to fewer false blocks, then to the higher threshold.
73/// A threshold that catches no false completion only adds false blocks (turning the engine off
74/// does better), so it never qualifies. `None` when none qualifies.
75pub fn choose_threshold(points: &[(f64, bool)], max_false_block: f64) -> Option<ThresholdPoint> {
76    let mut candidates: Vec<f64> = points.iter().map(|(p, _)| *p).collect();
77    candidates.sort_by(|a, b| a.total_cmp(b));
78    candidates.dedup();
79    candidates
80        .into_iter()
81        .map(|t| rates_at(points, t))
82        .filter(|point| point.recall > 0.0 && point.false_block_rate <= max_false_block)
83        .max_by(|a, b| {
84            a.recall
85                .total_cmp(&b.recall)
86                .then(b.false_blocks.cmp(&a.false_blocks))
87                .then(a.threshold.total_cmp(&b.threshold))
88        })
89}
90
91/// Wilson score interval for `k` successes in `n` trials at normal quantile `z`.
92pub fn wilson_interval(k: usize, n: usize, z: f64) -> [f64; 2] {
93    if n == 0 {
94        return [0.0, 1.0];
95    }
96    let n = n as f64;
97    let p = k as f64 / n;
98    let z2 = z * z;
99    let denominator = 1.0 + z2 / n;
100    let center = (p + z2 / (2.0 * n)) / denominator;
101    let half = z / denominator * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt();
102    [(center - half).max(0.0), (center + half).min(1.0)]
103}
104
105impl MemoryStore {
106    /// Pair recorded Truth Gate engine predictions (`model:` sources only) with their outcomes and
107    /// choose the engine threshold. Refuses with fewer than [`MIN_OUTCOMES`] resolved
108    /// predictions, or without both classes.
109    pub fn gate_threshold(&self, max_false_block: f64) -> Result<GateThresholdReport> {
110        if !(0.0..=1.0).contains(&max_false_block) {
111            return Err(KernelError::Invalid(
112                "--max-false-block must be between 0 and 1".into(),
113            ));
114        }
115        let outcomes = self.latest_outcomes();
116        let mut points = Vec::new();
117        let mut engines = BTreeSet::new();
118        for entry in self.all() {
119            // A purged or superseded prediction must not steer the threshold, and neither may a
120            // person's or agent's forecast: this threshold applies to the engine's probability.
121            if entry.status != TraceStatus::Active || !entry.trace.is_model_authored() {
122                continue;
123            }
124            let Some(prediction) = &entry.trace.prediction else {
125                continue;
126            };
127            if prediction.family != GATE_FAMILY || prediction.answer_kind != "noul" {
128                continue;
129            }
130            let (Some(p), Some((_, observed))) =
131                (prediction.p, outcomes.get(entry.trace.id.as_str()))
132            else {
133                continue;
134            };
135            engines.insert(prediction.engine.clone());
136            points.push((p, observed.trim().eq_ignore_ascii_case("true")));
137        }
138        let n = points.len();
139        if n < MIN_OUTCOMES {
140            return Err(KernelError::Invalid(format!(
141                "only {n} resolved {GATE_FAMILY} predictions with a probability; at least {MIN_OUTCOMES} are needed before choosing a threshold. Record predictions with HIKMAH_HOOK_RECORD and outcomes with `hikmah outcome --observed true|false` (true = the completion claim was false)"
142            )));
143        }
144        let false_completions = points.iter().filter(|(_, y)| *y).count();
145        let true_completions = n - false_completions;
146        if false_completions == 0 || true_completions == 0 {
147            return Err(KernelError::Invalid(format!(
148                "the resolved predictions contain {false_completions} false and {true_completions} true completions; both are needed to trade recall against false blocks"
149            )));
150        }
151        let chosen = choose_threshold(&points, max_false_block);
152        // With no qualifying threshold the engine blocks nothing: zero recall, zero false blocks.
153        let (recall, false_block_rate, false_blocks) = chosen.as_ref().map_or((0.0, 0.0, 0), |p| {
154            (p.recall, p.false_block_rate, p.false_blocks)
155        });
156        let note = match &chosen {
157            Some(point) => format!(
158                "Engine path only: the rules still block on their own. Set HIKMAH_HOOK_THRESHOLD={} to use it. The false-block interval is Wilson 95% over {true_completions} true completions; re-run as outcomes accumulate.",
159                point.threshold
160            ),
161            None => "No observed threshold keeps the false-block rate within the budget; keep the engine off (rules only) or raise the budget.".into(),
162        };
163        Ok(GateThresholdReport {
164            family: GATE_FAMILY.into(),
165            engines: engines.into_iter().collect(),
166            n,
167            false_completions,
168            true_completions,
169            max_false_block,
170            threshold: chosen.as_ref().map(|p| p.threshold),
171            recall,
172            false_block_rate,
173            false_blocks,
174            false_block_rate_ci95: wilson_interval(false_blocks, true_completions, Z_CRITICAL),
175            at_default: rates_at(&points, DEFAULT_ENGINE_THRESHOLD),
176            note,
177        })
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn wilson_matches_a_textbook_value() {
187        let [lo, hi] = wilson_interval(4, 40, 1.96);
188        assert!(
189            (lo - 0.0396).abs() < 5e-4 && (hi - 0.2305).abs() < 5e-4,
190            "{lo} {hi}"
191        );
192        assert_eq!(wilson_interval(0, 0, 1.96), [0.0, 1.0]);
193    }
194
195    #[test]
196    fn equal_recall_prefers_the_higher_threshold() {
197        let points = [(0.9, true), (0.1, false), (0.5, false)];
198        let chosen = choose_threshold(&points, 1.0).unwrap();
199        assert_eq!(chosen.threshold, 0.9);
200        assert_eq!(chosen.recall, 1.0);
201        assert_eq!(chosen.false_block_rate, 0.0);
202    }
203}