//! The composition rules, as code. Each is one of the vendor's documented //! patterns, kept here because they are the right way to use ANY calibrated //! classifier and should not live in one call site's `if` chain. use crate::Answer; /// Confidence-gated routing: three outcomes, not two. The answer says what; /// the confidence (or a Noul's distance from even) says whether to act. /// /// `act_above` is the probability/confidence at which code may act on its /// own; `dismiss_below` the one under which the answer is a clear no. In /// between is the band a person, or a slower model, gets to decide. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Gate { Act, Review, Dismiss, } pub fn gate_noul(p: f64, dismiss_below: f64, act_above: f64) -> Gate { debug_assert!(dismiss_below <= act_above); if p >= act_above { Gate::Act } else if p < dismiss_below { Gate::Dismiss } else { Gate::Review } } /// Gate a Choice or Score on its confidence alone. pub fn gate_confidence(answer: &Answer, review_below: f64) -> Gate { if answer.confidence() >= review_below { Gate::Act } else { Gate::Review } } /// Composite scoring: normalise each Score to 0..1 by its top level and /// combine with weights the caller owns. `parts` is `(score, levels, weight)`. /// Weights need not sum to one; the result is divided by their sum. pub fn composite(parts: &[(f64, usize, f64)]) -> f64 { let total: f64 = parts.iter().map(|(_, _, w)| w).sum(); if total <= 0.0 { return 0.0; } parts .iter() .map(|(score, levels, w)| { let top = (*levels as f64 - 1.0).max(1.0); (score / top).clamp(0.0, 1.0) * w }) .sum::() / total } /// Rerank: order candidates by a per-candidate Noul, highest first. pub fn rerank(mut items: Vec<(T, f64)>) -> Vec<(T, f64)> { items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); items } #[cfg(test)] mod tests { use super::*; #[test] fn a_noul_gates_three_ways() { assert_eq!(gate_noul(0.95, 0.2, 0.8), Gate::Act); assert_eq!(gate_noul(0.05, 0.2, 0.8), Gate::Dismiss); assert_eq!(gate_noul(0.5, 0.2, 0.8), Gate::Review); } #[test] fn composite_normalises_by_top_level_and_weights() { // severity 2 of 0..2 at weight 0.6, frustration 1 of 0..2 at 0.3, // report quality 3 of 0..3 at 0.1 → 0.6*1 + 0.3*0.5 + 0.1*1 = 0.85 let c = composite(&[(2.0, 3, 0.6), (1.0, 3, 0.3), (3.0, 4, 0.1)]); assert!((c - 0.85).abs() < 1e-9); } #[test] fn rerank_is_descending() { let r = rerank(vec![("a", 0.2), ("b", 0.9), ("c", 0.5)]); assert_eq!(r.iter().map(|x| x.0).collect::>(), ["b", "c", "a"]); } }