whiskers.git / crates / whiskers-store / tests / forgetting_turns.rs

Forgetting reaches the conversation a memory was told in. A turn has an identity, on every line of it and on the memory's mentions; forgetting writes the identities (never words) and the schema empties those turns, here and on every device the forgetting reaches, and in lines of them that arrive later. What waits to be thought about is dropped, and the bytes of the database and of its write-ahead log are searched for every word.

6use std::path::{Path, PathBuf};
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}

The lines of one turn, as the conversation writes them: what she said and showed, what a look found, what the 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}
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}