Skip to main content

typesafe/
answer.rs

1//! Answers: one per question, typed by the question that asked it.
2
3use std::collections::BTreeMap;
4
5use serde_json::{Map, Value};
6
7use crate::question::{Question, Questions};
8
9/// The answer to a [`Noul`](crate::Noul): `noul` is the probability of yes,
10/// from 0 to 1.
11///
12/// A value near 0.5 means yes and no are about equally likely, not "somewhat
13/// yes". Noul answers carry no separate confidence.
14#[derive(Clone, Debug, PartialEq)]
15pub struct NoulAnswer {
16    pub noul: f64,
17}
18
19impl NoulAnswer {
20    /// Whether the probability of yes is above 0.5.
21    pub fn yes(&self) -> bool {
22        self.noul > 0.5
23    }
24
25    /// Whether the probability of yes is above `threshold`.
26    pub fn yes_above(&self, threshold: f64) -> bool {
27        self.noul > threshold
28    }
29}
30
31/// The answer to a [`Choice`](crate::Choice).
32///
33/// - `choice`: the most probable option
34/// - `probabilities`: every option mapped to its probability; they sum to 1
35/// - `confidence`: how concentrated the distribution is, from 0 to 1. It is
36///   not the probability that the choice is right.
37#[derive(Clone, Debug, PartialEq)]
38pub struct ChoiceAnswer {
39    pub choice: String,
40    pub probabilities: BTreeMap<String, f64>,
41    pub confidence: f64,
42}
43
44impl ChoiceAnswer {
45    /// Whether `option` is the chosen one.
46    pub fn is(&self, option: &str) -> bool {
47        self.choice == option
48    }
49
50    /// The probability of `option`, or 0 if the answer doesn't list it.
51    pub fn probability(&self, option: &str) -> f64 {
52        self.probabilities.get(option).copied().unwrap_or(0.0)
53    }
54
55    /// The options with their probabilities, most probable first. Useful for a
56    /// "did you mean" prompt.
57    pub fn ranked(&self) -> Vec<(&str, f64)> {
58        let mut ranked: Vec<(&str, f64)> = self
59            .probabilities
60            .iter()
61            .map(|(option, &p)| (option.as_str(), p))
62            .collect();
63        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
64        ranked
65    }
66}
67
68/// The answer to a [`Score`](crate::Score).
69///
70/// - `score`: the probability-weighted level index. With four levels it lies
71///   between 0 and 3 and can fall between levels.
72/// - `legend`: each level index mapped to the description the question gave it
73/// - `probabilities`: each level index mapped to its probability
74/// - `confidence`: how concentrated the distribution is, from 0 to 1
75///
76/// Compare `score` against thresholds. Don't use it to recover an exact
77/// quantity between two levels.
78#[derive(Clone, Debug, PartialEq)]
79pub struct ScoreAnswer {
80    pub score: f64,
81    pub legend: BTreeMap<usize, Value>,
82    pub probabilities: BTreeMap<usize, f64>,
83    pub confidence: f64,
84}
85
86impl ScoreAnswer {
87    /// The score scaled to 0..=1, where 0 is the first level and 1 the last.
88    pub fn normalized(&self) -> f64 {
89        match self.legend.len() {
90            0 | 1 => 0.0,
91            n => self.score / (n - 1) as f64,
92        }
93    }
94
95    /// The index of the most probable level.
96    pub fn level(&self) -> usize {
97        self.probabilities
98            .iter()
99            .max_by(|a, b| a.1.total_cmp(b.1).then(b.0.cmp(a.0)))
100            .map_or(0, |(&level, _)| level)
101    }
102}
103
104/// Any one answer. Its variant matches the question that asked it.
105#[derive(Clone, Debug, PartialEq)]
106#[non_exhaustive]
107pub enum Answer {
108    Noul(NoulAnswer),
109    Choice(ChoiceAnswer),
110    Score(ScoreAnswer),
111    /// The answer to a [`Question::Raw`], as the server sent it.
112    Raw(Value),
113}
114
115impl Answer {
116    pub fn as_noul(&self) -> Option<&NoulAnswer> {
117        match self {
118            Answer::Noul(a) => Some(a),
119            _ => None,
120        }
121    }
122
123    pub fn as_choice(&self) -> Option<&ChoiceAnswer> {
124        match self {
125            Answer::Choice(a) => Some(a),
126            _ => None,
127        }
128    }
129
130    pub fn as_score(&self) -> Option<&ScoreAnswer> {
131        match self {
132            Answer::Score(a) => Some(a),
133            _ => None,
134        }
135    }
136
137    pub fn as_raw(&self) -> Option<&Value> {
138        match self {
139            Answer::Raw(a) => Some(a),
140            _ => None,
141        }
142    }
143}
144
145/// Decodes every question's answer. On failure, returns the path of the first
146/// field that is missing or malformed, such as `answers.tone.confidence`, so a
147/// bad response is reported rather than half read.
148pub(crate) fn decode_all(
149    questions: &Questions,
150    answers: Option<&Value>,
151) -> Result<BTreeMap<String, Answer>, String> {
152    let answers = answers
153        .and_then(Value::as_object)
154        .ok_or_else(|| "answers".to_string())?;
155    questions
156        .iter()
157        .map(|(id, question)| {
158            let raw = answers.get(id).ok_or_else(|| format!("answers.{id}"))?;
159            let answer = decode(question, raw).map_err(|field| format!("answers.{id}.{field}"))?;
160            Ok((id.to_string(), answer))
161        })
162        .collect()
163}
164
165fn decode(question: &Question, raw: &Value) -> Result<Answer, String> {
166    if let Question::Raw(_) = question {
167        return Ok(Answer::Raw(raw.clone()));
168    }
169    let raw = raw.as_object().ok_or_else(|| "type".to_string())?;
170    match question {
171        Question::Noul(_) => {
172            expect_type(raw, "noul")?;
173            Ok(Answer::Noul(NoulAnswer {
174                noul: number(raw, "noul")?,
175            }))
176        }
177        Question::Choice(_) => {
178            expect_type(raw, "choice")?;
179            let choice = raw
180                .get("choice")
181                .and_then(Value::as_str)
182                .ok_or_else(|| "choice".to_string())?;
183            let probabilities = probabilities(raw)?
184                .into_iter()
185                .map(|(option, p)| (option.clone(), p))
186                .collect();
187            Ok(Answer::Choice(ChoiceAnswer {
188                choice: choice.to_string(),
189                probabilities,
190                confidence: number(raw, "confidence")?,
191            }))
192        }
193        Question::Score(_) => {
194            expect_type(raw, "score")?;
195            let score = number(raw, "score")?;
196            let legend = raw
197                .get("legend")
198                .and_then(Value::as_object)
199                .ok_or_else(|| "legend".to_string())?;
200            let legend = index_keys(legend, "legend", |v| Some(v.clone()))?;
201            let probabilities = raw
202                .get("probabilities")
203                .and_then(Value::as_object)
204                .ok_or_else(|| "probabilities".to_string())?;
205            let probabilities = index_keys(probabilities, "probabilities", Value::as_f64)?;
206            Ok(Answer::Score(ScoreAnswer {
207                score,
208                legend,
209                probabilities,
210                confidence: number(raw, "confidence")?,
211            }))
212        }
213        Question::Raw(_) => unreachable!("handled above"),
214    }
215}
216
217fn expect_type(raw: &Map<String, Value>, expected: &str) -> Result<(), String> {
218    match raw.get("type").and_then(Value::as_str) {
219        Some(t) if t == expected => Ok(()),
220        _ => Err("type".into()),
221    }
222}
223
224fn number(raw: &Map<String, Value>, field: &str) -> Result<f64, String> {
225    raw.get(field)
226        .and_then(Value::as_f64)
227        .ok_or_else(|| field.to_string())
228}
229
230fn probabilities(raw: &Map<String, Value>) -> Result<Vec<(&String, f64)>, String> {
231    raw.get("probabilities")
232        .and_then(Value::as_object)
233        .ok_or_else(|| "probabilities".to_string())?
234        .iter()
235        .map(|(option, p)| {
236            p.as_f64()
237                .map(|p| (option, p))
238                .ok_or_else(|| format!("probabilities.{option}"))
239        })
240        .collect()
241}
242
243// Score levels come keyed by their index as a string ("0", "1", ...).
244fn index_keys<T>(
245    map: &Map<String, Value>,
246    field: &str,
247    value: impl Fn(&Value) -> Option<T>,
248) -> Result<BTreeMap<usize, T>, String> {
249    map.iter()
250        .map(|(key, v)| {
251            let bad = || format!("{field}.{key}");
252            let index = key.parse::<usize>().map_err(|_| bad())?;
253            Ok((index, value(v).ok_or_else(bad)?))
254        })
255        .collect()
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::{Choice, Noul, Score};
262    use serde_json::json;
263
264    fn questions() -> Questions {
265        Questions::new()
266            .ask("billing", Noul::new("Billing?"))
267            .ask(
268                "tone",
269                Choice::new("Tone?").options(["calm", "frustrated", "angry"]),
270            )
271            .ask(
272                "urgency",
273                Score::new("Urgent?", ["Can wait", "This week", "Today"]),
274            )
275            .ask("future", Question::Raw(json!({"type": "future"})))
276    }
277
278    fn answers() -> Value {
279        json!({
280            "billing": {"type": "noul", "noul": 0.99},
281            "tone": {"type": "choice", "choice": "frustrated",
282                     "probabilities": {"calm": 0.0, "frustrated": 0.86, "angry": 0.14},
283                     "confidence": 0.78},
284            "urgency": {"type": "score", "score": 1.99,
285                        "legend": {"0": "Can wait", "1": "This week", "2": "Today"},
286                        "probabilities": {"0": 0.0, "1": 0.01, "2": 0.99},
287                        "confidence": 0.99},
288            "future": {"type": "future", "whatever": [1, 2]}
289        })
290    }
291
292    #[test]
293    fn decodes_every_type() {
294        let decoded = decode_all(&questions(), Some(&answers())).unwrap();
295
296        let billing = decoded["billing"].as_noul().unwrap();
297        assert!(billing.yes());
298        assert!(!billing.yes_above(0.995));
299
300        let tone = decoded["tone"].as_choice().unwrap();
301        assert!(tone.is("frustrated"));
302        assert_eq!(tone.probability("angry"), 0.14);
303        assert_eq!(tone.probability("missing"), 0.0);
304        assert_eq!(
305            tone.ranked(),
306            vec![("frustrated", 0.86), ("angry", 0.14), ("calm", 0.0)]
307        );
308
309        let urgency = decoded["urgency"].as_score().unwrap();
310        assert_eq!(urgency.level(), 2);
311        assert_eq!(urgency.legend[&1], json!("This week"));
312        assert!((urgency.normalized() - 0.995).abs() < 1e-9);
313
314        assert_eq!(
315            decoded["future"].as_raw().unwrap(),
316            &json!({"type": "future", "whatever": [1, 2]})
317        );
318    }
319
320    #[test]
321    fn names_the_first_bad_field() {
322        let broken = |path: &str, value: Value| {
323            let mut answers = answers();
324            let (id, field) = path.split_once('.').unwrap();
325            answers[id][field] = value;
326            decode_all(&questions(), Some(&answers)).unwrap_err()
327        };
328
329        assert_eq!(
330            broken("billing.noul", json!("high")),
331            "answers.billing.noul"
332        );
333        assert_eq!(broken("tone.type", json!("noul")), "answers.tone.type");
334        assert_eq!(
335            broken("tone.confidence", Value::Null),
336            "answers.tone.confidence"
337        );
338        assert_eq!(
339            broken("tone.probabilities", json!({"calm": "x"})),
340            "answers.tone.probabilities.calm"
341        );
342        assert_eq!(
343            broken("urgency.legend", json!({"one": "x"})),
344            "answers.urgency.legend.one"
345        );
346
347        let mut missing = answers();
348        missing.as_object_mut().unwrap().remove("tone");
349        assert_eq!(
350            decode_all(&questions(), Some(&missing)).unwrap_err(),
351            "answers.tone"
352        );
353        assert_eq!(decode_all(&questions(), None).unwrap_err(), "answers");
354    }
355
356    #[test]
357    fn level_ties_go_to_the_lower_level() {
358        let score = ScoreAnswer {
359            score: 0.5,
360            legend: BTreeMap::from([(0, json!("a")), (1, json!("b"))]),
361            probabilities: BTreeMap::from([(0, 0.5), (1, 0.5)]),
362            confidence: 0.0,
363        };
364        assert_eq!(score.level(), 0);
365    }
366}