Forgetting is true everywhere the memory, or what it was learned from, could be: the prompt built after it holds none of it, what waited to be thought about cannot teach it back, and the parents' log keeps that the talk happened and not what was said. What waits survives the app being stopped.

5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
9use whiskers_core::*;
10use whiskers_engine::*;
11use whiskers_store::{SqliteChat, SqliteLog, SqliteMemory, SqlitePictures};
12
13const FACT: &str = "Biscuit is a purple bunny FORGOTTEN-FACT-4471";
14const FIRST: &str = "my bunny Biscuit is purple SAID-FIRST-5582";
15const AGAIN: &str = "Biscuit hopped again SAID-AGAIN-6693";
16const AFTER: &str = "what shall we play now";
17const OLD_SUMMARY: &str = "SUMMARY-SECRET-7704 she has a purple bunny";
18const OLD_TURN: &str = "OLD-TURN-SECRET-8815";

A model that records what it is asked, answers a turn with a greeting, and proposes the memory when asked what to remember (unless it is down).

22struct Brain {
23    seen: Mutex<Vec<(String, Vec<Turn>)>>,
24    down: AtomicBool,
25    teaches: &'static str,

Stops the next call of a kind ("chat" or "remember") where it is, says it has, and goes on when told to.

27    hold: Mutex<Option<Hold>>,
28}
30struct Hold {
31    kind: &'static str,
32    started: std::sync::mpsc::Sender<()>,
33    release: std::sync::mpsc::Receiver<()>,
34}
35
36impl Brain {

Arms a hold on the next call of kind: the receiver says it is in the call, the sender lets it go.

38    fn hold_next(&self, kind: &'static str) -> (std::sync::mpsc::Receiver<()>, std::sync::mpsc::Sender<()>) {
39        let (started_tx, started_rx) = std::sync::mpsc::channel();
40        let (release_tx, release_rx) = std::sync::mpsc::channel();
41        *self.hold.lock().unwrap() = Some(Hold { kind, started: started_tx, release: release_rx });
42        (started_rx, release_tx)
43    }
45    fn pause_if_held(&self, kind: &str) {
46        let hold = {
47            let mut g = self.hold.lock().unwrap();
48            if g.as_ref().is_some_and(|h| h.kind == kind) { g.take() } else { None }
49        };
50        if let Some(h) = hold {
51            h.started.send(()).unwrap();
52            h.release.recv().unwrap();
53        }
54    }
55}
56
57impl Model for Brain {
58    fn complete(&self, system: &str, history: &[Turn]) -> Result<String, ModelError> {
59        self.seen.lock().unwrap().push((system.to_owned(), history.to_vec()));
60        let asked = history.last().map(|t| t.text.as_str()).unwrap_or_default();
61        self.pause_if_held(if asked.starts_with("Already known:") { "remember" } else if asked.starts_with("Summary so far:") { "summary" } else { "chat" });
62        if asked.starts_with("Already known:") {
63            if self.down.load(Ordering::SeqCst) {
64                return Err(ModelError::new("down"));
65            }
66            return Ok(self.teaches.to_owned());
67        }
68        if asked.starts_with("Summary so far:") {
69            return Ok("A summary.".into());
70        }
71        Ok("What a lovely thing to say!".into())
72    }
73}
74
75struct AllowAll;
76impl Guard for AllowAll {
77    fn check(&self, _d: Direction, _a: Age, _t: &str) -> Result<Verdict, GuardError> {
78        Ok(Verdict::Allow)
79    }
80}
81struct NoEmbedding;
82impl Embedder for NoEmbedding {
83    fn embed(&self, _t: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
84        Err(EmbedError("none in tests".into()))
85    }
86}
87struct NoRanking;
88impl Ranker for NoRanking {
89    fn rank(&self, _q: &str, _c: &[String]) -> Result<Vec<f32>, RankError> {
90        Err(RankError("none in tests".into()))
91    }
92}
93
94fn dir(name: &str) -> PathBuf {
95    let d = std::env::temp_dir().join(format!("whiskers-engine-forget-{name}-{}", std::process::id()));
96    let _ = std::fs::remove_dir_all(&d);
97    std::fs::create_dir_all(&d).unwrap();
98    d
99}
100
101fn engine(d: &Path, brain: Arc<Brain>) -> Engine {
102    let cfg = EngineConfig { data_dir: d.to_owned(), gateway_url: String::new(), guard_url: String::new(), model: String::new(), system_prompt: None };
103    let store = Store::open(&d.join(DATABASE)).unwrap();
104    let device = whiskers_engine::device_id(d).unwrap();
105    let parts = Parts {
106        model: brain,
107        guard: Arc::new(AllowAll),
108        log: SharedLog::new(Box::new(SqliteLog::new(store.clone(), &device))),
109        pictures: Box::new(SqlitePictures::new(store.clone())),
110        memory: SharedMemory::new(Box::new(SqliteMemory::open(store.clone(), &device).unwrap())),
111        chat: SharedChat::open(Box::new(SqliteChat::new(store.clone()))).unwrap(),
112        recall: Arc::new(Recall::new(Arc::new(NoEmbedding), Arc::new(NoRanking))),
113        clock: Arc::new(SystemClock),
114        profile: SharedProfile::default(),
115    };
116    Engine::with_parts(cfg, store, parts, Arc::new(NoEmbedding), Box::new(Brain { seen: Mutex::default(), down: AtomicBool::new(false), teaches: "[]", hold: Mutex::default() })).unwrap()
117}
118
119fn brain(teaches: &'static str) -> Arc<Brain> {
120    Arc::new(Brain { seen: Mutex::default(), down: AtomicBool::new(false), teaches, hold: Mutex::default() })
121}
122
123fn everything_in_the_last_prompt(b: &Brain) -> String {
124    let seen = b.seen.lock().unwrap();
125    let (system, history) = seen.iter().rev().find(|(_, h)| !h.last().is_some_and(|t| t.text.starts_with("Already known:") || t.text.starts_with("Summary so far:"))).unwrap();
126    format!("{system}\n{}", history.iter().map(|t| t.text.as_str()).collect::<Vec<_>>().join("\n"))
127}
128
129fn on_disk(d: &Path) -> Vec<u8> {
130    let mut all = std::fs::read(d.join(DATABASE)).unwrap();
131    all.extend(std::fs::read(d.join(format!("{DATABASE}-wal"))).unwrap_or_default());
132    all
133}
134
135fn contains(haystack: &[u8], needle: &str) -> bool {
136    haystack.windows(needle.len()).any(|w| w == needle.as_bytes())
137}

