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 {

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}

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}
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}

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}

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}
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    };
256    Rig { conv, reflector, log, seen, pictures, facts, chat, embedder, ranker, clock, ages, profile }
257}
258
259fn rig(replies: &[Result<&str, &str>]) -> Rig {
260    rig_with(replies, Knobs::default())
261}
262
263fn simple(model: Result<&str, &str>) -> Rig {
264    rig(&[model])
265}
266
267fn events(r: &Rig) -> Vec<Event> {
268    r.log.lock().unwrap().iter().map(|e| e.event.clone()).collect()
269}
270
271fn hear(text: &str) -> Heard {
272    Heard::words(text)
273}
274
275fn fact(id: u64, text: &str) -> Fact {
276    Fact {
277        id,
278        text: text.into(),
279        learned_at_ms: id,
280        kind: Kind::Other,
281        who: vec![],
282        place: None,
283        when: None,
284        pictures: vec![],
285        embedding: FakeEmbedder::vector(text),
286        gid: format!("g{id}"),
287        visibility: whiskers_core::Visibility::default(),
288        icon: None,
289    }
290}

---- the pipeline ----------------------------------------------------------

294#[test]
295fn an_allowed_turn_is_answered_and_fully_logged() {
296    let mut r = simple(Ok("Meow, hello!"));
297    let reply = r.conv.respond(hear("hi"));
298    assert_eq!(reply.outcome, Outcome::Answered);
299    assert_eq!(reply.say.as_str(), "Meow, hello!");
300    assert_eq!(
301        events(&r),
302        vec![
303            Event::Heard { text: "hi".into(), pictures: vec![] },
304            Event::Guarded { direction: Direction::FromChild, verdict: Verdict::Allow },
305            Event::ModelWrote { text: "Meow, hello!".into() },
306            Event::Guarded { direction: Direction::ToChild, verdict: Verdict::Allow },
307            Event::Said { text: "Meow, hello!".into(), outcome: Outcome::Answered },
308        ]
309    );
310}
312#[test]
313fn a_refused_question_never_reaches_the_model_and_offers_nothing_to_remember() {
314    let mut r = simple(Ok("x"));
315    let reply = r.conv.respond(hear("BAD thing"));
316    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ChildRefused));
317    assert_eq!(reply.reflect, None);
318    assert!(r.seen.lock().unwrap().is_empty());
319    assert!(matches!(events(&r).last(), Some(Event::Said { outcome: Outcome::Fallback(Fallback::ChildRefused), .. })));
320}
321
322#[test]
323fn something_that_means_she_is_unsafe_sends_her_to_a_grown_up() {
324    let mut r = simple(Ok("x"));
325    let reply = r.conv.respond(hear("HELP"));
326    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::NeedsAGrownUp));
327    assert!(reply.say.as_str().contains("grown-up"));
328    assert!(r.seen.lock().unwrap().is_empty());
329}
330
331#[test]
332fn a_refused_answer_is_logged_for_the_parents_but_not_spoken() {
333    let mut r = simple(Ok("a BAD answer"));
334    let reply = r.conv.respond(hear("hi"));
335    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::AnswerRefused));
336    assert!(!reply.say.as_str().contains("BAD"));
337    assert!(events(&r).contains(&Event::ModelWrote { text: "a BAD answer".into() }));
338}
339
340#[test]
341fn an_unreachable_guard_is_a_refusal_not_an_allow() {
342    let mut r = simple(Ok("x"));
343    let reply = r.conv.respond(hear("DOWN"));
344    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::GuardUnavailable));
345    assert!(r.seen.lock().unwrap().is_empty());
346}
347
348#[test]
349fn a_model_error_ends_in_a_line_about_the_cloud_and_never_shows_the_error() {
350    let mut r = simple(Err("HTTP 500 stack trace"));
351    let reply = r.conv.respond(hear("hi"));
352    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ModelUnavailable));
353    assert!(!reply.say.as_str().contains("500"));
354    assert!(reply.say.as_str().contains("thinking cloud"));
355    assert!(events(&r).iter().any(|e| matches!(e, Event::Failed { stage: FailureStage::Model, .. })));
356}
357
358#[test]
359fn a_broken_log_sends_nothing_anywhere() {
360    let mut r = rig_with(&[Ok("x")], Knobs { log_broken: true, ..Knobs::default() });
361    let reply = r.conv.respond(hear("hi"));
362    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::LogUnavailable));
363    assert!(r.seen.lock().unwrap().is_empty());
364}
365
366#[test]
367fn only_fully_allowed_turns_enter_the_chat_and_the_model_is_shown_a_window() {
368    let mut r = simple(Ok("ok"));
369    r.conv.respond(hear("one"));
370    r.conv.respond(hear("BAD two"));
371    r.conv.respond(hear("three"));
372    r.conv.respond(hear("four"));
373    let seen = r.seen.lock().unwrap();
374    let last = &seen.last().unwrap().1;
375    assert_eq!(last.len(), 4, "history_turns is 4");
376    assert!(last.iter().all(|t| !t.text.contains("BAD")));
377    assert_eq!(last.last().unwrap().text, "four");
378}
379
380#[test]
381fn the_greeting_is_fixed_logged_and_never_asks_the_model() {
382    let mut r = simple(Ok("x"));
383    let reply = r.conv.greet();
384    assert_eq!(reply.say.as_str(), greeting(&Audience::new(None)));
385    assert!(r.seen.lock().unwrap().is_empty());
386    assert!(matches!(events(&r).as_slice(), [Event::Greeted { .. }]));
387}

