icon.rsannotatedicon.rssource88 lines · 3.7 KB · raw
1use jev_protocol::{Choice, ChoiceAnswer, Json, Key, ProtocolError, Questions, Response};
2use log::{debug, trace, warn};
3
4/// The option that means "none of these pictures fits". Never an icon's name (those are lowercase words).
5pub const NONE_FITS: &str = "NONE";
6
7/// Jev must put at least this much on one picture for it to be used. A first guess (2026-10-05), to be tuned from
8/// what the choices look like on real facts: with twenty options a calibrated top choice is seldom above 0.9, and a
9/// wrong picture on a card costs more than a generic star does.
10pub const MIN_ICON_CONFIDENCE: f64 = 0.3;
11
12/// Which picture shows a memory best, as one Jev Choice over a shortlist, with a way out when none does.
13pub struct IconChoice {
14    pub questions: Questions,
15    key: Key<ChoiceAnswer>,
16}
17
18impl IconChoice {
19    /// Needs at least one name; the question always adds [`NONE_FITS`]. `options` are `(name, what it shows)`,
20    /// best candidate first.
21    pub fn new(options: &[(String, String)]) -> Result<Self, ProtocolError> {
22        debug!("building an icon question over {} pictures", options.len());
23        let mut questions = Questions::new();
24        let key = questions.choice(
25            "picture",
26            Choice::new(
27                Json::text(
28                    "The state holds one thing a toy cat remembers about a young child. Which picture shows what it is \
29                     about best? If none of the pictures is about it, pick NONE.",
30                ),
31                options
32                    .iter()
33                    .map(|(name, what)| (name.clone(), Some(Json::text(what))))
34                    .chain(std::iter::once((NONE_FITS.to_owned(), Some(Json::text("none of the pictures is about it"))))),
35            )?,
36        )?;
37        Ok(Self { questions, key })
38    }
39
40    pub fn state(memory: &str) -> Json {
41        let json = serde_json::json!({ "memory": memory });
42        trace!("icon state: memory of {} chars", memory.len());
43        Json::canonical(&json.to_string()).unwrap_or_else(|e| {
44            warn!("icon state is not canonical JSON ({e:?}); sending it as plain text");
45            Json::text(memory)
46        })
47    }
48
49    /// The chosen picture's name, or `None` when Jev chose NONE or was not sure enough of any.
50    pub fn chosen(&self, response: &Response) -> Option<String> {
51        let a = response.get(self.key);
52        decide(&a.choice, a.probabilities.iter().map(|(l, p)| (l.as_str(), *p)))
53    }
54}
55
56/// The decision, apart from asking: the top option, unless it is NONE or too unsure.
57pub fn decide<'a>(choice: &str, probabilities: impl Iterator<Item = (&'a str, f64)>) -> Option<String> {
58    if choice == NONE_FITS {
59        return None;
60    }
61    let p = probabilities.filter(|(l, _)| *l == choice).map(|(_, p)| p).next().unwrap_or(0.0);
62    (p >= MIN_ICON_CONFIDENCE).then(|| choice.to_owned())
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    fn options(n: usize) -> Vec<(String, String)> {
70        (0..n).map(|i| (format!("icon-{i}"), format!("a picture of icon {i}"))).collect()
71    }
72
73    #[test]
74    fn the_question_builds_for_one_to_many_pictures() {
75        assert!(IconChoice::new(&options(1)).is_ok());
76        assert!(IconChoice::new(&options(20)).is_ok());
77        assert!(IconChoice::new(&options(0)).is_err(), "NONE alone is not a choice");
78    }
79
80    #[test]
81    fn none_and_an_unsure_top_choice_are_no_picture() {
82        let p = |v: &[(&'static str, f64)]| v.to_vec().into_iter();
83        assert_eq!(decide("cat", p(&[("cat", 0.8), ("dog", 0.1)])), Some("cat".into()));
84        assert_eq!(decide(NONE_FITS, p(&[(NONE_FITS, 0.9), ("cat", 0.05)])), None);
85        assert_eq!(decide("cat", p(&[("cat", 0.2), ("dog", 0.19)])), None, "too unsure");
86        assert_eq!(decide("cat", p(&[])), None, "no probability, no picture");
87    }
88}