whiskers.git / crates / whiskers-core / src / greeting.rs

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):

VariantWhen
[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;
32const MINUTE_MS: u64 = 60_000;
33const DAY_MINUTES: i64 = 24 * 60;

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.

44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct LocalMoment {
46    pub day: u32,
47    pub minute_of_day: u16,
48}
50impl LocalMoment {

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    }

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.

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

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    }
92    pub fn is_school_day(self) -> bool {
93        !matches!(self, Weekday::Saturday | Weekday::Sunday)
94    }
95}

What the greeting depends on.

98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub struct GreetingContext {

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,

She was already here earlier the same local day.

105    pub earlier_today: bool,
106}
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.

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

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}
170impl Variant {
171    pub const ALL: [Variant; 6] = [Variant::FirstEver, Variant::MissedYou, Variant::Morning, Variant::School, Variant::Again, Variant::Hello];
172}

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

Day 4 is Monday 1970-01-05; day 9 is Saturday.

216    const MONDAY: u32 = 4;
217    const SATURDAY: u32 = 9;
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    }

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