whiskers.git / crates / whiskers-core / tests / conversation.rs
1use std::sync::{Arc, Mutex};
2
3use whiskers_core::*;
4
5type Shared<T> = Arc<Mutex<T>>;
6
7struct FakeModel {
8    /// Answers in order; the last repeats. `Err` is a model failure.
9    replies: Mutex<Vec<Result<String, String>>>,
10    seen: Shared<Vec<(String, Vec<Turn>)>>,
11}
12impl Model for FakeModel {
13    fn complete(&self, system: &str, history: &[Turn]) -> Result<String, ModelError> {
14        self.seen.lock().unwrap().push((system.to_owned(), history.to_vec()));
15        let mut r = self.replies.lock().unwrap();
16        let next = if r.len() > 1 { r.remove(0) } else { r[0].clone() };
17        next.map_err(ModelError::new)
18    }
19}
20
21/// Refuses text containing "BAD"; "HELP" needs a grown-up; "DOWN" is unreachable.
22struct FakeGuard(Shared<Vec<Age>>);
23impl Guard for FakeGuard {
24    fn check(&self, _d: Direction, age: Age, text: &str) -> Result<Verdict, GuardError> {
25        self.0.lock().unwrap().push(age);
26        if text.contains("DOWN") {
27            Err(GuardError("unreachable".into()))
28        } else if text.contains("HELP") {
29            Ok(Verdict::Refuse { reason: "hurt".into(), kind: RefusalKind::NeedsAGrownUp })
30        } else if text.contains("BAD") {
31            Ok(Verdict::Refuse { reason: "bad".into(), kind: RefusalKind::OffLimits })
32        } else {
33            Ok(Verdict::Allow)
34        }
35    }
36}
37
38struct MemLog(Shared<Vec<Entry>>, bool);
39impl Log for MemLog {
40    fn append(&mut self, e: &Entry) -> Result<(), LogError> {
41        if self.1 {
42            return Err(LogError("disk full".into()));
43        }
44        self.0.lock().unwrap().push(e.clone());
45        Ok(())
46    }
47}
48
49struct MemPictures(Shared<Vec<Image>>, bool);
50impl Pictures for MemPictures {
51    fn save(&mut self, image: &Image) -> Result<PictureId, PictureError> {
52        if self.1 {
53            return Err(PictureError("no space".into()));
54        }
55        let mut v = self.0.lock().unwrap();
56        v.push(image.clone());
57        Ok(PictureId(format!("p{}", v.len())))
58    }
59}
60
61struct MemMemory(Shared<Vec<Fact>>);
62impl Memory for MemMemory {
63    fn facts(&self) -> Vec<Fact> {
64        self.0.lock().unwrap().clone()
65    }
66    fn add(&mut self, new: NewFact, at_ms: u64) -> Result<Fact, MemoryError> {
67        let mut v = self.0.lock().unwrap();
68        let f = Fact {
69            id: v.len() as u64 + 1,
70            text: new.text,
71            learned_at_ms: at_ms,
72            kind: new.kind,
73            who: new.who,
74            place: new.place,
75            when: new.when,
76            pictures: new.pictures,
77            embedding: vec![],
78            gid: format!("g{}", v.len() + 1),
79            visibility: whiskers_core::Visibility::default(),
80            icon: None,
81        };
82        v.push(f.clone());
83        Ok(f)
84    }
85    fn set_embedding(&mut self, id: u64, embedding: Vec<f32>) -> Result<(), MemoryError> {
86        let mut v = self.0.lock().unwrap();
87        v.iter_mut().find(|f| f.id == id).ok_or(MemoryError("none".into()))?.embedding = embedding;
88        Ok(())
89    }
90    fn forget(&mut self, id: u64) -> Result<Fact, MemoryError> {
91        let mut v = self.0.lock().unwrap();
92        let at = v.iter().position(|f| f.id == id).ok_or(MemoryError("none".into()))?;
93        Ok(v.remove(at))
94    }
95    fn give_icon(&mut self, id: u64, icon: whiskers_core::IconId) -> Result<Fact, MemoryError> {
96        let mut v = self.0.lock().unwrap();
97        let f = v.iter_mut().find(|f| f.id == id).ok_or(MemoryError("none".into()))?;
98        f.icon.get_or_insert(icon);
99        Ok(f.clone())
100    }
101    fn hide(&mut self, id: u64, at_ms: u64) -> Result<Fact, MemoryError> {
102        let mut v = self.0.lock().unwrap();
103        let f = v.iter_mut().find(|f| f.id == id).ok_or(MemoryError("none".into()))?;
104        f.visibility = f.visibility.hidden_after(at_ms);
105        Ok(f.clone())
106    }
107    fn restore(&mut self, id: u64, at_ms: u64) -> Result<Fact, MemoryError> {
108        let mut v = self.0.lock().unwrap();
109        let f = v.iter_mut().find(|f| f.id == id).ok_or(MemoryError("none".into()))?;
110        f.visibility = f.visibility.restored_after(at_ms);
111        Ok(f.clone())
112    }
113    fn snapshot(&self) -> MemorySnapshot {
114        MemorySnapshot { facts: self.0.lock().unwrap().clone(), forgotten: vec![] }
115    }
116    fn merge(&mut self, _remote: MemorySnapshot) -> Result<usize, MemoryError> {
117        Ok(0)
118    }
119}
120
121/// Words hashed into 16 buckets: texts that share words are close.
122struct FakeEmbedder {
123    down: Mutex<bool>,
124    calls: Mutex<usize>,
125}
126impl FakeEmbedder {
127    fn vector(text: &str) -> Vec<f32> {
128        let mut v = vec![0.0f32; 16];
129        for w in text.to_lowercase().split(|c: char| !c.is_alphanumeric()).filter(|w| w.len() > 2) {
130            let h = w.bytes().fold(7usize, |a, b| a.wrapping_mul(31).wrapping_add(b as usize));
131            v[h % 16] += 1.0;
132        }
133        v
134    }
135}
136impl Embedder for FakeEmbedder {
137    fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
138        *self.calls.lock().unwrap() += 1;
139        if *self.down.lock().unwrap() {
140            return Err(EmbedError("down".into()));
141        }
142        Ok(texts.iter().map(|t| Self::vector(t)).collect())
143    }
144}
145
146/// Scores a candidate by how many of the query's words it shares; can be switched off.
147struct FakeRanker {
148    down: Mutex<bool>,
149    calls: Mutex<usize>,
150}
151impl Ranker for FakeRanker {
152    fn rank(&self, query: &str, candidates: &[String]) -> Result<Vec<f32>, RankError> {
153        *self.calls.lock().unwrap() += 1;
154        if *self.down.lock().unwrap() {
155            return Err(RankError("down".into()));
156        }
157        let q: Vec<String> = query.to_lowercase().split_whitespace().map(String::from).collect();
158        Ok(candidates.iter().map(|c| q.iter().filter(|w| c.to_lowercase().contains(w.as_str())).count() as f32).collect())
159    }
160}
161
162struct MemChat(Shared<ChatState>);
163impl ChatStore for MemChat {
164    fn load(&self) -> Result<ChatState, ChatError> {
165        Ok(self.0.lock().unwrap().clone())
166    }
167    fn save(&mut self, state: &ChatState) -> Result<(), ChatError> {
168        *self.0.lock().unwrap() = state.clone();
169        Ok(())
170    }
171}
172
173struct TestClock(Mutex<u64>);
174impl Clock for TestClock {
175    fn now_ms(&self) -> u64 {
176        *self.0.lock().unwrap()
177    }
178}
179
180struct Rig {
181    conv: Conversation,
182    reflector: Reflector,
183    log: Shared<Vec<Entry>>,
184    seen: Shared<Vec<(String, Vec<Turn>)>>,
185    pictures: Shared<Vec<Image>>,
186    facts: Shared<Vec<Fact>>,
187    chat: Shared<ChatState>,
188    embedder: Arc<FakeEmbedder>,
189    ranker: Arc<FakeRanker>,
190    clock: Arc<TestClock>,
191    ages: Shared<Vec<Age>>,
192    profile: SharedProfile,
193}
194
195struct Knobs {
196    log_broken: bool,
197    pictures_broken: bool,
198    memory_limit: usize,
199    compress_after: usize,
200    keep_turns: usize,
201    chat: ChatState,
202    facts: Vec<Fact>,
203    min_cosine: f32,
204}
205impl Default for Knobs {
206    fn default() -> Self {
207        Self { log_broken: false, pictures_broken: false, memory_limit: 50, compress_after: 100, keep_turns: 6, chat: ChatState::default(), facts: vec![], min_cosine: 0.45 }
208    }
209}
210
211fn rig_with(replies: &[Result<&str, &str>], k: Knobs) -> Rig {
212    let log: Shared<Vec<Entry>> = Arc::default();
213    let seen: Shared<Vec<(String, Vec<Turn>)>> = Arc::default();
214    let pictures: Shared<Vec<Image>> = Arc::default();
215    let facts: Shared<Vec<Fact>> = Arc::new(Mutex::new(k.facts));
216    let chat: Shared<ChatState> = Arc::new(Mutex::new(k.chat));
217    let replies = replies.iter().map(|r| r.map(String::from).map_err(String::from)).collect();
218    let model: Arc<dyn Model> = Arc::new(FakeModel { replies: Mutex::new(replies), seen: seen.clone() });
219    let ages: Shared<Vec<Age>> = Arc::default();
220    let profile = SharedProfile::default();
221    let guard: Arc<dyn Guard> = Arc::new(FakeGuard(ages.clone()));
222    let embedder = Arc::new(FakeEmbedder { down: Mutex::new(false), calls: Mutex::new(0) });
223    let ranker = Arc::new(FakeRanker { down: Mutex::new(false), calls: Mutex::new(0) });
224    let clock = Arc::new(TestClock(Mutex::new(1_000)));
225    let slog = SharedLog::new(Box::new(MemLog(log.clone(), k.log_broken)));
226    let smem = SharedMemory::new(Box::new(MemMemory(facts.clone())));
227    let schat = SharedChat::open(Box::new(MemChat(chat.clone()))).unwrap();
228    let mut recall = Recall::new(embedder.clone(), ranker.clone());
229    recall.min_cosine = k.min_cosine;
230    let recall = Arc::new(recall);
231    let conv = Conversation::new(
232        Config { system_prompt: None, history_turns: 4, greet_after_ms: 30 * 60 * 1000 },
233        Parts {
234            model: model.clone(),
235            guard: guard.clone(),
236            log: slog.clone(),
237            pictures: Box::new(MemPictures(pictures.clone(), k.pictures_broken)),
238            memory: smem.clone(),
239            chat: schat.clone(),
240            recall,
241            clock: clock.clone(),
242            profile: profile.clone(),
243        },
244    );
245    let reflector = Reflector {
246        model,
247        guard,
248        embedder: embedder.clone(),
249        log: slog,
250        memory: smem,
251        chat: schat,
252        clock: clock.clone(),
253        profile: profile.clone(),
254        config: ReflectorConfig { memory_limit: k.memory_limit, compress_after: k.compress_after, keep_turns: k.keep_turns },
255        icons: Arc::new(whiskers_core::NoIcons),
256        iconified: Default::default(),
257    };
258    Rig { conv, reflector, log, seen, pictures, facts, chat, embedder, ranker, clock, ages, profile }
259}
260
261fn rig(replies: &[Result<&str, &str>]) -> Rig {
262    rig_with(replies, Knobs::default())
263}
264
265fn simple(model: Result<&str, &str>) -> Rig {
266    rig(&[model])
267}
268
269fn events(r: &Rig) -> Vec<Event> {
270    r.log.lock().unwrap().iter().map(|e| e.event.clone()).collect()
271}
272
273fn hear(text: &str) -> Heard {
274    Heard::words(text)
275}
276
277fn fact(id: u64, text: &str) -> Fact {
278    Fact {
279        id,
280        text: text.into(),
281        learned_at_ms: id,
282        kind: Kind::Other,
283        who: vec![],
284        place: None,
285        when: None,
286        pictures: vec![],
287        embedding: FakeEmbedder::vector(text),
288        gid: format!("g{id}"),
289        visibility: whiskers_core::Visibility::default(),
290        icon: None,
291    }
292}
293
294// ---- the pipeline ----------------------------------------------------------
295
296#[test]
297fn an_allowed_turn_is_answered_and_fully_logged() {
298    let mut r = simple(Ok("Meow, hello!"));
299    let reply = r.conv.respond(hear("hi"));
300    assert_eq!(reply.outcome, Outcome::Answered);
301    assert_eq!(reply.say.as_str(), "Meow, hello!");
302    assert_eq!(
303        events(&r),
304        vec![
305            Event::Heard { text: "hi".into(), pictures: vec![] },
306            Event::Guarded { direction: Direction::FromChild, verdict: Verdict::Allow },
307            Event::ModelWrote { text: "Meow, hello!".into() },
308            Event::Guarded { direction: Direction::ToChild, verdict: Verdict::Allow },
309            Event::Said { text: "Meow, hello!".into(), outcome: Outcome::Answered },
310        ]
311    );
312}
313
314#[test]
315fn a_refused_question_never_reaches_the_model_and_offers_nothing_to_remember() {
316    let mut r = simple(Ok("x"));
317    let reply = r.conv.respond(hear("BAD thing"));
318    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ChildRefused));
319    assert_eq!(reply.reflect, None);
320    assert!(r.seen.lock().unwrap().is_empty());
321    assert!(matches!(events(&r).last(), Some(Event::Said { outcome: Outcome::Fallback(Fallback::ChildRefused), .. })));
322}
323
324#[test]
325fn something_that_means_she_is_unsafe_sends_her_to_a_grown_up() {
326    let mut r = simple(Ok("x"));
327    let reply = r.conv.respond(hear("HELP"));
328    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::NeedsAGrownUp));
329    assert!(reply.say.as_str().contains("grown-up"));
330    assert!(r.seen.lock().unwrap().is_empty());
331}
332
333#[test]
334fn a_refused_answer_is_logged_for_the_parents_but_not_spoken() {
335    let mut r = simple(Ok("a BAD answer"));
336    let reply = r.conv.respond(hear("hi"));
337    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::AnswerRefused));
338    assert!(!reply.say.as_str().contains("BAD"));
339    assert!(events(&r).contains(&Event::ModelWrote { text: "a BAD answer".into() }));
340}
341
342#[test]
343fn an_unreachable_guard_is_a_refusal_not_an_allow() {
344    let mut r = simple(Ok("x"));
345    let reply = r.conv.respond(hear("DOWN"));
346    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::GuardUnavailable));
347    assert!(r.seen.lock().unwrap().is_empty());
348}
349
350#[test]
351fn a_model_error_ends_in_a_line_about_the_cloud_and_never_shows_the_error() {
352    let mut r = simple(Err("HTTP 500 stack trace"));
353    let reply = r.conv.respond(hear("hi"));
354    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ModelUnavailable));
355    assert!(!reply.say.as_str().contains("500"));
356    assert!(reply.say.as_str().contains("thinking cloud"));
357    assert!(events(&r).iter().any(|e| matches!(e, Event::Failed { stage: FailureStage::Model, .. })));
358}
359
360#[test]
361fn a_broken_log_sends_nothing_anywhere() {
362    let mut r = rig_with(&[Ok("x")], Knobs { log_broken: true, ..Knobs::default() });
363    let reply = r.conv.respond(hear("hi"));
364    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::LogUnavailable));
365    assert!(r.seen.lock().unwrap().is_empty());
366}
367
368#[test]
369fn only_fully_allowed_turns_enter_the_chat_and_the_model_is_shown_a_window() {
370    let mut r = simple(Ok("ok"));
371    r.conv.respond(hear("one"));
372    r.conv.respond(hear("BAD two"));
373    r.conv.respond(hear("three"));
374    r.conv.respond(hear("four"));
375    let seen = r.seen.lock().unwrap();
376    let last = &seen.last().unwrap().1;
377    assert_eq!(last.len(), 4, "history_turns is 4");
378    assert!(last.iter().all(|t| !t.text.contains("BAD")));
379    assert_eq!(last.last().unwrap().text, "four");
380}
381
382#[test]
383fn the_greeting_is_fixed_logged_and_never_asks_the_model() {
384    let mut r = simple(Ok("x"));
385    let reply = r.conv.greet();
386    assert_eq!(reply.say.as_str(), greeting(&Audience::new(None)));
387    assert!(r.seen.lock().unwrap().is_empty());
388    assert!(matches!(events(&r).as_slice(), [Event::Greeted { .. }]));
389}
390
391// ---- pictures ---------------------------------------------------------------
392
393#[test]
394fn pictures_are_kept_first_logged_shown_once_and_not_repeated_in_history() {
395    let mut r = simple(Ok("A bunny!"));
396    let img = Image { media_type: "image/jpeg".into(), bytes: vec![9, 9] };
397    r.conv.respond(Heard { text: "what is this".into(), pictures: vec![img.clone()] });
398    assert_eq!(r.pictures.lock().unwrap().as_slice(), &[img.clone()]);
399    assert_eq!(events(&r)[0], Event::Heard { text: "what is this".into(), pictures: vec![PictureId("p1".into())] });
400    // Call 0 is the separate look at the picture; call 1 is the cat's reply.
401    assert_eq!(r.seen.lock().unwrap()[1].1.last().unwrap().pictures, vec![img]);
402    r.conv.respond(hear("again"));
403    assert!(r.seen.lock().unwrap()[2].1.iter().all(|t| t.pictures.is_empty()));
404}
405
406#[test]
407fn pictures_are_described_and_the_description_is_judged_before_the_cat_sees_them() {
408    let mut r = rig(&[Ok("A rabbit on grass."), Ok("What a lovely bunny!")]);
409    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
410    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
411    assert_eq!(reply.outcome, Outcome::Answered);
412    let seen = r.seen.lock().unwrap();
413    assert_eq!(seen.len(), 2, "one look, one reply");
414    assert!(seen[0].0.contains("-year-old child shows a toy cat"), "the look has its own prompt, not the persona");
415    drop(seen);
416    assert!(events(&r).contains(&Event::PictureSeen { description: "A rabbit on grass.".into() }));
417    let reflect = reply.reflect.unwrap();
418    assert_eq!(reflect.description.as_deref(), Some("A rabbit on grass."));
419    assert_eq!(reflect.pictures, vec![PictureId("p1".into())]);
420}
421
422#[test]
423fn a_picture_whose_description_is_refused_never_reaches_the_cat() {
424    let mut r = rig(&[Ok("A BAD scene"), Ok("never said")]);
425    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
426    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
427    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ChildRefused));
428    assert_eq!(r.seen.lock().unwrap().len(), 1, "only the look was made");
429}
430
431#[test]
432fn a_picture_that_shows_she_is_unsafe_sends_her_to_a_grown_up() {
433    let mut r = rig(&[Ok("A child with a HELP sign"), Ok("never said")]);
434    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
435    let reply = r.conv.respond(Heard { text: String::new(), pictures: vec![img] });
436    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::NeedsAGrownUp));
437}
438
439#[test]
440fn a_failed_look_keeps_the_picture_from_the_cat() {
441    let mut r = rig(&[Err("down")]);
442    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
443    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
444    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ModelUnavailable));
445}
446
447#[test]
448fn a_picture_with_no_words_still_gets_a_guarded_answer() {
449    let mut r = simple(Ok("A cute bunny!"));
450    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
451    let reply = r.conv.respond(Heard { text: String::new(), pictures: vec![img] });
452    assert_eq!(reply.outcome, Outcome::Answered);
453    assert!(events(&r).contains(&Event::Guarded { direction: Direction::ToChild, verdict: Verdict::Allow }));
454}
455
456#[test]
457fn a_picture_that_cannot_be_kept_is_not_sent() {
458    let mut r = rig_with(&[Ok("x")], Knobs { pictures_broken: true, ..Knobs::default() });
459    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
460    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
461    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::LogUnavailable));
462    assert!(r.seen.lock().unwrap().is_empty());
463}
464
465// ---- the persistent chat ----------------------------------------------------
466
467#[test]
468fn the_chat_persists_and_a_new_conversation_picks_it_up() {
469    let mut r = simple(Ok("Nice to meet you!"));
470    r.conv.respond(hear("my name is Ada"));
471    let saved = r.chat.lock().unwrap().clone();
472    assert_eq!(saved.turns.len(), 2);
473    assert!(saved.last_active_ms > 0);
474    // A relaunch: a fresh rig over the saved chat sees the earlier turns.
475    let mut again = rig_with(&[Ok("Welcome back")], Knobs { chat: saved, ..Knobs::default() });
476    again.conv.respond(hear("hello again"));
477    let seen = again.seen.lock().unwrap();
478    assert_eq!(seen[0].1.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(), ["my name is Ada", "Nice to meet you!", "hello again"]);
479}
480
481#[test]
482fn it_says_hello_the_first_time_and_after_a_long_quiet_but_not_on_a_quick_relaunch() {
483    let mut r = simple(Ok("x"));
484    assert!(r.conv.greeting_due(), "first ever");
485    r.conv.greet();
486    assert!(!r.conv.greeting_due(), "just greeted");
487    *r.clock.0.lock().unwrap() += 29 * 60 * 1000;
488    assert!(!r.conv.greeting_due());
489    *r.clock.0.lock().unwrap() += 2 * 60 * 1000;
490    assert!(r.conv.greeting_due(), "after half an hour of quiet");
491}
492
493#[test]
494fn a_long_chat_is_folded_into_its_summary_off_to_the_side() {
495    let mut r = rig_with(
496        &[Ok("ok"), Ok("ok"), Ok("ok"), Ok("ok"), Ok("Ada likes bunnies; they talked about Biscuit.")],
497        Knobs { compress_after: 6, keep_turns: 2, ..Knobs::default() },
498    );
499    for t in ["one", "two", "three", "four"] {
500        r.conv.respond(hear(t));
501    }
502    assert_eq!(r.chat.lock().unwrap().turns.len(), 8);
503    r.reflector.run(None);
504    let chat = r.chat.lock().unwrap().clone();
505    assert_eq!(chat.turns.len(), 2, "only the most recent turns stay word for word");
506    assert_eq!(chat.summary, "Ada likes bunnies; they talked about Biscuit.");
507    assert!(events(&r).contains(&Event::Compressed { turns: 6 }));
508    // The next answer is written knowing the summary.
509    drop(chat);
510    r.conv.respond(hear("five"));
511    assert!(r.seen.lock().unwrap().last().unwrap().0.contains("they talked about Biscuit"));
512}
513
514#[test]
515fn a_summary_that_fails_changes_nothing_and_is_logged() {
516    let mut r = rig_with(&[Ok("ok"), Ok("ok"), Err("down")], Knobs { compress_after: 2, keep_turns: 2, ..Knobs::default() });
517    r.conv.respond(hear("one"));
518    r.conv.respond(hear("two"));
519    r.reflector.run(None);
520    assert_eq!(r.chat.lock().unwrap().turns.len(), 4);
521    assert!(r.chat.lock().unwrap().summary.is_empty());
522    assert!(events(&r).iter().any(|e| matches!(e, Event::MemoryFailed { .. })));
523}
524
525#[test]
526fn a_summary_the_guard_refuses_is_never_applied() {
527    let mut r = rig_with(&[Ok("ok"), Ok("ok"), Ok("BAD summary")], Knobs { compress_after: 2, keep_turns: 2, ..Knobs::default() });
528    r.conv.respond(hear("one"));
529    r.conv.respond(hear("two"));
530    r.reflector.run(None);
531    assert_eq!(r.chat.lock().unwrap().turns.len(), 4, "nothing was folded away");
532    assert!(r.chat.lock().unwrap().summary.is_empty());
533    assert!(events(&r).iter().any(|e| matches!(e, Event::MemoryFailed { error } if error.contains("refused"))));
534}
535
536#[test]
537fn an_exchange_is_offered_again_only_when_the_model_could_not_be_asked() {
538    let mut r = rig(&[Ok("Hi!"), Err("down"), Ok("[]")]);
539    let reply = r.conv.respond(hear("my bunny is Biscuit"));
540    assert!(r.reflector.reflect(reply.reflect.as_ref()).retry, "the model was down");
541    assert!(!r.reflector.reflect(reply.reflect.as_ref()).retry, "an answer, even an empty one, is done");
542}
543
544// ---- remembering ------------------------------------------------------------
545
546#[test]
547fn what_she_said_is_filed_with_its_kind_names_place_and_time_and_found_next_time() {
548    let json = r#"[{"text":"Biscuit is her toy bunny, brown with long ears","kind":"toy","who":["Biscuit"],"place":"at Nana's","when":"yesterday"}]"#;
549    let mut r = rig(&[Ok("Bunny sounds lovely!"), Ok(json), Ok("ok")]);
550    let reply = r.conv.respond(hear("my bunny Biscuit is brown"));
551    let kept = r.reflector.run(reply.reflect.as_ref());
552    assert_eq!(kept.len(), 1);
553    let f = &kept[0];
554    assert_eq!((f.kind, f.who.clone(), f.place.clone(), f.when.clone()), (Kind::Toy, vec!["Biscuit".to_string()], Some("at Nana's".into()), Some("yesterday".into())));
555    assert!(!f.embedding.is_empty(), "filed with its embedding");
556    assert!(events(&r).contains(&Event::Remembered { fact: "Biscuit is her toy bunny, brown with long ears".into() }));
557    r.conv.respond(hear("hello again"));
558    let seen = r.seen.lock().unwrap();
559    assert!(seen.last().unwrap().0.contains("Biscuit is her toy bunny"));
560    assert!(seen.last().unwrap().0.contains("where: at Nana's"));
561}
562
563#[test]
564fn the_picture_that_goes_with_a_name_is_filed_with_it() {
565    let json = r#"[{"text":"Nana has gray hair and glasses","kind":"person","who":["Nana"]}]"#;
566    let mut r = rig(&[Ok("A kind looking lady."), Ok("That is Nana!"), Ok(json)]);
567    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
568    let reply = r.conv.respond(Heard { text: "this is my Nana".into(), pictures: vec![img] });
569    let kept = r.reflector.run(reply.reflect.as_ref());
570    assert_eq!(kept[0].pictures, vec![PictureId("p1".into())]);
571    let asked = r.seen.lock().unwrap().last().unwrap().1.last().unwrap().text.clone();
572    assert!(asked.contains("A kind looking lady."), "the memory is told what the picture looked like: {asked}");
573}
574
575#[test]
576fn old_style_plain_string_facts_still_parse() {
577    let mut r = rig(&[Ok("hi"), Ok(r#"["A fact"]"#)]);
578    let reply = r.conv.respond(hear("hello"));
579    assert_eq!(r.reflector.run(reply.reflect.as_ref()).len(), 1);
580}
581
582#[test]
583fn a_fact_the_guard_refuses_is_not_kept_and_the_parents_can_see_why() {
584    let mut r = rig(&[Ok("hi"), Ok(r#"["something BAD"]"#)]);
585    let reply = r.conv.respond(hear("hello"));
586    assert!(r.reflector.run(reply.reflect.as_ref()).is_empty());
587    assert!(r.facts.lock().unwrap().is_empty());
588    assert!(events(&r).iter().any(|e| matches!(e, Event::NotRemembered { why, .. } if why.starts_with("guard"))));
589}
590
591#[test]
592fn a_full_memory_refuses_new_facts_loudly() {
593    let mut r = rig_with(&[Ok("hi"), Ok(r#"["one"]"#), Ok("hi"), Ok(r#"["two"]"#)], Knobs { memory_limit: 1, ..Knobs::default() });
594    let a = r.conv.respond(hear("a"));
595    assert_eq!(r.reflector.run(a.reflect.as_ref()).len(), 1);
596    let b = r.conv.respond(hear("b"));
597    assert!(r.reflector.run(b.reflect.as_ref()).is_empty());
598    assert!(events(&r).iter().any(|e| matches!(e, Event::NotRemembered { why, .. } if why == "memory is full")));
599}
600
601#[test]
602fn something_that_means_the_same_as_a_known_fact_is_not_filed_twice() {
603    let mut r = rig_with(
604        &[Ok("hi"), Ok(r#"["her toy bunny is named Biscuit"]"#)],
605        Knobs { facts: vec![fact(1, "Biscuit is the name of her toy bunny")], ..Knobs::default() },
606    );
607    let reply = r.conv.respond(hear("hello"));
608    // Same words in another order: the fake embedder puts them on top of each other.
609    assert!(r.reflector.run(reply.reflect.as_ref()).is_empty());
610    assert_eq!(r.facts.lock().unwrap().len(), 1);
611}
612
613#[test]
614fn a_model_that_answers_nonsense_for_remembering_remembers_nothing() {
615    let mut r = rig(&[Ok("hi"), Ok("I do not know what you mean")]);
616    let reply = r.conv.respond(hear("hello"));
617    assert!(r.reflector.run(reply.reflect.as_ref()).is_empty());
618}
619
620#[test]
621fn facts_filed_while_the_embedder_was_down_get_their_embedding_later() {
622    let mut f = fact(1, "Biscuit is her toy bunny");
623    f.embedding.clear();
624    let r = rig_with(&[Ok("x")], Knobs { facts: vec![f], ..Knobs::default() });
625    r.reflector.run(None);
626    assert!(!r.facts.lock().unwrap()[0].embedding.is_empty());
627}
628
629#[test]
630fn a_parent_forgetting_a_fact_is_logged() {
631    let mut r = rig_with(&[Ok("x")], Knobs { facts: vec![fact(1, "A fact")], ..Knobs::default() });
632    assert!(r.conv.forget(1));
633    assert!(r.facts.lock().unwrap().is_empty());
634    assert!(events(&r).contains(&Event::Forgot { fact: "A fact".into() }));
635    assert!(!r.conv.forget(1));
636}
637
638// ---- recalling --------------------------------------------------------------
639
640fn crowded() -> Vec<Fact> {
641    vec![
642        fact(1, "Biscuit is her toy bunny, brown with long ears"),
643        fact(2, "Her teacher at school is Mrs Pine"),
644        fact(3, "She loves purple and drawing rainbows"),
645        fact(4, "Nana lives by the lake and has a boat"),
646        fact(5, "She is learning to ride a bicycle"),
647        fact(6, "Her friend Omar has a green dinosaur"),
648    ]
649}
650
651#[test]
652fn a_small_memory_is_all_remembered_without_any_searching() {
653    let mut r = rig_with(&[Ok("ok")], Knobs { facts: crowded()[..3].to_vec(), ..Knobs::default() });
654    r.conv.respond(hear("hello"));
655    assert_eq!(*r.embedder.calls.lock().unwrap(), 0);
656    assert!(r.seen.lock().unwrap()[0].0.contains("Mrs Pine"));
657}
658
659#[test]
660fn a_big_memory_is_searched_by_meaning_and_only_the_best_few_are_used() {
661    let mut r = rig_with(&[Ok("ok")], Knobs { facts: crowded(), ..Knobs::default() });
662    r.conv.respond(hear("tell me about Biscuit the toy bunny with long ears"));
663    let system = r.seen.lock().unwrap()[0].0.clone();
664    assert!(system.contains("Biscuit is her toy bunny"), "{system}");
665    assert!(!system.contains("Mrs Pine"), "an unrelated fact is left out: {system}");
666    assert!(events(&r).iter().any(|e| matches!(e, Event::Recalled { facts } if facts.iter().any(|f| f.contains("Biscuit")))));
667}
668
669#[test]
670fn the_ranker_gets_the_last_word_on_order_and_its_failure_changes_nothing_important() {
671    // Every fact shares a word with the message, so all are candidates and the ranker has a real choice.
672    let favourites: Vec<Fact> = [
673        "favorite toy is Biscuit the bunny", "favorite color is purple", "favorite food is pancakes",
674        "favorite song is the rainbow song", "favorite game is hide and seek", "favorite place is Nana's lake",
675    ]
676    .iter()
677    .enumerate()
678    .map(|(i, t)| fact(i as u64 + 1, t))
679    .collect();
680    let mut r = rig_with(&[Ok("ok"), Ok("ok")], Knobs { facts: favourites, min_cosine: 0.0, ..Knobs::default() });
681    r.conv.respond(hear("favorite pancakes favorite food"));
682    assert!(*r.ranker.calls.lock().unwrap() >= 1, "more candidates than fit, so the ranker was asked");
683    assert!(r.seen.lock().unwrap().last().unwrap().0.contains("pancakes"), "the ranker put the best fit first");
684    *r.ranker.down.lock().unwrap() = true;
685    r.conv.respond(hear("favorite pancakes favorite food"));
686    // Still answered, still using memories, in cosine order.
687    assert!(r.seen.lock().unwrap().last().unwrap().0.contains("favorite"));
688}
689
690#[test]
691fn with_the_embedder_down_the_most_recent_memories_are_used_and_the_chat_goes_on() {
692    let mut r = rig_with(&[Ok("ok")], Knobs { facts: crowded(), ..Knobs::default() });
693    *r.embedder.down.lock().unwrap() = true;
694    let reply = r.conv.respond(hear("anything at all"));
695    assert_eq!(reply.outcome, Outcome::Answered);
696    let system = r.seen.lock().unwrap()[0].0.clone();
697    assert!(system.contains("Omar") && system.contains("bicycle"), "newest facts: {system}");
698    assert!(!system.contains("Mrs Pine"));
699}
700
701#[test]
702fn a_fact_she_put_away_is_never_remembered_in_a_small_or_a_big_memory() {
703    for all in [crowded()[..3].to_vec(), crowded()] {
704        let mut away = all.clone();
705        away[1].visibility = away[1].visibility.hidden_after(10); // Mrs Pine
706        away[0].visibility = away[0].visibility.hidden_after(10); // Biscuit
707        let mut r = rig_with(&[Ok("ok")], Knobs { facts: away, ..Knobs::default() });
708        r.conv.respond(hear("tell me about Biscuit the toy bunny with long ears and Mrs Pine"));
709        let system = r.seen.lock().unwrap()[0].0.clone();
710        assert!(!system.contains("Mrs Pine") && !system.contains("Biscuit is her"), "{system}");
711        assert!(events(&r).iter().all(|e| !matches!(e, Event::Recalled { facts } if facts.iter().any(|f| f.contains("Pine") || f.contains("Biscuit")))));
712    }
713}
714
715#[test]
716fn a_fact_she_put_away_is_still_there_for_the_parents_and_still_counts_as_known() {
717    let mut r = rig_with(&[Ok("x")], Knobs { facts: vec![fact(1, "Biscuit is her toy bunny")], ..Knobs::default() });
718    r.facts.lock().unwrap()[0].visibility = Visibility::default().hidden_after(5);
719    assert_eq!(r.conv.facts().len(), 1, "the parents' list keeps it");
720    assert!(r.conv.facts()[0].visibility.is_hidden());
721}
722
723// ---- the picture of a fact ----------------------------------------------------
724
725struct Chooser {
726    answers: Mutex<Vec<Result<Option<&'static str>, &'static str>>>,
727    asked: Mutex<usize>,
728}
729
730impl whiskers_core::IconChooser for Chooser {
731    fn choose(&self, _text: &str) -> Result<Option<whiskers_core::IconId>, whiskers_core::IconError> {
732        *self.asked.lock().unwrap() += 1;
733        match self.answers.lock().unwrap().remove(0) {
734            Ok(n) => Ok(n.map(|n| whiskers_core::IconId::new(n).unwrap())),
735            Err(e) => Err(whiskers_core::IconError(e.into())),
736        }
737    }
738}
739
740fn with_chooser(r: &mut Rig, answers: Vec<Result<Option<&'static str>, &'static str>>) -> Arc<Chooser> {
741    let c = Arc::new(Chooser { answers: Mutex::new(answers), asked: Mutex::new(0) });
742    r.reflector.icons = c.clone();
743    c
744}
745
746#[test]
747fn facts_without_a_picture_are_given_one_a_few_at_a_time_and_a_failure_leaves_them_as_they_were() {
748    let facts: Vec<Fact> = (1..=5).map(|i| fact(i, &format!("thing number {i}"))).collect();
749    let mut r = rig_with(&[Ok("x")], Knobs { facts, ..Knobs::default() });
750    let c = with_chooser(&mut r, vec![Ok(Some("pumpkin")), Ok(None), Err("down"), Ok(Some("cat")), Ok(None), Ok(None)]);
751    r.reflector.give_icons();
752    let icons: Vec<Option<String>> = r.facts.lock().unwrap().iter().map(|f| f.icon.as_ref().map(|i| i.to_string())).collect();
753    assert_eq!(icons, [Some("pumpkin".to_string()), None, None, None, None], "three asked, and a failure stops the pass");
754    assert_eq!(*c.asked.lock().unwrap(), 3);
755    // Fact 2 was answered "none fits": it is not asked about again. Fact 3 failed: it is, with the next two.
756    r.reflector.give_icons();
757    let icons: Vec<Option<String>> = r.facts.lock().unwrap().iter().map(|f| f.icon.as_ref().map(|i| i.to_string())).collect();
758    assert_eq!(icons[2].as_deref(), Some("cat"));
759    assert_eq!(icons[1], None);
760    assert_eq!(*c.asked.lock().unwrap(), 6, "the failed one and the two after it; the answered one was not asked again");
761}
762
763#[test]
764fn a_device_with_no_service_just_keeps_facts_without_pictures() {
765    let r = rig_with(&[Ok("x")], Knobs { facts: vec![fact(1, "a thing")], ..Knobs::default() });
766    r.reflector.give_icons();
767    assert!(r.facts.lock().unwrap()[0].icon.is_none());
768}
769
770// ---- the child profile ------------------------------------------------------------
771
772fn ada() -> Child {
773    Child { name: ChildName::new("Ada").unwrap(), age: Age::new(6).unwrap() }
774}
775
776#[test]
777fn with_no_profile_the_guard_is_asked_for_the_youngest_age_and_the_wording_is_neutral() {
778    let mut r = simple(Ok("Hello!"));
779    assert!(r.conv.greet().say.as_str().starts_with("Hi there!"));
780    r.conv.respond(hear("hi"));
781    let ages = r.ages.lock().unwrap().clone();
782    assert!(!ages.is_empty() && ages.iter().all(|a| *a == Age::YOUNGEST), "{ages:?}");
783    let system = r.seen.lock().unwrap()[0].0.clone();
784    assert!(system.contains("a 3-year-old child") && !system.contains("Ada"), "{system}");
785}
786
787#[test]
788fn a_profile_sets_the_greeting_the_prompt_and_the_age_the_guard_judges_for() {
789    let mut r = simple(Ok("Hello!"));
790    r.profile.set(Some(ada()));
791    assert!(r.conv.greet().say.as_str().starts_with("Hi Ada!"));
792    r.conv.respond(hear("hi"));
793    let ages = r.ages.lock().unwrap().clone();
794    assert!(!ages.is_empty() && ages.iter().all(|a| a.years() == 6), "{ages:?}");
795    let system = r.seen.lock().unwrap()[0].0.clone();
796    assert!(system.contains("Ada, a 6-year-old child"), "{system}");
797}
798
799#[test]
800fn a_change_to_the_profile_applies_from_the_next_turn() {
801    let mut r = simple(Ok("Hello!"));
802    r.profile.set(Some(ada()));
803    r.conv.respond(hear("one"));
804    r.profile.set(None);
805    r.conv.respond(hear("two"));
806    let seen = r.seen.lock().unwrap();
807    assert!(seen[0].0.contains("Ada") && !seen[1].0.contains("Ada"));
808}
809
810#[test]
811fn clearing_the_profile_makes_the_guard_stricter_not_looser() {
812    let mut r = simple(Ok("Hello!"));
813    r.profile.set(Some(Child { name: ChildName::new("Ada").unwrap(), age: Age::new(11).unwrap() }));
814    r.conv.respond(hear("one"));
815    r.profile.set(None);
816    r.conv.respond(hear("two"));
817    let ages = r.ages.lock().unwrap().clone();
818    assert!(ages.first().unwrap() > ages.last().unwrap());
819}
820
821// ---- the journey the website draws -------------------------------------------
822
823/// Runs the real pipeline into the way `cause` ends `step`, and says how the turn ended. A step or
824/// a cause the journey gains that has no scenario here fails the test, so the page's diagram
825/// cannot show an ending the code does not have.
826fn outcome_of(step: &str, cause: &str) -> Outcome {
827    let picture = || Image { media_type: "image/jpeg".into(), bytes: vec![1] };
828    match (step, cause) {
829        ("keep-pictures", "a picture cannot be kept") => {
830            let mut r = rig_with(&[Ok("x")], Knobs { pictures_broken: true, ..Knobs::default() });
831            r.conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
832        }
833        ("log-heard", "the log cannot be written") => {
834            rig_with(&[Ok("x")], Knobs { log_broken: true, ..Knobs::default() }).conv.respond(hear("hi")).outcome
835        }
836        ("check-words", "refused") => simple(Ok("x")).conv.respond(hear("BAD thing")).outcome,
837        ("check-words", "refused: she is hurt, unsafe or asked to keep a secret") => simple(Ok("x")).conv.respond(hear("HELP")).outcome,
838        ("check-words", "Jev cannot be reached") => simple(Ok("x")).conv.respond(hear("DOWN")).outcome,
839        ("describe-picture", "the model cannot be reached") => {
840            rig(&[Err("down")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
841        }
842        ("check-picture", "refused") => rig(&[Ok("A BAD scene"), Ok("x")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome,
843        ("check-picture", "refused: she is hurt, unsafe or asked to keep a secret") => {
844            rig(&[Ok("A HELP sign"), Ok("x")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
845        }
846        ("check-picture", "Jev cannot be reached") => {
847            rig(&[Ok("DOWN"), Ok("x")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
848        }
849        ("model-writes", "the model cannot be reached") => simple(Err("down")).conv.respond(hear("hi")).outcome,
850        ("model-writes", "the allowance for thinking is used up") => resting_turn(),
851        ("check-answer", "refused") => simple(Ok("a BAD answer")).conv.respond(hear("hi")).outcome,
852        ("check-answer", "Jev cannot be reached") => simple(Ok("DOWN")).conv.respond(hear("hi")).outcome,
853        other => panic!("the journey has an ending with no scenario: {other:?}"),
854    }
855}
856
857/// A turn whose model says the allowance is used up.
858fn resting_turn() -> Outcome {
859    struct Resting;
860    impl Model for Resting {
861        fn complete(&self, _: &str, _: &[Turn]) -> Result<String, ModelError> {
862            Err(ModelError::resting("allowance used"))
863        }
864    }
865    let model: Arc<dyn Model> = Arc::new(Resting);
866    let mut conv = Conversation::new(
867        Config { system_prompt: None, history_turns: 4, greet_after_ms: 30 * 60 * 1000 },
868        Parts {
869            model,
870            guard: Arc::new(FakeGuard(Arc::default())),
871            log: SharedLog::new(Box::new(MemLog(Arc::default(), false))),
872            pictures: Box::new(MemPictures(Arc::default(), false)),
873            memory: SharedMemory::new(Box::new(MemMemory(Arc::default()))),
874            chat: SharedChat::open(Box::new(MemChat(Arc::default()))).unwrap(),
875            recall: Arc::new(Recall::new(Arc::new(FakeEmbedder { down: Mutex::new(false), calls: Mutex::new(0) }), Arc::new(FakeRanker { down: Mutex::new(false), calls: Mutex::new(0) }))),
876            clock: Arc::new(TestClock(Mutex::new(1_000))),
877            profile: SharedProfile::default(),
878        },
879    );
880    conv.respond(hear("hi")).outcome
881}
882
883#[test]
884fn every_ending_the_journey_lists_is_one_the_pipeline_reaches() {
885    use whiskers_core::journey::TURN;
886    let mut run = 0;
887    for step in TURN {
888        for exit in step.exits {
889            assert_eq!(outcome_of(step.id, exit.cause), Outcome::Fallback(exit.then), "{} / {}", step.id, exit.cause);
890            run += 1;
891        }
892    }
893    assert_eq!(run, 13, "{run} endings run");
894}
895
896#[test]
897fn the_journey_says_what_the_cat_says() {
898    // The fixed lines are the journey's to show: each is a line a turn really speaks.
899    let reply = simple(Ok("x")).conv.respond(hear("BAD"));
900    assert_eq!(reply.say.as_str(), Fallback::ChildRefused.line());
901}