use std::collections::BTreeMap;
use serde_json::{Map, Value};
use crate::question::{Question, Questions};
#[derive(Clone, Debug, PartialEq)]
pub struct NoulAnswer {
pub noul: f64,
}
impl NoulAnswer {
pub fn yes(&self) -> bool {
self.noul > 0.5
}
pub fn yes_above(&self, threshold: f64) -> bool {
self.noul > threshold
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ChoiceAnswer {
pub choice: String,
pub probabilities: BTreeMap<String, f64>,
pub confidence: f64,
}
impl ChoiceAnswer {
pub fn is(&self, option: &str) -> bool {
self.choice == option
}
pub fn probability(&self, option: &str) -> f64 {
self.probabilities.get(option).copied().unwrap_or(0.0)
}
pub fn ranked(&self) -> Vec<(&str, f64)> {
let mut ranked: Vec<(&str, f64)> = self
.probabilities
.iter()
.map(|(option, &p)| (option.as_str(), p))
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
ranked
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ScoreAnswer {
pub score: f64,
pub legend: BTreeMap<usize, Value>,
pub probabilities: BTreeMap<usize, f64>,
pub confidence: f64,
}
impl ScoreAnswer {
pub fn normalized(&self) -> f64 {
match self.legend.len() {
0 | 1 => 0.0,
n => self.score / (n - 1) as f64,
}
}
pub fn level(&self) -> usize {
self.probabilities
.iter()
.max_by(|a, b| a.1.total_cmp(b.1).then(b.0.cmp(a.0)))
.map_or(0, |(&level, _)| level)
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Answer {
Noul(NoulAnswer),
Choice(ChoiceAnswer),
Score(ScoreAnswer),
Raw(Value),
}
impl Answer {
pub fn as_noul(&self) -> Option<&NoulAnswer> {
match self {
Answer::Noul(a) => Some(a),
_ => None,
}
}
pub fn as_choice(&self) -> Option<&ChoiceAnswer> {
match self {
Answer::Choice(a) => Some(a),
_ => None,
}
}
pub fn as_score(&self) -> Option<&ScoreAnswer> {
match self {
Answer::Score(a) => Some(a),
_ => None,
}
}
pub fn as_raw(&self) -> Option<&Value> {
match self {
Answer::Raw(a) => Some(a),
_ => None,
}
}
}
pub(crate) fn decode_all(
questions: &Questions,
answers: Option<&Value>,
) -> Result<BTreeMap<String, Answer>, String> {
let answers = answers
.and_then(Value::as_object)
.ok_or_else(|| "answers".to_string())?;
questions
.iter()
.map(|(id, question)| {
let raw = answers.get(id).ok_or_else(|| format!("answers.{id}"))?;
let answer = decode(question, raw).map_err(|field| format!("answers.{id}.{field}"))?;
Ok((id.to_string(), answer))
})
.collect()
}
fn decode(question: &Question, raw: &Value) -> Result<Answer, String> {
if let Question::Raw(_) = question {
return Ok(Answer::Raw(raw.clone()));
}
let raw = raw.as_object().ok_or_else(|| "type".to_string())?;
match question {
Question::Noul(_) => {
expect_type(raw, "noul")?;
Ok(Answer::Noul(NoulAnswer {
noul: number(raw, "noul")?,
}))
}
Question::Choice(_) => {
expect_type(raw, "choice")?;
let choice = raw
.get("choice")
.and_then(Value::as_str)
.ok_or_else(|| "choice".to_string())?;
let probabilities = probabilities(raw)?
.into_iter()
.map(|(option, p)| (option.clone(), p))
.collect();
Ok(Answer::Choice(ChoiceAnswer {
choice: choice.to_string(),
probabilities,
confidence: number(raw, "confidence")?,
}))
}
Question::Score(_) => {
expect_type(raw, "score")?;
let score = number(raw, "score")?;
let legend = raw
.get("legend")
.and_then(Value::as_object)
.ok_or_else(|| "legend".to_string())?;
let legend = index_keys(legend, "legend", |v| Some(v.clone()))?;
let probabilities = raw
.get("probabilities")
.and_then(Value::as_object)
.ok_or_else(|| "probabilities".to_string())?;
let probabilities = index_keys(probabilities, "probabilities", Value::as_f64)?;
Ok(Answer::Score(ScoreAnswer {
score,
legend,
probabilities,
confidence: number(raw, "confidence")?,
}))
}
Question::Raw(_) => unreachable!("handled above"),
}
}
fn expect_type(raw: &Map<String, Value>, expected: &str) -> Result<(), String> {
match raw.get("type").and_then(Value::as_str) {
Some(t) if t == expected => Ok(()),
_ => Err("type".into()),
}
}
fn number(raw: &Map<String, Value>, field: &str) -> Result<f64, String> {
raw.get(field)
.and_then(Value::as_f64)
.ok_or_else(|| field.to_string())
}
fn probabilities(raw: &Map<String, Value>) -> Result<Vec<(&String, f64)>, String> {
raw.get("probabilities")
.and_then(Value::as_object)
.ok_or_else(|| "probabilities".to_string())?
.iter()
.map(|(option, p)| {
p.as_f64()
.map(|p| (option, p))
.ok_or_else(|| format!("probabilities.{option}"))
})
.collect()
}
fn index_keys<T>(
map: &Map<String, Value>,
field: &str,
value: impl Fn(&Value) -> Option<T>,
) -> Result<BTreeMap<usize, T>, String> {
map.iter()
.map(|(key, v)| {
let bad = || format!("{field}.{key}");
let index = key.parse::<usize>().map_err(|_| bad())?;
Ok((index, value(v).ok_or_else(bad)?))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Choice, Noul, Score};
use serde_json::json;
fn questions() -> Questions {
Questions::new()
.ask("billing", Noul::new("Billing?"))
.ask(
"tone",
Choice::new("Tone?").options(["calm", "frustrated", "angry"]),
)
.ask(
"urgency",
Score::new("Urgent?", ["Can wait", "This week", "Today"]),
)
.ask("future", Question::Raw(json!({"type": "future"})))
}
fn answers() -> Value {
json!({
"billing": {"type": "noul", "noul": 0.99},
"tone": {"type": "choice", "choice": "frustrated",
"probabilities": {"calm": 0.0, "frustrated": 0.86, "angry": 0.14},
"confidence": 0.78},
"urgency": {"type": "score", "score": 1.99,
"legend": {"0": "Can wait", "1": "This week", "2": "Today"},
"probabilities": {"0": 0.0, "1": 0.01, "2": 0.99},
"confidence": 0.99},
"future": {"type": "future", "whatever": [1, 2]}
})
}
#[test]
fn decodes_every_type() {
let decoded = decode_all(&questions(), Some(&answers())).unwrap();
let billing = decoded["billing"].as_noul().unwrap();
assert!(billing.yes());
assert!(!billing.yes_above(0.995));
let tone = decoded["tone"].as_choice().unwrap();
assert!(tone.is("frustrated"));
assert_eq!(tone.probability("angry"), 0.14);
assert_eq!(tone.probability("missing"), 0.0);
assert_eq!(
tone.ranked(),
vec![("frustrated", 0.86), ("angry", 0.14), ("calm", 0.0)]
);
let urgency = decoded["urgency"].as_score().unwrap();
assert_eq!(urgency.level(), 2);
assert_eq!(urgency.legend[&1], json!("This week"));
assert!((urgency.normalized() - 0.995).abs() < 1e-9);
assert_eq!(
decoded["future"].as_raw().unwrap(),
&json!({"type": "future", "whatever": [1, 2]})
);
}
#[test]
fn names_the_first_bad_field() {
let broken = |path: &str, value: Value| {
let mut answers = answers();
let (id, field) = path.split_once('.').unwrap();
answers[id][field] = value;
decode_all(&questions(), Some(&answers)).unwrap_err()
};
assert_eq!(
broken("billing.noul", json!("high")),
"answers.billing.noul"
);
assert_eq!(broken("tone.type", json!("noul")), "answers.tone.type");
assert_eq!(
broken("tone.confidence", Value::Null),
"answers.tone.confidence"
);
assert_eq!(
broken("tone.probabilities", json!({"calm": "x"})),
"answers.tone.probabilities.calm"
);
assert_eq!(
broken("urgency.legend", json!({"one": "x"})),
"answers.urgency.legend.one"
);
let mut missing = answers();
missing.as_object_mut().unwrap().remove("tone");
assert_eq!(
decode_all(&questions(), Some(&missing)).unwrap_err(),
"answers.tone"
);
assert_eq!(decode_all(&questions(), None).unwrap_err(), "answers");
}
#[test]
fn level_ties_go_to_the_lower_level() {
let score = ScoreAnswer {
score: 0.5,
legend: BTreeMap::from([(0, json!("a")), (1, json!("b"))]),
probabilities: BTreeMap::from([(0, 0.5), (1, 0.5)]),
confidence: 0.0,
};
assert_eq!(score.level(), 0);
}
}