whiskers.git / crates / whiskers-core / src / greeting.rs
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}