use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::api::{Answer, MAX_LEVELS};
use crate::{Confidence, Error, Probability};
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct Policy {
pub yes_above: Option<f64>,
pub no_below: Option<f64>,
pub min_confidence: Option<f64>,
}
impl Policy {
pub const fn new() -> Self {
Self {
yes_above: None,
no_below: None,
min_confidence: None,
}
}
pub const fn yes_above(mut self, p: f64) -> Self {
self.yes_above = Some(p);
self
}
pub const fn no_below(mut self, p: f64) -> Self {
self.no_below = Some(p);
self
}
pub const fn min_confidence(mut self, c: f64) -> Self {
self.min_confidence = Some(c);
self
}
pub fn over(self, base: Policy) -> Policy {
Policy {
yes_above: self.yes_above.or(base.yes_above),
no_below: self.no_below.or(base.no_below),
min_confidence: self.min_confidence.or(base.min_confidence),
}
}
pub fn settle(self) -> Result<Thresholds, Error> {
let d = Thresholds::default();
Thresholds::new(
self.yes_above.unwrap_or(d.yes_above),
self.no_below.unwrap_or(d.no_below),
self.min_confidence.unwrap_or(d.min_confidence),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "ThresholdsRepr")]
pub struct Thresholds {
yes_above: f64,
no_below: f64,
min_confidence: f64,
}
#[derive(Deserialize, JsonSchema)]
#[schemars(rename = "Thresholds")]
struct ThresholdsRepr {
#[schemars(range(min = 0.0, max = 1.0))]
yes_above: f64,
#[schemars(range(min = 0.0, max = 1.0))]
no_below: f64,
#[schemars(range(min = 0.0, max = 1.0))]
min_confidence: f64,
}
impl TryFrom<ThresholdsRepr> for Thresholds {
type Error = Error;
fn try_from(r: ThresholdsRepr) -> Result<Self, Error> {
Self::new(r.yes_above, r.no_below, r.min_confidence)
}
}
impl Thresholds {
pub fn new(yes_above: f64, no_below: f64, min_confidence: f64) -> Result<Self, Error> {
for (name, v) in [
("yes_above", yes_above),
("no_below", no_below),
("min_confidence", min_confidence),
] {
if !(0.0..=1.0).contains(&v) {
return Err(Error::Config {
detail: format!("{name} = {v} is outside 0..=1"),
});
}
}
if no_below > yes_above {
return Err(Error::Config {
detail: format!("no_below {no_below} > yes_above {yes_above}"),
});
}
Ok(Self {
yes_above,
no_below,
min_confidence,
})
}
pub const fn yes_above(self) -> f64 {
self.yes_above
}
pub const fn no_below(self) -> f64 {
self.no_below
}
pub const fn min_confidence(self) -> f64 {
self.min_confidence
}
}
impl Default for Thresholds {
fn default() -> Self {
Self {
yes_above: 0.5,
no_below: 0.5,
min_confidence: 0.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "verdict", content = "p", rename_all = "lowercase")]
pub enum Verdict {
Yes(Probability),
No(Probability),
Unsure(Probability),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Outcome {
Noul(Verdict),
Choice {
key: String,
confidence: Confidence,
unsure: bool,
ranked: Vec<(String, Probability)>,
},
Score {
index: usize,
value: f64,
confidence: Confidence,
unsure: bool,
distribution: Vec<Probability>,
legend: Vec<String>,
},
}
pub fn resolve(answer: &Answer, t: Thresholds) -> Result<Outcome, Error> {
match answer {
Answer::Noul { noul } => {
let p = noul.get();
Ok(Outcome::Noul(if p >= t.yes_above {
Verdict::Yes(*noul)
} else if p <= t.no_below {
Verdict::No(*noul)
} else {
Verdict::Unsure(*noul)
}))
}
Answer::Choice {
choice,
probabilities,
confidence,
} => {
if !probabilities.contains_key(choice) {
return Err(Error::Protocol {
detail: format!("choice {choice:?} is not in the distribution"),
});
}
let mut ranked: Vec<(String, Probability)> =
probabilities.iter().map(|(k, p)| (k.clone(), *p)).collect();
ranked.sort_by(|a, b| b.1.get().total_cmp(&a.1.get()).then_with(|| a.0.cmp(&b.0)));
Ok(Outcome::Choice {
key: choice.clone(),
confidence: *confidence,
unsure: confidence.get() < t.min_confidence,
ranked,
})
}
Answer::Score {
score,
legend,
probabilities,
confidence,
} => {
let n = legend.len();
let contiguous = legend.keys().enumerate().all(|(i, k)| usize::from(*k) == i)
&& probabilities
.keys()
.enumerate()
.all(|(i, k)| usize::from(*k) == i)
&& probabilities.len() == n;
if !(2..=MAX_LEVELS).contains(&n) || !contiguous {
return Err(Error::Protocol {
detail: format!(
"score legend/probabilities must be contiguous levels 0..n with 2 <= n <= {MAX_LEVELS}, got {n}"
),
});
}
let Ok(last) = u8::try_from(n - 1) else {
return Err(Error::Protocol {
detail: format!("score has {n} levels"),
});
};
let max = f64::from(last);
if !(0.0..=max).contains(score) {
return Err(Error::Protocol {
detail: format!("score {score} is outside 0..={max}"),
});
}
let index = probabilities.iter().fold(0u8, |best, (k, p)| {
let best_p = probabilities.get(&best).map_or(0.0, |b| b.get());
if p.get() > best_p { *k } else { best }
});
Ok(Outcome::Score {
index: usize::from(index),
value: *score,
confidence: *confidence,
unsure: confidence.get() < t.min_confidence,
distribution: probabilities.values().copied().collect(),
legend: legend.values().cloned().collect(),
})
}
}
}