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}