use crate::calibration::{MIN_OUTCOMES, Z_CRITICAL};
use crate::error::{KernelError, Result};
use crate::hook::{DEFAULT_ENGINE_THRESHOLD, GATE_FAMILY};
use crate::ledger::MemoryStore;
use crate::trace::TraceStatus;
use serde::Serialize;
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ThresholdPoint {
pub threshold: f64,
pub recall: f64,
pub false_block_rate: f64,
pub false_blocks: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct GateThresholdReport {
pub family: String,
pub engines: Vec<String>,
pub n: usize,
pub false_completions: usize,
pub true_completions: usize,
pub max_false_block: f64,
pub threshold: Option<f64>,
pub recall: f64,
pub false_block_rate: f64,
pub false_blocks: usize,
pub false_block_rate_ci95: [f64; 2],
pub at_default: ThresholdPoint,
pub note: String,
}
pub fn rates_at(points: &[(f64, bool)], t: f64) -> ThresholdPoint {
let positives = points.iter().filter(|(_, y)| *y).count();
let negatives = points.len() - positives;
let caught = points.iter().filter(|(p, y)| *y && *p >= t).count();
let false_blocks = points.iter().filter(|(p, y)| !*y && *p >= t).count();
let share = |k: usize, n: usize| if n == 0 { 0.0 } else { k as f64 / n as f64 };
ThresholdPoint {
threshold: t,
recall: share(caught, positives),
false_block_rate: share(false_blocks, negatives),
false_blocks,
}
}
pub fn choose_threshold(points: &[(f64, bool)], max_false_block: f64) -> Option<ThresholdPoint> {
let mut candidates: Vec<f64> = points.iter().map(|(p, _)| *p).collect();
candidates.sort_by(|a, b| a.total_cmp(b));
candidates.dedup();
candidates
.into_iter()
.map(|t| rates_at(points, t))
.filter(|point| point.recall > 0.0 && point.false_block_rate <= max_false_block)
.max_by(|a, b| {
a.recall
.total_cmp(&b.recall)
.then(b.false_blocks.cmp(&a.false_blocks))
.then(a.threshold.total_cmp(&b.threshold))
})
}
pub fn wilson_interval(k: usize, n: usize, z: f64) -> [f64; 2] {
if n == 0 {
return [0.0, 1.0];
}
let n = n as f64;
let p = k as f64 / n;
let z2 = z * z;
let denominator = 1.0 + z2 / n;
let center = (p + z2 / (2.0 * n)) / denominator;
let half = z / denominator * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt();
[(center - half).max(0.0), (center + half).min(1.0)]
}
impl MemoryStore {
pub fn gate_threshold(&self, max_false_block: f64) -> Result<GateThresholdReport> {
if !(0.0..=1.0).contains(&max_false_block) {
return Err(KernelError::Invalid(
"--max-false-block must be between 0 and 1".into(),
));
}
let outcomes = self.latest_outcomes();
let mut points = Vec::new();
let mut engines = BTreeSet::new();
for entry in self.all() {
if entry.status != TraceStatus::Active || !entry.trace.is_model_authored() {
continue;
}
let Some(prediction) = &entry.trace.prediction else {
continue;
};
if prediction.family != GATE_FAMILY || prediction.answer_kind != "noul" {
continue;
}
let (Some(p), Some((_, observed))) =
(prediction.p, outcomes.get(entry.trace.id.as_str()))
else {
continue;
};
engines.insert(prediction.engine.clone());
points.push((p, observed.trim().eq_ignore_ascii_case("true")));
}
let n = points.len();
if n < MIN_OUTCOMES {
return Err(KernelError::Invalid(format!(
"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)"
)));
}
let false_completions = points.iter().filter(|(_, y)| *y).count();
let true_completions = n - false_completions;
if false_completions == 0 || true_completions == 0 {
return Err(KernelError::Invalid(format!(
"the resolved predictions contain {false_completions} false and {true_completions} true completions; both are needed to trade recall against false blocks"
)));
}
let chosen = choose_threshold(&points, max_false_block);
let (recall, false_block_rate, false_blocks) = chosen.as_ref().map_or((0.0, 0.0, 0), |p| {
(p.recall, p.false_block_rate, p.false_blocks)
});
let note = match &chosen {
Some(point) => format!(
"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.",
point.threshold
),
None => "No observed threshold keeps the false-block rate within the budget; keep the engine off (rules only) or raise the budget.".into(),
};
Ok(GateThresholdReport {
family: GATE_FAMILY.into(),
engines: engines.into_iter().collect(),
n,
false_completions,
true_completions,
max_false_block,
threshold: chosen.as_ref().map(|p| p.threshold),
recall,
false_block_rate,
false_blocks,
false_block_rate_ci95: wilson_interval(false_blocks, true_completions, Z_CRITICAL),
at_default: rates_at(&points, DEFAULT_ENGINE_THRESHOLD),
note,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wilson_matches_a_textbook_value() {
let [lo, hi] = wilson_interval(4, 40, 1.96);
assert!(
(lo - 0.0396).abs() < 5e-4 && (hi - 0.2305).abs() < 5e-4,
"{lo} {hi}"
);
assert_eq!(wilson_interval(0, 0, 1.96), [0.0, 1.0]);
}
#[test]
fn equal_recall_prefers_the_higher_threshold() {
let points = [(0.9, true), (0.1, false), (0.5, false)];
let chosen = choose_threshold(&points, 1.0).unwrap();
assert_eq!(chosen.threshold, 0.9);
assert_eq!(chosen.recall, 1.0);
assert_eq!(chosen.false_block_rate, 0.0);
}
}