1use 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#[derive(Debug, Clone, PartialEq, Serialize)]
23pub struct ThresholdPoint {
24 pub threshold: f64,
25 pub recall: f64,
27 pub false_block_rate: f64,
29 pub false_blocks: usize,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct GateThresholdReport {
35 pub family: String,
36 pub engines: Vec<String>,
37 pub n: usize,
39 pub false_completions: usize,
41 pub true_completions: usize,
43 pub max_false_block: f64,
44 pub threshold: Option<f64>,
46 pub recall: f64,
47 pub false_block_rate: f64,
48 pub false_blocks: usize,
49 pub false_block_rate_ci95: [f64; 2],
51 pub at_default: ThresholdPoint,
53 pub note: String,
54}
55
56pub 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
71pub 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
91pub 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 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 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 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}