1//! Forgetting is true everywhere the memory, or what it was learned from, could be: the prompt built after it holds none
2//! of it, what waited to be thought about cannot teach it back, and the parents' log keeps that the talk happened and
3//! not what was said. What waits survives the app being stopped.
4
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8
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";
19
20/// A model that records what it is asked, answers a turn with a greeting, and proposes the memory when asked what to
21/// remember (unless it is down).
22struct Brain {
23    seen: Mutex<Vec<(String, Vec<Turn>)>>,
24    down: AtomicBool,
25    teaches: &'static str,
26    /// 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}
29
30struct Hold {
31    kind: &'static str,
32    started: std::sync::mpsc::Sender<()>,
33    release: std::sync::mpsc::Receiver<()>,
34}
35
36impl Brain {
37    /// 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    }
44
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}
138
139/// 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}
155
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}