What Whiskers says when it says hello, as a pure function of a [GreetingContext].
The greeting is where the child is told she is talking to an AI (Anthropic's guidance for
services that minors use), so every variant says "robot cat" and "not a person". A repeat is
shorter, never silent about it. The text is fixed, not a model output, so the disclosure cannot
be dropped by a prompt a parent wrote; the name comes only from the [Audience], and with none
the wording is neutral.
Which variant is chosen, first match wins (thresholds are the named constants below):
| Variant | When |
|---|---|
[Variant::FirstEver] | she has never been here: the full hello |
[Variant::MissedYou] | away at least [LONG_AWAY_MS] (3 days): "I missed you" |
[Variant::Morning] | the first time on a new local day, before [MORNING_ENDS] (noon): "Good morning" |
[Variant::School] | a weekday, at or after [SCHOOL_FROM] (16:00), and she has not been here since then: "How was school today?" |
[Variant::Again] | she was already here earlier the same local day: "Hi again" |
[Variant::Hello] | the rest (a new day, after noon, not a school afternoon): a short hello |
Whether a greeting is due at all is not decided here: Conversation::greeting_due (first time
ever, or 30 minutes quiet).
Time. The core keeps UTC. The shell supplies her local day number and minute of day, exactly
as it does for allowed(day, minuteOfDay) ([LocalMoment]); there is no second time source. To
find the local day of the last activity the offset from UTC is read off the two clocks the core
already has (the local moment against now_ms) and rounded to a quarter hour, which every real
zone is a multiple of. That uses today's offset for an earlier moment, so across a clock change
"already here today" can be wrong for activity within an hour of local midnight.
30use crate::profile::Audience;
Away this long or longer: "I missed you". Three days.
36pub const LONG_AWAY_MS: u64 = 3 * 24 * 60 * MINUTE_MS;
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;
From this minute of the day on a weekday, the greeting asks about school. 16:00.
40pub const SCHOOL_FROM: u16 = 16 * 60;
Her local time, as the shell supplies it: the day number (changes at local midnight; day 0 is 1970-01-01, a Thursday) and the minute of that day.
50impl LocalMoment {
UTC read as local time, for a host with no time zone (the command line, tests).
How far this local moment is from UTC at now_ms, in minutes, read off the two clocks and rounded to a quarter
hour (which every real zone is a multiple of). None if that is further than any zone is, which means the two
clocks do not describe the same moment and nothing should be concluded from them.
The weekday of a day number (day 0 is a Thursday).
What the greeting depends on.
How long since she was last here; None if she never has been.
108impl GreetingContext {
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 }
The minute of the local day she was last here, when that was earlier today.
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}
Said at the start of a chat. Always tells her it is a robot cat and not a person.
The words of one variant, for the audience. What [greeting] says once it has chosen, and what a cache of
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}
Day 4 is Monday 1970-01-05; day 9 is Saturday.
A context built straight from fields, local time as UTC.
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
1970-01-05 (a Monday) 12:00 UTC.
355 const NOON_MONDAY_UTC: u64 = (MONDAY as u64 * 24 + 12) * HOUR;
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}