---- pictures ---------------------------------------------------------------

391#[test]
392fn pictures_are_kept_first_logged_shown_once_and_not_repeated_in_history() {
393    let mut r = simple(Ok("A bunny!"));
394    let img = Image { media_type: "image/jpeg".into(), bytes: vec![9, 9] };
395    r.conv.respond(Heard { text: "what is this".into(), pictures: vec![img.clone()] });
396    assert_eq!(r.pictures.lock().unwrap().as_slice(), &[img.clone()]);
397    assert_eq!(events(&r)[0], Event::Heard { text: "what is this".into(), pictures: vec![PictureId("p1".into())] });
398    // Call 0 is the separate look at the picture; call 1 is the cat's reply.
399    assert_eq!(r.seen.lock().unwrap()[1].1.last().unwrap().pictures, vec![img]);
400    r.conv.respond(hear("again"));
401    assert!(r.seen.lock().unwrap()[2].1.iter().all(|t| t.pictures.is_empty()));
402}
404#[test]
405fn pictures_are_described_and_the_description_is_judged_before_the_cat_sees_them() {
406    let mut r = rig(&[Ok("A rabbit on grass."), Ok("What a lovely bunny!")]);
407    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
408    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
409    assert_eq!(reply.outcome, Outcome::Answered);
410    let seen = r.seen.lock().unwrap();
411    assert_eq!(seen.len(), 2, "one look, one reply");
412    assert!(seen[0].0.contains("-year-old child shows a toy cat"), "the look has its own prompt, not the persona");
413    drop(seen);
414    assert!(events(&r).contains(&Event::PictureSeen { description: "A rabbit on grass.".into() }));
415    let reflect = reply.reflect.unwrap();
416    assert_eq!(reflect.description.as_deref(), Some("A rabbit on grass."));
417    assert_eq!(reflect.pictures, vec![PictureId("p1".into())]);
418}
419
420#[test]
421fn a_picture_whose_description_is_refused_never_reaches_the_cat() {
422    let mut r = rig(&[Ok("A BAD scene"), Ok("never said")]);
423    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
424    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
425    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ChildRefused));
426    assert_eq!(r.seen.lock().unwrap().len(), 1, "only the look was made");
427}
428
429#[test]
430fn a_picture_that_shows_she_is_unsafe_sends_her_to_a_grown_up() {
431    let mut r = rig(&[Ok("A child with a HELP sign"), Ok("never said")]);
432    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
433    let reply = r.conv.respond(Heard { text: String::new(), pictures: vec![img] });
434    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::NeedsAGrownUp));
435}
436
437#[test]
438fn a_failed_look_keeps_the_picture_from_the_cat() {
439    let mut r = rig(&[Err("down")]);
440    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
441    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
442    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::ModelUnavailable));
443}
444
445#[test]
446fn a_picture_with_no_words_still_gets_a_guarded_answer() {
447    let mut r = simple(Ok("A cute bunny!"));
448    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
449    let reply = r.conv.respond(Heard { text: String::new(), pictures: vec![img] });
450    assert_eq!(reply.outcome, Outcome::Answered);
451    assert!(events(&r).contains(&Event::Guarded { direction: Direction::ToChild, verdict: Verdict::Allow }));
452}
453
454#[test]
455fn a_picture_that_cannot_be_kept_is_not_sent() {
456    let mut r = rig_with(&[Ok("x")], Knobs { pictures_broken: true, ..Knobs::default() });
457    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
458    let reply = r.conv.respond(Heard { text: "look".into(), pictures: vec![img] });
459    assert_eq!(reply.outcome, Outcome::Fallback(Fallback::LogUnavailable));
460    assert!(r.seen.lock().unwrap().is_empty());
461}

