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}