use std::borrow::Cow;
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use super::named;
use super::{DecisionRequest, Question, QuestionKind};
use crate::{Error, ProviderData, Result, Usage, Warning};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DecisionResponse {
#[serde(with = "named")]
pub answers: Vec<(String, Answer)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<Cow<'static, str>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<Warning>,
#[serde(default, skip_serializing_if = "ProviderData::is_empty")]
pub provider_data: ProviderData,
}
impl DecisionResponse {
#[must_use]
pub fn new<I, N>(answers: I) -> Self
where
I: IntoIterator<Item = (N, Answer)>,
N: Into<String>,
{
Self {
answers: answers
.into_iter()
.map(|(name, answer)| (name.into(), answer))
.collect(),
..Self::default()
}
}
#[must_use]
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
#[must_use]
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
#[must_use]
pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
#[must_use]
pub fn provider(mut self, provider: impl Into<Cow<'static, str>>) -> Self {
self.provider = Some(provider.into());
self
}
#[must_use]
pub fn usage(mut self, usage: Usage) -> Self {
self.usage = Some(usage);
self
}
#[must_use]
pub fn warning(mut self, warning: Warning) -> Self {
self.warnings.push(warning);
self
}
#[must_use]
pub fn provider_data(mut self, data: ProviderData) -> Self {
self.provider_data = data;
self
}
#[must_use]
pub fn answer(&self, name: &str) -> Option<&Answer> {
named::get(&self.answers, name)
}
#[must_use]
pub fn yes_no(&self, name: &str) -> Option<&YesNoAnswer> {
self.answer(name)?.as_yes_no()
}
#[must_use]
pub fn choice(&self, name: &str) -> Option<&ChoiceAnswer> {
self.answer(name)?.as_choice()
}
#[must_use]
pub fn score(&self, name: &str) -> Option<&ScoreAnswer> {
self.answer(name)?.as_score()
}
pub fn validate(&self, request: &DecisionRequest) -> Result<()> {
let mut seen = HashSet::new();
for (name, answer) in &self.answers {
if !seen.insert(name.as_str()) {
return Err(unexpected(name, "answered more than once"));
}
let Some(question) = request.find_question(name) else {
return Err(unexpected(name, "was not asked"));
};
if answer.kind() != question.kind() {
return Err(unexpected(
name,
format!(
"is a {} question, answered as {}",
question.kind(),
answer.kind()
),
));
}
match (question, answer) {
(Question::YesNo(_), Answer::YesNo(answer)) => {
check_probability(name, answer.probability)?;
}
(Question::Choice(question), Answer::Choice(answer)) => {
if question.find_option(&answer.chosen).is_none() {
return Err(unexpected(
name,
format!("chose unknown option {:?}", answer.chosen),
));
}
let mut keys = HashSet::new();
for (key, probability) in &answer.probabilities {
if question.find_option(key).is_none() {
return Err(unexpected(
name,
format!("reports a probability for unknown option {key:?}"),
));
}
if !keys.insert(key.as_str()) {
return Err(unexpected(
name,
format!("reports option {key:?} more than once"),
));
}
check_probability(name, *probability)?;
}
if let Some(option) = question
.options
.iter()
.find(|option| !keys.contains(option.key.as_str()))
{
return Err(unexpected(
name,
format!("reports no probability for option {:?}", option.key),
));
}
}
(Question::Score(question), Answer::Score(answer)) => {
if question.levels.is_empty() {
return Err(unexpected(name, "asks a score question without levels"));
}
if answer.probabilities.len() != question.levels.len() {
return Err(unexpected(
name,
format!(
"reports {} level probabilities for {} levels",
answer.probabilities.len(),
question.levels.len()
),
));
}
for probability in &answer.probabilities {
check_probability(name, *probability)?;
}
#[allow(clippy::cast_precision_loss)]
let top = question.levels.len().saturating_sub(1) as f64;
if !(answer.score.is_finite()
&& (-TOLERANCE..=top + TOLERANCE).contains(&answer.score))
{
return Err(unexpected(
name,
format!("score {} is outside 0..={top}", answer.score),
));
}
}
_ => unreachable!("kinds were compared above"),
}
}
for (name, _) in &request.questions {
if !seen.contains(name.as_str()) {
return Err(unexpected(name, "was not answered"));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Answer {
YesNo(YesNoAnswer),
Choice(ChoiceAnswer),
Score(ScoreAnswer),
}
impl Answer {
#[must_use]
pub fn kind(&self) -> QuestionKind {
match self {
Self::YesNo(_) => QuestionKind::YesNo,
Self::Choice(_) => QuestionKind::Choice,
Self::Score(_) => QuestionKind::Score,
}
}
#[must_use]
pub fn as_yes_no(&self) -> Option<&YesNoAnswer> {
match self {
Self::YesNo(answer) => Some(answer),
_ => None,
}
}
#[must_use]
pub fn as_choice(&self) -> Option<&ChoiceAnswer> {
match self {
Self::Choice(answer) => Some(answer),
_ => None,
}
}
#[must_use]
pub fn as_score(&self) -> Option<&ScoreAnswer> {
match self {
Self::Score(answer) => Some(answer),
_ => None,
}
}
}
impl From<YesNoAnswer> for Answer {
fn from(answer: YesNoAnswer) -> Self {
Self::YesNo(answer)
}
}
impl From<ChoiceAnswer> for Answer {
fn from(answer: ChoiceAnswer) -> Self {
Self::Choice(answer)
}
}
impl From<ScoreAnswer> for Answer {
fn from(answer: ScoreAnswer) -> Self {
Self::Score(answer)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct YesNoAnswer {
pub probability: f64,
}
impl YesNoAnswer {
#[must_use]
pub fn new(probability: f64) -> Self {
Self { probability }
}
#[must_use]
pub fn is_yes(&self) -> bool {
self.probability > 0.5
}
#[must_use]
pub fn decide(&self, threshold: f64) -> Option<bool> {
assert!(
threshold > 0.5 && threshold <= 1.0,
"a yes/no threshold must be above 0.5 and at most 1, got {threshold}"
);
if self.probability >= threshold {
Some(true)
} else if 1.0 - self.probability >= threshold {
Some(false)
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ChoiceAnswer {
pub chosen: String,
#[serde(with = "named")]
pub probabilities: Vec<(String, f64)>,
}
impl ChoiceAnswer {
#[must_use]
pub fn new<I, K>(chosen: impl Into<String>, probabilities: I) -> Self
where
I: IntoIterator<Item = (K, f64)>,
K: Into<String>,
{
Self {
chosen: chosen.into(),
probabilities: probabilities
.into_iter()
.map(|(key, probability)| (key.into(), probability))
.collect(),
}
}
#[must_use]
pub fn probability(&self, key: &str) -> Option<f64> {
named::get(&self.probabilities, key).copied()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScoreAnswer {
pub score: f64,
pub probabilities: Vec<f64>,
}
impl ScoreAnswer {
#[must_use]
pub fn new(score: f64, probabilities: impl Into<Vec<f64>>) -> Self {
Self {
score,
probabilities: probabilities.into(),
}
}
#[must_use]
pub fn probability(&self, level: usize) -> Option<f64> {
self.probabilities.get(level).copied()
}
#[must_use]
pub fn most_likely(&self) -> Option<usize> {
most_likely_level(&self.probabilities)
}
#[must_use]
pub fn nearest(&self) -> Option<usize> {
let top = self.probabilities.len().checked_sub(1)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let level = self.score.round().max(0.0) as usize;
Some(level.min(top))
}
}
const TOLERANCE: f64 = 1e-6;
pub(super) fn most_likely_level(probabilities: &[f64]) -> Option<usize> {
if probabilities.iter().any(|p| p.is_nan()) {
return None;
}
let mut best: Option<(usize, f64)> = None;
for (level, &p) in probabilities.iter().enumerate() {
match best {
Some((_, top)) if top >= p => {}
_ => best = Some((level, p)),
}
}
best.filter(|&(_, p)| p > 0.0).map(|(level, _)| level)
}
fn check_probability(name: &str, probability: f64) -> Result<()> {
if probability.is_finite() && (-TOLERANCE..=1.0 + TOLERANCE).contains(&probability) {
Ok(())
} else {
Err(unexpected(
name,
format!("reports probability {probability} outside 0..=1"),
))
}
}
fn unexpected(name: &str, problem: impl std::fmt::Display) -> Error {
Error::unexpected_response(format!("decision question {name:?} {problem}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::decision::{Question, ScoreQuestion};
use serde_json::json;
fn request() -> DecisionRequest {
DecisionRequest::new("m", "ticket")
.question(
"label",
Question::choice("?").bare_option("a").bare_option("b"),
)
.question("spam", Question::yes_no("?"))
.question(
"urgency",
Question::score("?").levels(["low", "mid", "high"]),
)
}
fn response() -> DecisionResponse {
DecisionResponse::new([
(
"label",
Answer::from(ChoiceAnswer::new("a", [("b", 0.1), ("a", 0.9)])),
),
("spam", YesNoAnswer::new(0.2).into()),
("urgency", ScoreAnswer::new(1.2, [0.1, 0.6, 0.3]).into()),
])
}
fn problem(response: DecisionResponse) -> String {
match response.validate(&request()).unwrap_err() {
Error::UnexpectedResponse { message, .. } => message,
other => panic!("expected UnexpectedResponse, got {other:?}"),
}
}
#[test]
fn matching_response_is_valid() {
response().validate(&request()).unwrap();
}
#[test]
fn mismatches_are_reported() {
let mut missing = response();
missing.answers.pop();
assert!(problem(missing).contains("\"urgency\" was not answered"));
let mut extra = response();
extra
.answers
.push(("other".into(), YesNoAnswer::new(0.5).into()));
assert!(problem(extra).contains("was not asked"));
let mut wrong_kind = response();
wrong_kind.answers[1].1 = ScoreAnswer::new(0.0, [1.0, 0.0]).into();
assert!(problem(wrong_kind).contains("is a yes/no question, answered as score"));
let mut unknown = response();
unknown.answers[0].1 = ChoiceAnswer::new("c", [("a", 0.5), ("b", 0.5)]).into();
assert!(problem(unknown).contains("unknown option \"c\""));
let mut incomplete = response();
incomplete.answers[0].1 = ChoiceAnswer::new("a", [("a", 1.0)]).into();
assert!(problem(incomplete).contains("no probability for option \"b\""));
let mut repeated = response();
repeated.answers[0].1 =
ChoiceAnswer::new("a", [("a", 0.9), ("a", 0.05), ("b", 0.05)]).into();
assert!(problem(repeated).contains("option \"a\" more than once"));
let mut levels = response();
levels.answers[2].1 = ScoreAnswer::new(0.5, [0.5, 0.5]).into();
assert!(problem(levels).contains("2 level probabilities for 3 levels"));
let mut range = response();
range.answers[1].1 = YesNoAnswer::new(1.5).into();
assert!(problem(range).contains("outside 0..=1"));
let mut nan = response();
nan.answers[2].1 = ScoreAnswer::new(f64::NAN, [0.1, 0.6, 0.3]).into();
assert!(problem(nan).contains("score NaN"));
}
#[test]
fn unvalidated_empty_scale_does_not_underflow() {
let request = DecisionRequest::new("m", "x").question("q", ScoreQuestion::new("?"));
for score in [0.0, 3.0] {
let response =
DecisionResponse::new([("q", Answer::from(ScoreAnswer::new(score, [])))]);
assert!(response.validate(&request).is_err(), "score {score}");
}
}
#[test]
fn accessors_and_helpers() {
let response = response();
assert_eq!(
response.choice("label").unwrap().probability("a"),
Some(0.9)
);
assert!(!response.yes_no("spam").unwrap().is_yes());
let score = response.score("urgency").unwrap();
assert_eq!(score.most_likely(), Some(1));
assert_eq!(score.nearest(), Some(1));
assert!(response.choice("spam").is_none());
assert_eq!(ScoreAnswer::new(0.0, [0.5, 0.5]).most_likely(), Some(0));
assert_eq!(ScoreAnswer::new(0.0, [0.0, 0.0]).most_likely(), None);
assert_eq!(ScoreAnswer::new(0.0, [0.5, f64::NAN]).most_likely(), None);
assert_eq!(ScoreAnswer::new(0.0, []).most_likely(), None);
assert_eq!(ScoreAnswer::new(9.0, [0.5, 0.5]).nearest(), Some(1));
assert_eq!(ScoreAnswer::new(0.0, []).nearest(), None);
}
#[test]
fn nearest_can_name_a_level_with_no_probability() {
let split = ScoreAnswer::new(2.0, [0.5, 0.0, 0.0, 0.0, 0.5]);
assert_eq!(split.nearest(), Some(2));
assert_eq!(split.probability(2), Some(0.0));
assert_eq!(split.most_likely(), Some(0));
}
#[test]
fn decide_answers_only_at_the_threshold() {
let decide =
|probability: f64, threshold: f64| YesNoAnswer::new(probability).decide(threshold);
assert_eq!(decide(0.9, 0.9), Some(true));
assert_eq!(decide(0.25, 0.75), Some(false));
assert_eq!(decide(0.95, 0.9), Some(true));
assert_eq!(decide(0.05, 0.9), Some(false));
assert_eq!(decide(0.7, 0.9), None);
assert_eq!(decide(0.3, 0.9), None);
assert_eq!(decide(0.5, 0.51), None);
assert!(YesNoAnswer::new(0.7).is_yes());
assert_eq!(decide(1.0, 1.0), Some(true));
assert_eq!(decide(0.0, 1.0), Some(false));
assert_eq!(decide(0.999, 1.0), None);
assert_eq!(decide(0.6, 0.55), Some(true));
assert_eq!(decide(0.5, 0.500_001), None);
assert_eq!(decide(f64::NAN, 0.9), None);
}
#[test]
fn decide_rejects_thresholds_that_cannot_separate_the_sides() {
for threshold in [0.5, 0.2, 0.0, -1.0, 1.01, f64::NAN, f64::INFINITY] {
let result = std::panic::catch_unwind(|| YesNoAnswer::new(0.5).decide(threshold));
assert!(result.is_err(), "threshold {threshold}");
}
}
#[test]
fn serializes_answers_in_order() {
let value = serde_json::to_value(response()).unwrap();
assert_eq!(
value["answers"]["label"],
json!({"type": "choice", "chosen": "a", "probabilities": {"b": 0.1, "a": 0.9}})
);
let text = serde_json::to_string(&response()).unwrap();
let back: DecisionResponse = serde_json::from_str(&text).unwrap();
assert_eq!(back, response());
}
}