---- the persistent chat ----------------------------------------------------

465#[test]
466fn the_chat_persists_and_a_new_conversation_picks_it_up() {
467    let mut r = simple(Ok("Nice to meet you!"));
468    r.conv.respond(hear("my name is Ada"));
469    let saved = r.chat.lock().unwrap().clone();
470    assert_eq!(saved.turns.len(), 2);
471    assert!(saved.last_active_ms > 0);
472    // A relaunch: a fresh rig over the saved chat sees the earlier turns.
473    let mut again = rig_with(&[Ok("Welcome back")], Knobs { chat: saved, ..Knobs::default() });
474    again.conv.respond(hear("hello again"));
475    let seen = again.seen.lock().unwrap();
476    assert_eq!(seen[0].1.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(), ["my name is Ada", "Nice to meet you!", "hello again"]);
477}
479#[test]
480fn it_says_hello_the_first_time_and_after_a_long_quiet_but_not_on_a_quick_relaunch() {
481    let mut r = simple(Ok("x"));
482    assert!(r.conv.greeting_due(), "first ever");
483    r.conv.greet();
484    assert!(!r.conv.greeting_due(), "just greeted");
485    *r.clock.0.lock().unwrap() += 29 * 60 * 1000;
486    assert!(!r.conv.greeting_due());
487    *r.clock.0.lock().unwrap() += 2 * 60 * 1000;
488    assert!(r.conv.greeting_due(), "after half an hour of quiet");
489}
490
491#[test]
492fn a_long_chat_is_folded_into_its_summary_off_to_the_side() {
493    let mut r = rig_with(
494        &[Ok("ok"), Ok("ok"), Ok("ok"), Ok("ok"), Ok("Ada likes bunnies; they talked about Biscuit.")],
495        Knobs { compress_after: 6, keep_turns: 2, ..Knobs::default() },
496    );
497    for t in ["one", "two", "three", "four"] {
498        r.conv.respond(hear(t));
499    }
500    assert_eq!(r.chat.lock().unwrap().turns.len(), 8);
501    r.reflector.run(None);
502    let chat = r.chat.lock().unwrap().clone();
503    assert_eq!(chat.turns.len(), 2, "only the most recent turns stay word for word");
504    assert_eq!(chat.summary, "Ada likes bunnies; they talked about Biscuit.");
505    assert!(events(&r).contains(&Event::Compressed { turns: 6 }));
506    // The next answer is written knowing the summary.
507    drop(chat);
508    r.conv.respond(hear("five"));
509    assert!(r.seen.lock().unwrap().last().unwrap().0.contains("they talked about Biscuit"));
510}
511
512#[test]
513fn a_summary_that_fails_changes_nothing_and_is_logged() {
514    let mut r = rig_with(&[Ok("ok"), Ok("ok"), Err("down")], Knobs { compress_after: 2, keep_turns: 2, ..Knobs::default() });
515    r.conv.respond(hear("one"));
516    r.conv.respond(hear("two"));
517    r.reflector.run(None);
518    assert_eq!(r.chat.lock().unwrap().turns.len(), 4);
519    assert!(r.chat.lock().unwrap().summary.is_empty());
520    assert!(events(&r).iter().any(|e| matches!(e, Event::MemoryFailed { .. })));
521}
522
523#[test]
524fn a_summary_the_guard_refuses_is_never_applied() {
525    let mut r = rig_with(&[Ok("ok"), Ok("ok"), Ok("BAD summary")], Knobs { compress_after: 2, keep_turns: 2, ..Knobs::default() });
526    r.conv.respond(hear("one"));
527    r.conv.respond(hear("two"));
528    r.reflector.run(None);
529    assert_eq!(r.chat.lock().unwrap().turns.len(), 4, "nothing was folded away");
530    assert!(r.chat.lock().unwrap().summary.is_empty());
531    assert!(events(&r).iter().any(|e| matches!(e, Event::MemoryFailed { error } if error.contains("refused"))));
532}
533
534#[test]
535fn an_exchange_is_offered_again_only_when_the_model_could_not_be_asked() {
536    let mut r = rig(&[Ok("Hi!"), Err("down"), Ok("[]")]);
537    let reply = r.conv.respond(hear("my bunny is Biscuit"));
538    assert!(r.reflector.reflect(reply.reflect.as_ref()).retry, "the model was down");
539    assert!(!r.reflector.reflect(reply.reflect.as_ref()).retry, "an answer, even an empty one, is done");
540}

