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