1//! What Whiskers says when it says hello, as a pure function of a [`GreetingContext`]. 2//! 3//! The greeting is where the child is told she is talking to an AI (Anthropic's guidance for 4//! services that minors use), so **every variant says "robot cat" and "not a person"**. A repeat is 5//! shorter, never silent about it. The text is fixed, not a model output, so the disclosure cannot 6//! be dropped by a prompt a parent wrote; the name comes only from the [`Audience`], and with none 7//! the wording is neutral. 8//! 9//! Which variant is chosen, first match wins (thresholds are the named constants below): 10//! 11//! | Variant | When | 12//! |---|---| 13//! | [`Variant::FirstEver`] | she has never been here: the full hello | 14//! | [`Variant::MissedYou`] | away at least [`LONG_AWAY_MS`] (3 days): "I missed you" | 15//! | [`Variant::Morning`] | the first time on a new local day, before [`MORNING_ENDS`] (noon): "Good morning" | 16//! | [`Variant::School`] | a weekday, at or after [`SCHOOL_FROM`] (16:00), and she has not been here since then: "How was school today?" | 17//! | [`Variant::Again`] | she was already here earlier the same local day: "Hi again" | 18//! | [`Variant::Hello`] | the rest (a new day, after noon, not a school afternoon): a short hello | 19//! 20//! Whether a greeting is due at all is not decided here: `Conversation::greeting_due` (first time 21//! ever, or 30 minutes quiet). 22//! 23//! **Time.** The core keeps UTC. The shell supplies her local day number and minute of day, exactly 24//! as it does for `allowed(day, minuteOfDay)` ([`LocalMoment`]); there is no second time source. To 25//! find the local day of the last activity the offset from UTC is read off the two clocks the core 26//! already has (the local moment against `now_ms`) and rounded to a quarter hour, which every real 27//! zone is a multiple of. That uses today's offset for an earlier moment, so across a clock change 28//! "already here today" can be wrong for activity within an hour of local midnight. 29 30use crate::profile::Audience; 31 32const MINUTE_MS: u64 = 60_000; 33const DAY_MINUTES: i64 = 24 * 60; 34 35/// Away this long or longer: "I missed you". Three days. 36pub const LONG_AWAY_MS: u64 = 3 * 24 * 60 * MINUTE_MS; 37/// A first open on a new local day before this minute of the day is "Good morning". Noon. 38pub const MORNING_ENDS: u16 = 12 * 60; 39/// From this minute of the day on a weekday, the greeting asks about school. 16:00. 40pub const SCHOOL_FROM: u16 = 16 * 60; 41 42/// Her local time, as the shell supplies it: the day number (changes at local midnight; day 0 is 43/// 1970-01-01, a Thursday) and the minute of that day. 44#[derive(Clone, Copy, Debug, PartialEq, Eq)] 45pub struct LocalMoment { 46 pub day: u32, 47 pub minute_of_day: u16, 48} 49 50impl LocalMoment { 51 /// UTC read as local time, for a host with no time zone (the command line, tests). 52 pub fn utc(now_ms: u64) -> LocalMoment { 53 let minutes = now_ms / MINUTE_MS; 54 LocalMoment { day: (minutes / DAY_MINUTES as u64) as u32, minute_of_day: (minutes % DAY_MINUTES as u64) as u16 } 55 } 56 57 /// How far this local moment is from UTC at `now_ms`, in minutes, read off the two clocks and rounded to a quarter 58 /// hour (which every real zone is a multiple of). `None` if that is further than any zone is, which means the two 59 /// clocks do not describe the same moment and nothing should be concluded from them. 60 pub fn utc_offset_minutes(self, now_ms: u64) -> Option<i32> { 61 let local = self.day as i64 * DAY_MINUTES + self.minute_of_day as i64; 62 let offset = round_to_quarter_hour(local - (now_ms / MINUTE_MS) as i64); 63 i32::try_from(offset).ok().filter(|m| crate::limits::valid_offset(*m)) 64 } 65} 66 67#[derive(Clone, Copy, Debug, PartialEq, Eq)] 68pub enum Weekday { 69 Monday, 70 Tuesday, 71 Wednesday, 72 Thursday, 73 Friday, 74 Saturday, 75 Sunday, 76} 77 78impl Weekday { 79 /// The weekday of a day number (day 0 is a Thursday). 80 pub fn of_day(day: u32) -> Weekday { 81 match (day + 3) % 7 { 82 0 => Weekday::Monday, 83 1 => Weekday::Tuesday, 84 2 => Weekday::Wednesday, 85 3 => Weekday::Thursday, 86 4 => Weekday::Friday, 87 5 => Weekday::Saturday, 88 _ => Weekday::Sunday, 89 } 90 } 91 92 pub fn is_school_day(self) -> bool { 93 !matches!(self, Weekday::Saturday | Weekday::Sunday) 94 } 95} 96 97/// What the greeting depends on. 98#[derive(Clone, Copy, Debug, PartialEq, Eq)] 99pub struct GreetingContext { 100 /// How long since she was last here; `None` if she never has been. 101 pub since_last_ms: Option<u64>, 102 pub weekday: Weekday, 103 pub minute_of_day: u16, 104 /// She was already here earlier the same local day. 105 pub earlier_today: bool, 106} 107 108impl GreetingContext { 109 /// From the chat's `last_active_ms` (0 = never), the core's clock and her local moment. 110 pub fn new(last_active_ms: u64, now_ms: u64, here: LocalMoment) -> GreetingContext { 111 if last_active_ms == 0 { 112 return GreetingContext { since_last_ms: None, weekday: Weekday::of_day(here.day), minute_of_day: here.minute_of_day, earlier_today: false }; 113 } 114 let local_now = here.day as i64 * DAY_MINUTES + here.minute_of_day as i64; 115 let offset = round_to_quarter_hour(local_now - (now_ms / MINUTE_MS) as i64); 116 let last_day = ((last_active_ms / MINUTE_MS) as i64 + offset).div_euclid(DAY_MINUTES); 117 GreetingContext { 118 since_last_ms: Some(now_ms.saturating_sub(last_active_ms)), 119 weekday: Weekday::of_day(here.day), 120 minute_of_day: here.minute_of_day, 121 earlier_today: last_day == here.day as i64, 122 } 123 } 124 125 /// The minute of the local day she was last here, when that was earlier today. 126 fn last_minute_today(&self) -> Option<u16> { 127 let gap_minutes = (self.since_last_ms? / MINUTE_MS) as u16; 128 self.earlier_today.then(|| self.minute_of_day.saturating_sub(gap_minutes)) 129 } 130} 131 132fn round_to_quarter_hour(minutes: i64) -> i64 { 133 (minutes as f64 / 15.0).round() as i64 * 15 134} 135 136#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 137pub enum Variant { 138 FirstEver, 139 MissedYou, 140 Morning, 141 School, 142 Again, 143 Hello, 144} 145 146impl Variant { 147 pub fn choose(c: &GreetingContext) -> Variant { 148 let Some(gap) = c.since_last_ms else { return Variant::FirstEver }; 149 if gap >= LONG_AWAY_MS { 150 Variant::MissedYou 151 } else if !c.earlier_today && c.minute_of_day < MORNING_ENDS { 152 Variant::Morning 153 } else if c.weekday.is_school_day() && c.minute_of_day >= SCHOOL_FROM && c.last_minute_today().is_none_or(|m| m < SCHOOL_FROM) { 154 Variant::School 155 } else if c.earlier_today { 156 Variant::Again 157 } else { 158 Variant::Hello 159 } 160 } 161} 162 163/// Said at the start of a chat. Always tells her it is a robot cat and not a person. 164pub fn greeting(a: &Audience, c: &GreetingContext) -> String { 165 let variant = Variant::choose(c); 166 ::log::debug!("greeting variant {variant:?}"); 167 say(a, variant) 168} 169 170impl Variant { 171 pub const ALL: [Variant; 6] = [Variant::FirstEver, Variant::MissedYou, Variant::Morning, Variant::School, Variant::Again, Variant::Hello]; 172} 173 174/// The words of one variant, for the audience. What [`greeting`] says once it has chosen, and what a cache of 175/// fixed lines holds. 176pub fn say(a: &Audience, variant: Variant) -> String { 177 let name = a.name().map(|n| n.as_str()); 178 let with = |named: &dyn Fn(&str) -> String, neutral: &str| name.map_or_else(|| neutral.to_owned(), |n| named(n)); 179 match variant { 180 Variant::FirstEver => format!( 181 "{} I'm Whiskers, a robot cat. I'm not a real cat and I'm not a person, \ 182but I love to chat with you! What do you want to talk about?", 183 with(&|n| format!("Hi {n}!"), "Hi there!") 184 ), 185 Variant::MissedYou => format!( 186 "{} I'm Whiskers, the robot cat, not a person. What do you want to talk about?", 187 with(&|n| format!("Hi {n}, I missed you!"), "Hi there, I missed you!") 188 ), 189 Variant::Morning => format!( 190 "{} It's Whiskers, the robot cat. I'm not a person. What do you want to talk about today?", 191 with(&|n| format!("Good morning, {n}!"), "Good morning!") 192 ), 193 Variant::School => format!( 194 "{} It's Whiskers, the robot cat, and I'm not a person, but I love hearing about your day.", 195 with(&|n| format!("Hi {n}! How was school today?"), "Hi there! How was school today?") 196 ), 197 Variant::Again => format!( 198 "{} It's Whiskers, the robot cat, not a person. What shall we talk about?", 199 with(&|n| format!("Hi again, {n}!"), "Hi again!") 200 ), 201 Variant::Hello => format!( 202 "{} It's Whiskers, the robot cat. I'm not a person. What do you want to talk about?", 203 with(&|n| format!("Hi {n}!"), "Hi there!") 204 ), 205 } 206} 207 208#[cfg(test)] 209mod tests { 210 use super::*; 211 use crate::profile::{Age, Child, ChildName}; 212 213 const MIN: u64 = MINUTE_MS; 214 const HOUR: u64 = 60 * MIN; 215 /// Day 4 is Monday 1970-01-05; day 9 is Saturday. 216 const MONDAY: u32 = 4; 217 const SATURDAY: u32 = 9; 218 219 fn ada() -> Audience { 220 Audience::new(Some(&Child { name: ChildName::new("Ada").unwrap(), age: Age::new(6).unwrap() })) 221 } 222 223 fn nobody() -> Audience { 224 Audience::new(None) 225 } 226 227 /// A context built straight from fields, local time as UTC. 228 fn ctx(since: Option<u64>, day: u32, minute: u16, earlier_today: bool) -> GreetingContext { 229 GreetingContext { since_last_ms: since, weekday: Weekday::of_day(day), minute_of_day: minute, earlier_today } 230 } 231 232 fn at(h: u16, m: u16) -> u16 { 233 h * 60 + m 234 } 235 236 #[test] 237 fn day_zero_is_a_thursday_and_the_week_wraps() { 238 assert_eq!(Weekday::of_day(0), Weekday::Thursday); 239 assert_eq!(Weekday::of_day(MONDAY), Weekday::Monday); 240 assert_eq!(Weekday::of_day(SATURDAY), Weekday::Saturday); 241 assert_eq!(Weekday::of_day(10), Weekday::Sunday); 242 assert_eq!(Weekday::of_day(11), Weekday::Monday); 243 } 244 245 #[test] 246 fn every_variant_says_robot_cat_and_not_a_person_with_and_without_a_name() { 247 let contexts = [ 248 ctx(None, MONDAY, at(9, 0), false), 249 ctx(Some(LONG_AWAY_MS), MONDAY, at(9, 0), false), 250 ctx(Some(10 * HOUR), MONDAY, at(9, 0), false), 251 ctx(Some(2 * HOUR), MONDAY, at(17, 0), false), 252 ctx(Some(HOUR), MONDAY, at(10, 0), true), 253 ctx(Some(10 * HOUR), SATURDAY, at(14, 0), false), 254 ]; 255 let mut seen = std::collections::HashSet::new(); 256 for c in &contexts { 257 seen.insert(Variant::choose(c)); 258 for a in [ada(), nobody()] { 259 let g = greeting(&a, c); 260 assert!(g.contains("robot cat") && g.contains("not a person"), "{c:?}: {g}"); 261 } 262 } 263 assert_eq!(seen.len(), 6, "the contexts reach every variant"); 264 } 265 266 #[test] 267 fn a_repeat_is_shorter_than_the_first_hello() { 268 let first = greeting(&ada(), &ctx(None, MONDAY, at(9, 0), false)); 269 let again = greeting(&ada(), &ctx(Some(HOUR), MONDAY, at(10, 0), true)); 270 assert!(again.len() < first.len()); 271 } 272 273 #[test] 274 fn the_name_comes_from_the_audience_and_without_one_the_wording_is_neutral() { 275 let cases = [ 276 (ctx(None, MONDAY, at(9, 0), false), "Hi Ada!", "Hi there!"), 277 (ctx(Some(LONG_AWAY_MS), MONDAY, at(9, 0), false), "Hi Ada, I missed you!", "Hi there, I missed you!"), 278 (ctx(Some(10 * HOUR), MONDAY, at(9, 0), false), "Good morning, Ada!", "Good morning!"), 279 (ctx(Some(2 * HOUR), MONDAY, at(17, 0), false), "Hi Ada! How was school today?", "Hi there! How was school today?"), 280 (ctx(Some(HOUR), MONDAY, at(10, 0), true), "Hi again, Ada!", "Hi again!"), 281 (ctx(Some(10 * HOUR), SATURDAY, at(14, 0), false), "Hi Ada!", "Hi there!"), 282 ]; 283 for (c, named, neutral) in cases { 284 assert!(greeting(&ada(), &c).starts_with(named), "{c:?}"); 285 let n = greeting(&nobody(), &c); 286 assert!(n.starts_with(neutral) && !n.contains("Ada"), "{c:?}: {n}"); 287 } 288 } 289 290 #[test] 291 fn never_been_here_is_the_full_hello() { 292 assert_eq!(Variant::choose(&ctx(None, MONDAY, at(9, 0), false)), Variant::FirstEver); 293 assert_eq!(Variant::choose(&ctx(None, SATURDAY, at(20, 0), false)), Variant::FirstEver); 294 } 295 296 #[test] 297 fn the_long_away_boundary_is_three_days() { 298 let v = |gap| Variant::choose(&ctx(Some(gap), SATURDAY, at(14, 0), false)); 299 assert_eq!(LONG_AWAY_MS, 3 * 24 * HOUR); 300 assert_eq!(v(LONG_AWAY_MS), Variant::MissedYou); 301 assert_eq!(v(LONG_AWAY_MS - 1), Variant::Hello); 302 // Being away a long time beats the morning and the school question. 303 assert_eq!(Variant::choose(&ctx(Some(LONG_AWAY_MS), MONDAY, at(9, 0), false)), Variant::MissedYou); 304 assert_eq!(Variant::choose(&ctx(Some(LONG_AWAY_MS), MONDAY, at(17, 0), false)), Variant::MissedYou); 305 } 306 307 #[test] 308 fn the_morning_boundary_is_noon_and_needs_a_new_local_day() { 309 let v = |minute, earlier| Variant::choose(&ctx(Some(10 * HOUR), SATURDAY, minute, earlier)); 310 assert_eq!(MORNING_ENDS, at(12, 0)); 311 assert_eq!(v(at(11, 59), false), Variant::Morning); 312 assert_eq!(v(at(12, 0), false), Variant::Hello); 313 assert_eq!(v(at(0, 0), false), Variant::Morning); 314 assert_eq!(v(at(11, 59), true), Variant::Again, "already here this morning"); 315 } 316 317 #[test] 318 fn the_school_boundary_is_four_oclock_on_a_weekday() { 319 let v = |day, minute| Variant::choose(&ctx(Some(2 * HOUR), day, minute, false)); 320 assert_eq!(SCHOOL_FROM, at(16, 0)); 321 assert_eq!(v(MONDAY, at(15, 59)), Variant::Hello); 322 assert_eq!(v(MONDAY, at(16, 0)), Variant::School); 323 assert_eq!(v(MONDAY + 4, at(16, 0)), Variant::School, "Friday"); 324 assert_eq!(v(SATURDAY, at(16, 0)), Variant::Hello); 325 assert_eq!(v(SATURDAY + 1, at(16, 0)), Variant::Hello, "Sunday"); 326 } 327 328 #[test] 329 fn school_is_asked_once_an_afternoon_and_a_return_the_same_afternoon_is_hi_again() { 330 // Here at 15:00, back at 16:30: not yet asked. 331 assert_eq!(Variant::choose(&ctx(Some(90 * MIN), MONDAY, at(16, 30), true)), Variant::School); 332 // Here at 16:10, back at 16:50: asked already. 333 assert_eq!(Variant::choose(&ctx(Some(40 * MIN), MONDAY, at(16, 50), true)), Variant::Again); 334 // Here at exactly 16:00 counts as asked. 335 assert_eq!(Variant::choose(&ctx(Some(30 * MIN), MONDAY, at(16, 30), true)), Variant::Again); 336 // Here at 15:59 has not. 337 assert_eq!(Variant::choose(&ctx(Some(31 * MIN), MONDAY, at(16, 30), true)), Variant::School); 338 } 339 340 #[test] 341 fn a_short_gap_the_same_day_is_hi_again() { 342 assert_eq!(Variant::choose(&ctx(Some(30 * MIN), MONDAY, at(10, 0), true)), Variant::Again); 343 assert_eq!(Variant::choose(&ctx(Some(3 * HOUR), SATURDAY, at(15, 0), true)), Variant::Again); 344 } 345 346 #[test] 347 fn a_new_day_after_noon_that_is_not_a_school_afternoon_is_a_short_hello() { 348 assert_eq!(Variant::choose(&ctx(Some(20 * HOUR), SATURDAY, at(14, 0), false)), Variant::Hello); 349 assert_eq!(Variant::choose(&ctx(Some(20 * HOUR), MONDAY, at(13, 0), false)), Variant::Hello); 350 } 351 352 // ## local time from the two clocks 353 354 /// 1970-01-05 (a Monday) 12:00 UTC. 355 const NOON_MONDAY_UTC: u64 = (MONDAY as u64 * 24 + 12) * HOUR; 356 357 #[test] 358 fn utc_read_as_local_is_the_day_and_minute() { 359 assert_eq!(LocalMoment::utc(NOON_MONDAY_UTC + 5 * MIN), LocalMoment { day: MONDAY, minute_of_day: at(12, 5) }); 360 } 361 362 #[test] 363 fn with_no_last_activity_there_is_no_gap_and_not_earlier_today() { 364 let c = GreetingContext::new(0, NOON_MONDAY_UTC, LocalMoment::utc(NOON_MONDAY_UTC)); 365 assert_eq!(c.since_last_ms, None); 366 assert!(!c.earlier_today); 367 } 368 369 #[test] 370 fn earlier_today_is_judged_in_her_local_day_not_utc() { 371 // She is five hours behind UTC (UTC-5): it is 08:00 on Monday locally when it is 13:00 UTC. 372 let now = NOON_MONDAY_UTC + HOUR; 373 let here = LocalMoment { day: MONDAY, minute_of_day: at(8, 0) }; 374 // Last here 06:30 UTC Monday = 01:30 local Monday: earlier today. 375 assert!(GreetingContext::new(NOON_MONDAY_UTC - 5 * HOUR - 30 * MIN, now, here).earlier_today); 376 // Last here 03:00 UTC Monday = 22:00 local Sunday: the night before, though the same UTC day. 377 let c = GreetingContext::new(NOON_MONDAY_UTC - 9 * HOUR, now, here); 378 assert!(!c.earlier_today); 379 assert_eq!(Variant::choose(&c), Variant::Morning); 380 } 381 382 #[test] 383 fn a_few_seconds_between_the_two_clocks_do_not_move_the_offset() { 384 let now = NOON_MONDAY_UTC + 59_000; 385 let here = LocalMoment { day: MONDAY, minute_of_day: at(12, 1) }; 386 assert!(GreetingContext::new(NOON_MONDAY_UTC - HOUR, now, here).earlier_today); 387 assert!(!GreetingContext::new(NOON_MONDAY_UTC - 13 * HOUR, now, here).earlier_today); 388 } 389 390 #[test] 391 fn a_zone_ahead_of_utc_works_too() { 392 // UTC+10: 00:30 on Monday locally is 14:30 on Sunday (day 3) in UTC. 393 let now = (3 * 24 + 14) * HOUR + 30 * MIN; 394 let here = LocalMoment { day: MONDAY, minute_of_day: at(0, 30) }; 395 assert!(GreetingContext::new(now - 20 * MIN, now, here).earlier_today); 396 assert!(!GreetingContext::new(now - 40 * MIN, now, here).earlier_today, "23:50 local is the day before"); 397 } 398}