---- remembering ------------------------------------------------------------

544#[test]
545fn what_she_said_is_filed_with_its_kind_names_place_and_time_and_found_next_time() {
546    let json = r#"[{"text":"Biscuit is her toy bunny, brown with long ears","kind":"toy","who":["Biscuit"],"place":"at Nana's","when":"yesterday"}]"#;
547    let mut r = rig(&[Ok("Bunny sounds lovely!"), Ok(json), Ok("ok")]);
548    let reply = r.conv.respond(hear("my bunny Biscuit is brown"));
549    let kept = r.reflector.run(reply.reflect.as_ref());
550    assert_eq!(kept.len(), 1);
551    let f = &kept[0];
552    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())));
553    assert!(!f.embedding.is_empty(), "filed with its embedding");
554    assert!(events(&r).contains(&Event::Remembered { fact: "Biscuit is her toy bunny, brown with long ears".into() }));
555    r.conv.respond(hear("hello again"));
556    let seen = r.seen.lock().unwrap();
557    assert!(seen.last().unwrap().0.contains("Biscuit is her toy bunny"));
558    assert!(seen.last().unwrap().0.contains("where: at Nana's"));
559}
561#[test]
562fn the_picture_that_goes_with_a_name_is_filed_with_it() {
563    let json = r#"[{"text":"Nana has gray hair and glasses","kind":"person","who":["Nana"]}]"#;
564    let mut r = rig(&[Ok("A kind looking lady."), Ok("That is Nana!"), Ok(json)]);
565    let img = Image { media_type: "image/jpeg".into(), bytes: vec![1] };
566    let reply = r.conv.respond(Heard { text: "this is my Nana".into(), pictures: vec![img] });
567    let kept = r.reflector.run(reply.reflect.as_ref());
568    assert_eq!(kept[0].pictures, vec![PictureId("p1".into())]);
569    let asked = r.seen.lock().unwrap().last().unwrap().1.last().unwrap().text.clone();
570    assert!(asked.contains("A kind looking lady."), "the memory is told what the picture looked like: {asked}");
571}
572
573#[test]
574fn old_style_plain_string_facts_still_parse() {
575    let mut r = rig(&[Ok("hi"), Ok(r#"["A fact"]"#)]);
576    let reply = r.conv.respond(hear("hello"));
577    assert_eq!(r.reflector.run(reply.reflect.as_ref()).len(), 1);
578}
579
580#[test]
581fn a_fact_the_guard_refuses_is_not_kept_and_the_parents_can_see_why() {
582    let mut r = rig(&[Ok("hi"), Ok(r#"["something BAD"]"#)]);
583    let reply = r.conv.respond(hear("hello"));
584    assert!(r.reflector.run(reply.reflect.as_ref()).is_empty());
585    assert!(r.facts.lock().unwrap().is_empty());
586    assert!(events(&r).iter().any(|e| matches!(e, Event::NotRemembered { why, .. } if why.starts_with("guard"))));
587}
588
589#[test]
590fn a_full_memory_refuses_new_facts_loudly() {
591    let mut r = rig_with(&[Ok("hi"), Ok(r#"["one"]"#), Ok("hi"), Ok(r#"["two"]"#)], Knobs { memory_limit: 1, ..Knobs::default() });
592    let a = r.conv.respond(hear("a"));
593    assert_eq!(r.reflector.run(a.reflect.as_ref()).len(), 1);
594    let b = r.conv.respond(hear("b"));
595    assert!(r.reflector.run(b.reflect.as_ref()).is_empty());
596    assert!(events(&r).iter().any(|e| matches!(e, Event::NotRemembered { why, .. } if why == "memory is full")));
597}
598
599#[test]
600fn something_that_means_the_same_as_a_known_fact_is_not_filed_twice() {
601    let mut r = rig_with(
602        &[Ok("hi"), Ok(r#"["her toy bunny is named Biscuit"]"#)],
603        Knobs { facts: vec![fact(1, "Biscuit is the name of her toy bunny")], ..Knobs::default() },
604    );
605    let reply = r.conv.respond(hear("hello"));
606    // Same words in another order: the fake embedder puts them on top of each other.
607    assert!(r.reflector.run(reply.reflect.as_ref()).is_empty());
608    assert_eq!(r.facts.lock().unwrap().len(), 1);
609}
610
611#[test]
612fn a_model_that_answers_nonsense_for_remembering_remembers_nothing() {
613    let mut r = rig(&[Ok("hi"), Ok("I do not know what you mean")]);
614    let reply = r.conv.respond(hear("hello"));
615    assert!(r.reflector.run(reply.reflect.as_ref()).is_empty());
616}
617
618#[test]
619fn facts_filed_while_the_embedder_was_down_get_their_embedding_later() {
620    let mut f = fact(1, "Biscuit is her toy bunny");
621    f.embedding.clear();
622    let r = rig_with(&[Ok("x")], Knobs { facts: vec![f], ..Knobs::default() });
623    r.reflector.run(None);
624    assert!(!r.facts.lock().unwrap()[0].embedding.is_empty());
625}
626
627#[test]
628fn a_parent_forgetting_a_fact_is_logged() {
629    let mut r = rig_with(&[Ok("x")], Knobs { facts: vec![fact(1, "A fact")], ..Knobs::default() });
630    assert!(r.conv.forget(1));
631    assert!(r.facts.lock().unwrap().is_empty());
632    assert!(events(&r).contains(&Event::Forgot { fact: "A fact".into() }));
633    assert!(!r.conv.forget(1));
634}

---- recalling --------------------------------------------------------------

638fn crowded() -> Vec<Fact> {
639    vec![
640        fact(1, "Biscuit is her toy bunny, brown with long ears"),
641        fact(2, "Her teacher at school is Mrs Pine"),
642        fact(3, "She loves purple and drawing rainbows"),
643        fact(4, "Nana lives by the lake and has a boat"),
644        fact(5, "She is learning to ride a bicycle"),
645        fact(6, "Her friend Omar has a green dinosaur"),
646    ]
647}
649#[test]
650fn a_small_memory_is_all_remembered_without_any_searching() {
651    let mut r = rig_with(&[Ok("ok")], Knobs { facts: crowded()[..3].to_vec(), ..Knobs::default() });
652    r.conv.respond(hear("hello"));
653    assert_eq!(*r.embedder.calls.lock().unwrap(), 0);
654    assert!(r.seen.lock().unwrap()[0].0.contains("Mrs Pine"));
655}
656
657#[test]
658fn a_big_memory_is_searched_by_meaning_and_only_the_best_few_are_used() {
659    let mut r = rig_with(&[Ok("ok")], Knobs { facts: crowded(), ..Knobs::default() });
660    r.conv.respond(hear("tell me about Biscuit the toy bunny with long ears"));
661    let system = r.seen.lock().unwrap()[0].0.clone();
662    assert!(system.contains("Biscuit is her toy bunny"), "{system}");
663    assert!(!system.contains("Mrs Pine"), "an unrelated fact is left out: {system}");
664    assert!(events(&r).iter().any(|e| matches!(e, Event::Recalled { facts } if facts.iter().any(|f| f.contains("Biscuit")))));
665}
666
667#[test]
668fn the_ranker_gets_the_last_word_on_order_and_its_failure_changes_nothing_important() {
669    // Every fact shares a word with the message, so all are candidates and the ranker has a real choice.
670    let favourites: Vec<Fact> = [
671        "favorite toy is Biscuit the bunny", "favorite color is purple", "favorite food is pancakes",
672        "favorite song is the rainbow song", "favorite game is hide and seek", "favorite place is Nana's lake",
673    ]
674    .iter()
675    .enumerate()
676    .map(|(i, t)| fact(i as u64 + 1, t))
677    .collect();
678    let mut r = rig_with(&[Ok("ok"), Ok("ok")], Knobs { facts: favourites, min_cosine: 0.0, ..Knobs::default() });
679    r.conv.respond(hear("favorite pancakes favorite food"));
680    assert!(*r.ranker.calls.lock().unwrap() >= 1, "more candidates than fit, so the ranker was asked");
681    assert!(r.seen.lock().unwrap().last().unwrap().0.contains("pancakes"), "the ranker put the best fit first");
682    *r.ranker.down.lock().unwrap() = true;
683    r.conv.respond(hear("favorite pancakes favorite food"));
684    // Still answered, still using memories, in cosine order.
685    assert!(r.seen.lock().unwrap().last().unwrap().0.contains("favorite"));
686}
687
688#[test]
689fn with_the_embedder_down_the_most_recent_memories_are_used_and_the_chat_goes_on() {
690    let mut r = rig_with(&[Ok("ok")], Knobs { facts: crowded(), ..Knobs::default() });
691    *r.embedder.down.lock().unwrap() = true;
692    let reply = r.conv.respond(hear("anything at all"));
693    assert_eq!(reply.outcome, Outcome::Answered);
694    let system = r.seen.lock().unwrap()[0].0.clone();
695    assert!(system.contains("Omar") && system.contains("bicycle"), "newest facts: {system}");
696    assert!(!system.contains("Mrs Pine"));
697}
698
699#[test]
700fn a_fact_she_put_away_is_never_remembered_in_a_small_or_a_big_memory() {
701    for all in [crowded()[..3].to_vec(), crowded()] {
702        let mut away = all.clone();
703        away[1].visibility = away[1].visibility.hidden_after(10); // Mrs Pine
704        away[0].visibility = away[0].visibility.hidden_after(10); // Biscuit
705        let mut r = rig_with(&[Ok("ok")], Knobs { facts: away, ..Knobs::default() });
706        r.conv.respond(hear("tell me about Biscuit the toy bunny with long ears and Mrs Pine"));
707        let system = r.seen.lock().unwrap()[0].0.clone();
708        assert!(!system.contains("Mrs Pine") && !system.contains("Biscuit is her"), "{system}");
709        assert!(events(&r).iter().all(|e| !matches!(e, Event::Recalled { facts } if facts.iter().any(|f| f.contains("Pine") || f.contains("Biscuit")))));
710    }
711}
712
713#[test]
714fn a_fact_she_put_away_is_still_there_for_the_parents_and_still_counts_as_known() {
715    let mut r = rig_with(&[Ok("x")], Knobs { facts: vec![fact(1, "Biscuit is her toy bunny")], ..Knobs::default() });
716    r.facts.lock().unwrap()[0].visibility = Visibility::default().hidden_after(5);
717    assert_eq!(r.conv.facts().len(), 1, "the parents' list keeps it");
718    assert!(r.conv.facts()[0].visibility.is_hidden());
719}

---- the child profile ------------------------------------------------------------

723fn ada() -> Child {
724    Child { name: ChildName::new("Ada").unwrap(), age: Age::new(6).unwrap() }
725}
727#[test]
728fn with_no_profile_the_guard_is_asked_for_the_youngest_age_and_the_wording_is_neutral() {
729    let mut r = simple(Ok("Hello!"));
730    assert!(r.conv.greet().say.as_str().starts_with("Hi there!"));
731    r.conv.respond(hear("hi"));
732    let ages = r.ages.lock().unwrap().clone();
733    assert!(!ages.is_empty() && ages.iter().all(|a| *a == Age::YOUNGEST), "{ages:?}");
734    let system = r.seen.lock().unwrap()[0].0.clone();
735    assert!(system.contains("a 3-year-old child") && !system.contains("Ada"), "{system}");
736}
737
738#[test]
739fn a_profile_sets_the_greeting_the_prompt_and_the_age_the_guard_judges_for() {
740    let mut r = simple(Ok("Hello!"));
741    r.profile.set(Some(ada()));
742    assert!(r.conv.greet().say.as_str().starts_with("Hi Ada!"));
743    r.conv.respond(hear("hi"));
744    let ages = r.ages.lock().unwrap().clone();
745    assert!(!ages.is_empty() && ages.iter().all(|a| a.years() == 6), "{ages:?}");
746    let system = r.seen.lock().unwrap()[0].0.clone();
747    assert!(system.contains("Ada, a 6-year-old child"), "{system}");
748}
749
750#[test]
751fn a_change_to_the_profile_applies_from_the_next_turn() {
752    let mut r = simple(Ok("Hello!"));
753    r.profile.set(Some(ada()));
754    r.conv.respond(hear("one"));
755    r.profile.set(None);
756    r.conv.respond(hear("two"));
757    let seen = r.seen.lock().unwrap();
758    assert!(seen[0].0.contains("Ada") && !seen[1].0.contains("Ada"));
759}
760
761#[test]
762fn clearing_the_profile_makes_the_guard_stricter_not_looser() {
763    let mut r = simple(Ok("Hello!"));
764    r.profile.set(Some(Child { name: ChildName::new("Ada").unwrap(), age: Age::new(11).unwrap() }));
765    r.conv.respond(hear("one"));
766    r.profile.set(None);
767    r.conv.respond(hear("two"));
768    let ages = r.ages.lock().unwrap().clone();
769    assert!(ages.first().unwrap() > ages.last().unwrap());
770}

---- the journey the website draws -------------------------------------------

Runs the real pipeline into the way cause ends step, and says how the turn ended. A step or a cause the journey gains that has no scenario here fails the test, so the page's diagram cannot show an ending the code does not have.

777fn outcome_of(step: &str, cause: &str) -> Outcome {
778    let picture = || Image { media_type: "image/jpeg".into(), bytes: vec![1] };
779    match (step, cause) {
780        ("keep-pictures", "a picture cannot be kept") => {
781            let mut r = rig_with(&[Ok("x")], Knobs { pictures_broken: true, ..Knobs::default() });
782            r.conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
783        }
784        ("log-heard", "the log cannot be written") => {
785            rig_with(&[Ok("x")], Knobs { log_broken: true, ..Knobs::default() }).conv.respond(hear("hi")).outcome
786        }
787        ("check-words", "refused") => simple(Ok("x")).conv.respond(hear("BAD thing")).outcome,
788        ("check-words", "refused: she is hurt, unsafe or asked to keep a secret") => simple(Ok("x")).conv.respond(hear("HELP")).outcome,
789        ("check-words", "Jev cannot be reached") => simple(Ok("x")).conv.respond(hear("DOWN")).outcome,
790        ("describe-picture", "the model cannot be reached") => {
791            rig(&[Err("down")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
792        }
793        ("check-picture", "refused") => rig(&[Ok("A BAD scene"), Ok("x")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome,
794        ("check-picture", "refused: she is hurt, unsafe or asked to keep a secret") => {
795            rig(&[Ok("A HELP sign"), Ok("x")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
796        }
797        ("check-picture", "Jev cannot be reached") => {
798            rig(&[Ok("DOWN"), Ok("x")]).conv.respond(Heard { text: "look".into(), pictures: vec![picture()] }).outcome
799        }
800        ("model-writes", "the model cannot be reached") => simple(Err("down")).conv.respond(hear("hi")).outcome,
801        ("model-writes", "the allowance for thinking is used up") => resting_turn(),
802        ("check-answer", "refused") => simple(Ok("a BAD answer")).conv.respond(hear("hi")).outcome,
803        ("check-answer", "Jev cannot be reached") => simple(Ok("DOWN")).conv.respond(hear("hi")).outcome,
804        other => panic!("the journey has an ending with no scenario: {other:?}"),
805    }
806}

A turn whose model says the allowance is used up.

809fn resting_turn() -> Outcome {
810    struct Resting;
811    impl Model for Resting {
812        fn complete(&self, _: &str, _: &[Turn]) -> Result<String, ModelError> {
813            Err(ModelError::resting("allowance used"))
814        }
815    }
816    let model: Arc<dyn Model> = Arc::new(Resting);
817    let mut conv = Conversation::new(
818        Config { system_prompt: None, history_turns: 4, greet_after_ms: 30 * 60 * 1000 },
819        Parts {
820            model,
821            guard: Arc::new(FakeGuard(Arc::default())),
822            log: SharedLog::new(Box::new(MemLog(Arc::default(), false))),
823            pictures: Box::new(MemPictures(Arc::default(), false)),
824            memory: SharedMemory::new(Box::new(MemMemory(Arc::default()))),
825            chat: SharedChat::open(Box::new(MemChat(Arc::default()))).unwrap(),
826            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) }))),
827            clock: Arc::new(TestClock(Mutex::new(1_000))),
828            profile: SharedProfile::default(),
829        },
830    );
831    conv.respond(hear("hi")).outcome
832}
834#[test]
835fn every_ending_the_journey_lists_is_one_the_pipeline_reaches() {
836    use whiskers_core::journey::TURN;
837    let mut run = 0;
838    for step in TURN {
839        for exit in step.exits {
840            assert_eq!(outcome_of(step.id, exit.cause), Outcome::Fallback(exit.then), "{} / {}", step.id, exit.cause);
841            run += 1;
842        }
843    }
844    assert_eq!(run, 13, "{run} endings run");
845}
846
847#[test]
848fn the_journey_says_what_the_cat_says() {
849    // The fixed lines are the journey's to show: each is a line a turn really speaks.
850    let reply = simple(Ok("x")).conv.respond(hear("BAD"));
851    assert_eq!(reply.say.as_str(), Fallback::ChildRefused.line());
852}