whiskers.git / crates / whiskers-store / tests / forgetting_turns.rs
1//! Forgetting reaches the conversation a memory was told in. A turn has an identity, on every line of it and on the
2//! memory's mentions; forgetting writes the identities (never words) and the schema empties those turns, here and on
3//! every device the forgetting reaches, and in lines of them that arrive later. What waits to be thought about is
4//! dropped, and the bytes of the database and of its write-ahead log are searched for every word.
5
6use std::path::{Path, PathBuf};
7
8use whiskers_core::{Entry, Event, Log, Memory, MemoryDoc, NewFact, Outcome, PictureId};
9use whiskers_core::{ReflectInput, Verdict};
10use whiskers_store::{SqliteLog, SqliteMemory, SqlitePictures, SqliteReflections, Store};
11
12const HEARD: &str = "HEARD-WORDS-3391-QX";
13const MODEL: &str = "MODEL-WORDS-4402-QX";
14const SAID: &str = "SAID-WORDS-5513-QX";
15const LOOK: &str = "LOOKED-LIKE-6624-QX";
16const PROPOSED: &str = "PROPOSED-FACT-7735-QX";
17const AGAIN: &str = "SAID-AGAIN-8846-QX";
18const UNRELATED: &str = "UNRELATED-WORDS-9957-QX";
19const WAITING: &str = "WAITING-WORDS-1068-QX";
20const SHOWN: &[u8] = b"SHOWN-PICTURE-BYTES-2179-QX-0123456789-0123456789";
21const TURN: &str = "0000000000001aaaaaaaaaaaaaaaa1";
22const AGAIN_TURN: &str = "0000000000002bbbbbbbbbbbbbbbb2";
23const OTHER_TURN: &str = "0000000000003cccccccccccccccc3";
24
25fn dir(name: &str) -> PathBuf {
26    use std::sync::atomic::{AtomicUsize, Ordering};
27    static N: AtomicUsize = AtomicUsize::new(0);
28    let d = std::env::temp_dir().join(format!("whiskers-store-turns-{name}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst)));
29    let _ = std::fs::remove_dir_all(&d);
30    std::fs::create_dir_all(&d).unwrap();
31    d
32}
33
34fn on_disk(db: &Path) -> (Vec<u8>, Vec<u8>) {
35    let wal = db.with_file_name(format!("{}-wal", db.file_name().unwrap().to_string_lossy()));
36    (std::fs::read(db).unwrap(), std::fs::read(wal).unwrap_or_default())
37}
38
39fn contains(haystack: &[u8], needle: &[u8]) -> bool {
40    haystack.windows(needle.len()).any(|w| w == needle)
41}
42
43fn gone_from_disk(db: &Path, what: &[(&str, &[u8])]) {
44    let (file, wal) = on_disk(db);
45    for (name, n) in what {
46        assert!(!contains(&file, n), "{name} is still in the database file");
47        assert!(!contains(&wal, n), "{name} is still in the write-ahead log");
48    }
49}
50
51fn on_disk_somewhere(db: &Path, what: &[(&str, &[u8])]) {
52    let (file, wal) = on_disk(db);
53    for (name, n) in what {
54        assert!(contains(&file, n) || contains(&wal, n), "{name} should be on disk before it is forgotten");
55    }
56}
57
58fn line(turn: &str, at: u64, event: Event) -> Entry {
59    Entry::in_turn(turn, at, event)
60}
61
62/// The lines of one turn, as the conversation writes them: what she said and showed, what a look found, what the
63/// model wrote, what was said, and a memory proposed from it that was kept out.
64fn the_turn(picture: &PictureId) -> Vec<Entry> {
65    vec![
66        line(TURN, 100, Event::Heard { text: HEARD.into(), pictures: vec![picture.clone()] }),
67        line(TURN, 101, Event::PictureSeen { description: LOOK.into(), pictures: vec![picture.clone()] }),
68        line(TURN, 102, Event::Guarded { direction: whiskers_core::Direction::FromChild, verdict: Verdict::Allow }),
69        line(TURN, 103, Event::ModelWrote { text: MODEL.into() }),
70        line(TURN, 104, Event::Said { text: SAID.into(), outcome: Outcome::Answered }),
71        line(TURN, 105, Event::NotRemembered { fact: PROPOSED.into(), why: "too long".into() }),
72    ]
73}
74
75fn texts_of(entries: &[Entry]) -> Vec<String> {
76    entries
77        .iter()
78        .map(|e| match &e.event {
79            Event::Heard { text, .. } | Event::ModelWrote { text } | Event::Said { text, .. } => text.clone(),
80            Event::PictureSeen { description, .. } => description.clone(),
81            Event::NotRemembered { fact, .. } => fact.clone(),
82            _ => String::new(),
83        })
84        .collect()
85}
86
87fn waiting(turn: &str, words: &str) -> ReflectInput {
88    ReflectInput { turn: turn.into(), heard: words.into(), said: "fine".into(), description: None, pictures: vec![] }
89}
90
91#[test]
92fn forgetting_a_memory_empties_the_turns_it_was_told_in_and_nothing_else() {
93    let d = dir("device");
94    let db = d.join("device.db");
95    let store = Store::open(&db).unwrap();
96    let pictures = SqlitePictures::new(store.clone());
97    let picture = PictureId("0000000000000009-0000.jpg".into());
98    pictures.store(&picture, SHOWN).unwrap();
99    let mut log = SqliteLog::new(store.clone(), "phone");
100    for e in the_turn(&picture) {
101        log.append(&e).unwrap();
102    }
103    // The memory was told again in a second turn, and an unrelated turn and the hello are in the log too.
104    log.append(&line(AGAIN_TURN, 200, Event::Heard { text: AGAIN.into(), pictures: vec![] })).unwrap();
105    log.append(&line(AGAIN_TURN, 201, Event::Said { text: format!("{AGAIN} again"), outcome: Outcome::Answered })).unwrap();
106    log.append(&line(OTHER_TURN, 300, Event::Heard { text: UNRELATED.into(), pictures: vec![] })).unwrap();
107    log.append(&Entry::new(1, Event::Greeted { text: "Hi!".into() })).unwrap();
108
109    let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap();
110    let f = memory.add(NewFact { text: "Biscuit is her bunny".into(), turn: TURN.into(), ..NewFact::default() }, 100).unwrap();
111    memory.mention(f.id, 200, AGAIN_TURN).unwrap();
112    // Exchanges wait: one of the turn the memory came from, one of an unrelated turn.
113    let queue = SqliteReflections::new(store.clone());
114    queue.push(&waiting(TURN, WAITING), 106, 8).unwrap();
115    queue.push(&waiting(OTHER_TURN, UNRELATED), 301, 8).unwrap();
116
117    // The turns are on the memory, by identity, and survive a restart.
118    drop(memory);
119    let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap();
120    let mentions: Vec<String> = memory.facts()[0].mentions.iter().map(|m| m.turn.clone()).collect();
121    assert_eq!(mentions, [TURN, AGAIN_TURN]);
122
123    let words: Vec<(&str, &[u8])> = vec![
124        ("what she said", HEARD.as_bytes()),
125        ("what a look at her picture found", LOOK.as_bytes()),
126        ("what the model wrote", MODEL.as_bytes()),
127        ("what was said back", SAID.as_bytes()),
128        ("a fact proposed from it", PROPOSED.as_bytes()),
129        ("what she said again", AGAIN.as_bytes()),
130        ("the picture she showed", SHOWN),
131        ("the exchange waiting to be thought about", WAITING.as_bytes()),
132    ];
133    on_disk_somewhere(&db, &words);
134    assert_eq!(queue.len().unwrap(), 2);
135
136    memory.forget(f.id, 999).unwrap();
137
138    gone_from_disk(&db, &words);
139    let (_, wal) = on_disk(&db);
140    assert!(wal.is_empty(), "the write-ahead log was truncated: {} bytes", wal.len());
141    // The lines stay, with their times and how the turn ended; what was said in them is empty.
142    let kept = log.own_entries().unwrap();
143    assert_eq!(kept.len(), 6 + 2 + 1 + 1, "no line was removed");
144    let turn: Vec<&Entry> = kept.iter().filter(|e| e.turn == TURN).collect();
145    assert_eq!(turn.iter().map(|e| e.at_ms).collect::<Vec<_>>(), [100, 101, 102, 103, 104, 105]);
146    assert!(texts_of(&turn.iter().map(|e| (*e).clone()).collect::<Vec<_>>()).iter().all(String::is_empty));
147    assert!(matches!(&turn[0].event, Event::Heard { pictures, .. } if pictures.is_empty()), "the picture is let go");
148    assert!(matches!(&turn[4].event, Event::Said { outcome: Outcome::Answered, .. }), "and how it ended stays");
149    assert!(matches!(&turn[5].event, Event::NotRemembered { why, .. } if why == "too long"));
150    assert!(kept.iter().filter(|e| e.turn == AGAIN_TURN).all(|e| texts_of(&[(*e).clone()])[0].is_empty()), "the turn she said it again in is emptied too");
151    let unrelated = kept.iter().find(|e| e.turn == OTHER_TURN).unwrap();
152    assert!(matches!(&unrelated.event, Event::Heard { text, .. } if text == UNRELATED), "an unrelated turn is untouched");
153    assert!(kept.iter().any(|e| matches!(&e.event, Event::Greeted { text } if text == "Hi!")));
154    // Every exchange that waited was dropped: any of them might say the memory again and learn it back.
155    assert!(queue.is_empty().unwrap(), "waiting exchanges are dropped, the unrelated one too");
156    assert!(!queue.has_turn(OTHER_TURN).unwrap());
157    // The picture only that turn showed went with it.
158    assert!(!pictures.has(&picture));
159    // What stays of the memory is its identity, when and by whom, and the identities of its turns.
160    let conn = rusqlite::Connection::open(&db).unwrap();
161    let count = |sql: &str| conn.query_row(sql, [], |r| r.get::<_, i64>(0)).unwrap();
162    assert_eq!(count(&format!("SELECT COUNT(*) FROM tombstone_turn WHERE gid = '{}'", f.gid)), 2);
163    assert_eq!(count("SELECT COUNT(*) FROM journal_picture"), 0);
164    drop((memory, store));
165    let again = SqliteMemory::open(Store::open(&db).unwrap(), "phone").unwrap();
166    assert_eq!(again.snapshot().forgotten_when[0].turns, [TURN, AGAIN_TURN], "and they survive a restart, to be sent on");
167}
168
169#[test]
170fn a_line_of_a_forgotten_turn_that_arrives_late_is_emptied_as_it_is_written() {
171    let d = dir("late");
172    let db = d.join("device.db");
173    let store = Store::open(&db).unwrap();
174    let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap();
175    let f = memory.add(NewFact { text: "Biscuit is her bunny".into(), turn: TURN.into(), ..NewFact::default() }, 100).unwrap();
176    memory.forget(f.id, 999).unwrap();
177    // The tablet had not heard of it, and its lines of that turn reach this device afterwards.
178    let picture = PictureId("0000000000000009-0000.jpg".into());
179    let lines: Vec<String> = the_turn(&picture).iter().map(|e| format!(r#"{{"device":"tablet","entry":{}}}"#, serde_json::to_string(e).unwrap())).collect();
180    let log = SqliteLog::new(store.clone(), "phone");
181    assert_eq!(log.take(0, &lines).unwrap(), 6);
182    store.scrub().unwrap();
183    gone_from_disk(&db, &[("what she said", HEARD.as_bytes()), ("what the model wrote", MODEL.as_bytes()), ("what was said back", SAID.as_bytes()), ("a fact proposed", PROPOSED.as_bytes())]);
184    let (groups, _) = log.read_groups().unwrap();
185    assert!(groups.iter().flatten().all(|e| texts_of(std::slice::from_ref(e))[0].is_empty()));
186    let conn = rusqlite::Connection::open(&db).unwrap();
187    assert_eq!(conn.query_row("SELECT COUNT(*) FROM journal_picture", [], |r| r.get::<_, i64>(0)).unwrap(), 0, "no picture is kept for a line that no longer names one");
188}
189
190#[test]
191fn a_device_that_never_saw_the_memory_empties_the_turns_when_the_forgetting_arrives() {
192    // Phone learned and forgot it; the tablet only has the phone's lines of that turn, taken from the service.
193    let phone_dir = dir("phone");
194    let mut phone = SqliteMemory::open(Store::open(&phone_dir.join("p.db")).unwrap(), "phone").unwrap();
195    let f = phone.add(NewFact { text: "Biscuit is her bunny".into(), turn: TURN.into(), ..NewFact::default() }, 100).unwrap();
196    let before_forgetting: MemoryDoc = MemoryDoc::default();
197    let _ = before_forgetting;
198    phone.forget(f.id, 999).unwrap();
199
200    let tablet_dir = dir("tablet");
201    let db = tablet_dir.join("t.db");
202    let store = Store::open(&db).unwrap();
203    let picture = PictureId("0000000000000009-0000.jpg".into());
204    let lines: Vec<String> = the_turn(&picture).iter().map(|e| format!(r#"{{"device":"phone","entry":{}}}"#, serde_json::to_string(e).unwrap())).collect();
205    let tablet_log = SqliteLog::new(store.clone(), "tablet");
206    tablet_log.take(0, &lines).unwrap();
207    let queue = SqliteReflections::new(store.clone());
208    queue.push(&waiting(OTHER_TURN, WAITING), 1, 8).unwrap();
209    on_disk_somewhere(&db, &[("what she said", HEARD.as_bytes()), ("what the model wrote", MODEL.as_bytes())]);
210
211    let mut tablet = SqliteMemory::open(store.clone(), "tablet").unwrap();
212    tablet.merge(phone.snapshot()).unwrap();
213
214    gone_from_disk(&db, &[("what she said", HEARD.as_bytes()), ("what the model wrote", MODEL.as_bytes()), ("what was said back", SAID.as_bytes()), ("the waiting exchange", WAITING.as_bytes())]);
215    assert!(queue.is_empty().unwrap());
216    // Merging again changes nothing, and is not a second forgetting: nothing waiting is dropped twice.
217    queue.push(&waiting(OTHER_TURN, "later"), 2, 8).unwrap();
218    tablet.merge(phone.snapshot()).unwrap();
219    assert_eq!(queue.len().unwrap(), 1, "the same forgetting arriving again drops nothing");
220}
221
222#[test]
223fn a_copy_dropped_as_a_duplicate_does_not_drop_what_waits_or_empty_its_turns() {
224    let d = dir("duplicate");
225    let store = Store::open(&d.join("d.db")).unwrap();
226    let mut a = SqliteMemory::open(store.clone(), "phone").unwrap();
227    let kept = a.add(NewFact { text: "Biscuit is her bunny".into(), turn: TURN.into(), ..NewFact::default() }, 10).unwrap();
228    let log = SqliteLog::new(store.clone(), "phone");
229    log.clone().append(&line(TURN, 10, Event::Heard { text: HEARD.into(), pictures: vec![] })).unwrap();
230    let queue = SqliteReflections::new(store.clone());
231    queue.push(&waiting(OTHER_TURN, WAITING), 11, 8).unwrap();
232    // The same thing learned on another device arrives as a second fact and is dropped, which writes a tombstone.
233    let mut other = MemoryDoc::default();
234    other.add(NewFact { text: "biscuit is her  bunny".into(), turn: OTHER_TURN.into(), ..NewFact::default() }, 90, "tablet", "zz-other".into());
235    a.merge(other.snapshot()).unwrap();
236    assert_eq!(a.facts().len(), 1);
237    assert_eq!(a.snapshot().forgotten.len(), 1, "a tombstone was written for the copy");
238    assert!(a.snapshot().forgotten_when.is_empty(), "but it is not a forgetting: it has no time");
239    assert_eq!(queue.len().unwrap(), 1, "nothing waiting was dropped");
240    let e = log.own_entries().unwrap();
241    assert!(matches!(&e[0].event, Event::Heard { text, .. } if text == HEARD), "and nothing was emptied");
242    assert_eq!(a.facts()[0].id, kept.id);
243}