A chat that already has a summary and recent turns, as a device that has been used for a while does.

140fn with_an_old_chat(d: &Path) {
141    let store = Store::open(&d.join(DATABASE)).unwrap();
142    let mut chat = SqliteChat::new(store);
143    ChatStore::save(
144        &mut chat,
145        &ChatState {
146            summary: OLD_SUMMARY.into(),
147            turns: vec![ChatTurn { speaker: Speaker::Child, text: OLD_TURN.into() }, ChatTurn { speaker: Speaker::Whiskers, text: "Lovely!".into() }],
148            last_active_ms: SystemClock.now_ms(),
149            version: 3,
150            scrubbed: vec![],
151        },
152    )
153    .unwrap();
154}
156#[test]
157fn a_prompt_built_after_a_forgetting_holds_nothing_of_the_memory_or_of_what_it_was_learned_from() {
158    let d = dir("prompt");
159    with_an_old_chat(&d);
160    let b = brain(r#"["Biscuit is a purple bunny FORGOTTEN-FACT-4471"]"#);
161    let e = engine(&d, b.clone());
162
163    // She tells it, and it is learned.
164    assert_eq!(e.hear(FIRST, vec![]).outcome, Outcome::Answered);
165    let learned = e.reflect();
166    assert_eq!(learned.len(), 1);
167    assert_eq!(learned[0].text, FACT);
168    // She says it again in a turn that is still waiting to be thought about when it is forgotten.
169    e.hear(AGAIN, vec![]);
170    assert_eq!(e.waiting_for_memory(), 1);
171
172    // The instrument can see them: before the forgetting, what she said and the old chat are in what the cat is told, and
173    // the bytes are in the files.
174    e.hear("and now", vec![]);
175    let before = everything_in_the_last_prompt(&b);
176    for w in [FIRST, OLD_SUMMARY, OLD_TURN] {
177        assert!(before.contains(w), "{w} should be in the prompt before: {before}");
178    }
179    assert!(contains(&on_disk(&d), FIRST) && contains(&on_disk(&d), OLD_SUMMARY) && contains(&on_disk(&d), FACT));
180
181    assert!(e.forget(learned[0].id));
182
183    // The exchange that was waiting cannot teach it back (and nothing of it was lost but the learning).
184    assert_eq!(e.waiting_for_memory(), 0);
185    assert!(e.reflect().is_empty(), "it is not learned again from what was said before");
186    assert!(e.facts().is_empty());
187    // The files hold none of it: the memory, what she said in the turn it was learned in, the chat that may carry it.
188    let bytes = on_disk(&d);
189    for w in [FACT, FIRST, OLD_SUMMARY, OLD_TURN] {
190        assert!(!contains(&bytes, w), "{w} is still in the database or its write-ahead log");
191    }
192    // Whiskers is told nothing of it: not the memory, not the summary, not the recent turns that may carry it.
193    e.hear(AFTER, vec![]);
194    let after = everything_in_the_last_prompt(&b);
195    for w in [FACT, "FORGOTTEN-FACT", FIRST, "SAID-FIRST", OLD_SUMMARY, "SUMMARY-SECRET", OLD_TURN, "OLD-TURN-SECRET"] {
196        assert!(!after.contains(w), "{w} is still in what the cat is told: {after}");
197    }
198    assert!(after.contains(AFTER), "the prompt is the new turn and nothing more");
199    assert_eq!(e.waiting_for_memory(), 1, "a turn after the forgetting is thought about as ever");
200}
201
202#[test]
203fn the_parents_log_keeps_that_the_talk_happened_and_not_what_was_said() {
204    let d = dir("log");
205    let b = brain(r#"["Biscuit is a purple bunny FORGOTTEN-FACT-4471"]"#);
206    let e = engine(&d, b);
207    e.hear(FIRST, vec![]);
208    let learned = e.reflect();
209    e.hear("an unrelated question about the weather", vec![]);
210    let day = e.digest(0, u64::MAX).unwrap();
211    assert_eq!((day.answered, day.exchanges.len()), (2, 2));
212    assert!(e.forget(learned[0].id));
213    let day = e.digest(0, u64::MAX).unwrap();
214    assert_eq!(day.exchanges.len(), 2, "both turns are still in the log");
215    assert_eq!((day.answered, day.stopped), (2, 0), "and how each ended");
216    let heard: Vec<&str> = day.exchanges.iter().map(|x| x.heard.as_str()).collect();
217    assert_eq!(heard, ["", "an unrelated question about the weather"], "what she said in the turn it was learned in is empty; the other turn is untouched");
218    assert!(day.exchanges[0].said.is_empty());
219    assert!(day.facts_learned.is_empty(), "and what was learned from it reads as nothing");
220}
221
222#[test]
223fn what_waits_to_be_thought_about_survives_the_app_being_stopped() {
224    let d = dir("restart");
225    let down = brain(r#"["Biscuit is a purple bunny FORGOTTEN-FACT-4471"]"#);
226    down.down.store(true, Ordering::SeqCst);
227    let first_turn = {
228        let e = engine(&d, down);
229        assert_eq!(e.hear(FIRST, vec![]).outcome, Outcome::Answered);
230        assert!(e.reflect().is_empty(), "the model cannot be reached");
231        assert_eq!(e.waiting_for_memory(), 1, "so it waits");
232        // The process dies here.
233        SqliteLog::new(Store::open(&d.join(DATABASE)).unwrap(), &whiskers_engine::device_id(&d).unwrap())
234            .own_entries()
235            .unwrap()
236            .into_iter()
237            .find(|x| matches!(x.event, Event::Heard { .. }))
238            .unwrap()
239            .turn
240    };
241    let up = brain(r#"["Biscuit is a purple bunny FORGOTTEN-FACT-4471"]"#);
242    let e = engine(&d, up);
243    assert_eq!(e.waiting_for_memory(), 1, "it is still waiting when the app starts again");
244    let learned = e.reflect();
245    assert_eq!(learned.len(), 1);
246    assert_eq!(e.waiting_for_memory(), 0, "dealt with, and taken off");
247    assert_eq!(learned[0].mentions[0].turn, first_turn, "learned from the turn she said it in, which the memory names");
248    assert!(e.reflect().is_empty());
249}
250
251#[test]
252fn a_memory_forgotten_while_the_memory_work_is_thinking_about_an_exchange_is_not_filed_from_it() {
253    let d = dir("race-filing");
254    let b = brain(r#"["Biscuit is a purple bunny FORGOTTEN-FACT-4471"]"#);
255    let e = Arc::new(engine(&d, b.clone()));
256    // Something is learned and will be forgotten, and an exchange waits.
257    e.hear("my cat is called Tom", vec![]);
258    let learned = e.reflect();
259    assert_eq!(learned.len(), 1, "the model proposes the same fact for anything: that is the memory the parents will forget");
260    e.hear("Biscuit again", vec![]);
261    assert_eq!(e.waiting_for_memory(), 1);
262    // The memory work reads the exchange and is in the model when the parents forget.
263    let (in_the_model, release) = b.hold_next("remember");
264    let worker = {
265        let e = e.clone();
266        std::thread::spawn(move || e.reflect())
267    };
268    in_the_model.recv_timeout(std::time::Duration::from_secs(10)).expect("the memory work reached the model");
269    assert!(e.forget(learned[0].id));
270    release.send(()).unwrap();
271    let filed = worker.join().unwrap();
272    assert!(filed.is_empty(), "the exchange was dropped by the forgetting, and the model's answer to it is not filed");
273    assert!(e.facts().is_empty());
274    assert_eq!(e.waiting_for_memory(), 0);
275}
276
277#[test]
278fn a_turn_being_answered_when_a_memory_is_forgotten_is_neither_kept_in_the_chat_nor_learned_from() {
279    let d = dir("mid-turn");
280    let b = brain(r#"["Biscuit is a purple bunny FORGOTTEN-FACT-4471"]"#);
281    let e = Arc::new(engine(&d, b.clone()));
282    e.hear(FIRST, vec![]);
283    let learned = e.reflect();
284    // A turn begins and the model is thinking when the parents forget the memory.
285    let (in_the_model, release) = b.hold_next("chat");
286    let talker = {
287        let e = e.clone();
288        std::thread::spawn(move || e.hear("MID-TURN-WORDS-3326 tell me about Biscuit", vec![]))
289    };
290    in_the_model.recv_timeout(std::time::Duration::from_secs(10)).expect("the turn reached the model");
291    assert!(e.forget(learned[0].id));
292    release.send(()).unwrap();
293    assert_eq!(talker.join().unwrap().outcome, Outcome::Answered, "she is still answered");
294    assert_eq!(e.waiting_for_memory(), 0, "but what the answer may have used is not learned from");
295    // Nor is it in the chat the next prompt is made from.
296    e.hear("next", vec![]);
297    assert!(!everything_in_the_last_prompt(&b).contains("MID-TURN-WORDS"));
298}
299
300#[test]
301fn the_household_day_follows_the_local_time_the_shell_reports() {
302    let d = dir("zone");
303    let e = engine(&d, brain("[]"));
304    assert_eq!(e.household_config().utc_offset_minutes, None);
305    let now = SystemClock.now_ms();
306    let local = |ahead: i64| {
307        let m = (now / 60_000) as i64 + ahead;
308        (m.div_euclid(1440) as u32, m.rem_euclid(1440) as u16)
309    };
310    // The first thing the device sees of its own clock, with the household knowing nothing, is written.
311    let (day, minute) = local(-240);
312    e.time_status(day, minute);
313    assert_eq!(e.household_config().utc_offset_minutes, Some(-240));
314    // Again and again (the shell asks every second): nothing more is written.
315    let stamp_of = |e: &Engine| serde_json::to_string(&e.household_config()).unwrap();
316    let before = stamp_of(&e);
317    for _ in 0..3 {
318        e.time_status(day, minute);
319        e.time_sheet(day, minute);
320    }
321    assert_eq!(stamp_of(&e), before);
322    // This device's own zone changes (it travels, or the clocks change): the household follows.
323    let (day, minute) = local(-300);
324    e.time_status(day, minute);
325    assert_eq!(e.household_config().utc_offset_minutes, Some(-300));
326    // A relaunch in a zone that differs from what the household has does not take it over: another device's stands
327    // until this one's own zone changes.
328    drop(e);
329    let again = engine(&d, brain("[]"));
330    let (day, minute) = local(60);
331    again.time_status(day, minute);
332    assert_eq!(again.household_config().utc_offset_minutes, Some(-300));
333}
334
335#[test]
336fn the_log_is_cleared_by_age_only_when_the_household_says_so_and_says_it_did() {
337    let d = dir("retention");
338    let e = engine(&d, brain("[]"));
339    let (long_ago, recent) = (SystemClock.now_ms() - 40 * 86_400_000, SystemClock.now_ms() - 86_400_000);
340    {
341        let store = Store::open(&d.join(DATABASE)).unwrap();
342        let mut log = SqliteLog::new(store, &whiskers_engine::device_id(&d).unwrap());
343        log.append(&Entry::new(long_ago, Event::Heard { text: "OLD-TALK-2468".into(), pictures: vec![] })).unwrap();
344        log.append(&Entry::new(recent, Event::Heard { text: "recent talk".into(), pictures: vec![] })).unwrap();
345    }
346    assert_eq!(e.apply_retention(), 0, "the default keeps everything");
347    assert!(contains(&on_disk(&d), "OLD-TALK-2468"));
348    let mut config = e.household_config();
349    config.log_retention_days = Some(30);
350    e.set_household_config(config);
351    assert_eq!(e.apply_retention(), 1);
352    assert!(!contains(&on_disk(&d), "OLD-TALK-2468"), "cleared from the file and its log");
353    let entries = SqliteLog::new(Store::open(&d.join(DATABASE)).unwrap(), &whiskers_engine::device_id(&d).unwrap()).own_entries().unwrap();
354    assert!(entries.iter().any(|x| x.event == Event::Expired && x.at_ms == long_ago), "the place and the time stay");
355    assert!(entries.iter().any(|x| matches!(&x.event, Event::Cleared { lines: 1, .. })), "and the log says it was cleared");
356    assert!(entries.iter().any(|x| matches!(&x.event, Event::Heard { text, .. } if text == "recent talk")));
357    assert_eq!(e.apply_retention(), 0, "nothing more to clear, nothing more said");
358    let days = e.log_days(0, u64::MAX, 10).unwrap();
359    assert!(days.iter().any(|d| d.cleared == 1), "{days:?}");
360    // Zero days is not a thing the household can ask for.
361    assert!(serde_json::from_str::<HouseholdConfig>(&serde_json::to_string(&e.household_config()).unwrap()).is_ok());
362}