semantic-memory 0.5.8

Local-first hybrid semantic search (SQLite + FTS5 + usearch 2.25) with bitemporal truth and typed receipts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
use semantic_memory::{MemoryConfig, MemoryStore, MockEmbedder, Role};
use tempfile::TempDir;

fn test_store() -> (MemoryStore, TempDir) {
    let tmp = TempDir::new().unwrap();
    let config = MemoryConfig {
        base_dir: tmp.path().to_path_buf(),
        ..Default::default()
    };
    let embedder = Box::new(MockEmbedder::new(768));
    let store = MemoryStore::open_with_embedder(config, embedder).unwrap();
    (store, tmp)
}

#[tokio::test]
async fn end_to_end_add_facts_and_search() {
    let (store, _tmp) = test_store();

    // Add multiple facts
    store
        .add_fact("general", "Rust was first released in 2015", None, None)
        .await
        .unwrap();
    store
        .add_fact(
            "general",
            "Python was created by Guido van Rossum",
            None,
            None,
        )
        .await
        .unwrap();
    store
        .add_fact("general", "JavaScript was created in 10 days", None, None)
        .await
        .unwrap();
    store
        .add_fact("user", "Josh's favorite language is Rust", None, None)
        .await
        .unwrap();
    store
        .add_fact("user", "Josh works on Ironforge", None, None)
        .await
        .unwrap();

    // Search via FTS
    let results = store
        .search_fts_only("Rust", None, None, None)
        .await
        .unwrap();
    assert!(!results.is_empty(), "Should find facts about Rust");

    // Hybrid search
    let _results = store
        .search("programming languages", None, None, None)
        .await
        .unwrap();
    // With mock embedder, results are based on keyword matching (FTS) only
    // Vector similarity with mock embedder won't give semantic results
    // But the pipeline should work without errors

    // Stats
    let stats = store.stats().await.unwrap();
    assert_eq!(stats.total_facts, 5);
    assert_eq!(stats.total_sessions, 0);
}

#[tokio::test]
async fn end_to_end_document_ingestion() {
    let (store, _tmp) = test_store();

    let content = "This is a test document about machine learning. ".repeat(50);
    let doc_id = store
        .ingest_document("Test Doc", &content, "docs", Some("/test/doc.txt"), None)
        .await
        .unwrap();

    // Document should be listed
    let docs = store.list_documents("docs", 10, 0).await.unwrap();
    assert_eq!(docs.len(), 1);
    assert_eq!(docs[0].title, "Test Doc");
    assert!(docs[0].chunk_count > 0);

    // Chunks should be searchable via FTS
    let results = store
        .search_fts_only("machine learning", None, None, None)
        .await
        .unwrap();
    assert!(
        !results.is_empty(),
        "Document chunks should be FTS searchable"
    );

    // Delete document
    store.delete_document(&doc_id).await.unwrap();
    let docs = store.list_documents("docs", 10, 0).await.unwrap();
    assert!(docs.is_empty());

    // FTS should be clean
    let results = store
        .search_fts_only("machine learning", None, None, None)
        .await
        .unwrap();
    assert!(
        results.is_empty(),
        "FTS should be clean after document deletion"
    );
}

#[tokio::test]
async fn clone_shares_state() {
    let (store_a, _tmp) = test_store();
    let store_b = store_a.clone();

    // Add via clone A
    store_a
        .add_fact("test", "Shared state test", None, None)
        .await
        .unwrap();

    // Find via clone B
    let results = store_b
        .search_fts_only("Shared state", None, None, None)
        .await
        .unwrap();
    assert!(!results.is_empty(), "Clone should share state");
}

#[tokio::test]
async fn conversations_and_facts_coexist() {
    let (store, _tmp) = test_store();

    // Add conversation
    let sid = store.create_session("test").await.unwrap();
    store
        .add_message(&sid, Role::User, "What is Rust?", Some(10), None)
        .await
        .unwrap();
    store
        .add_message(
            &sid,
            Role::Assistant,
            "Rust is a programming language",
            Some(15),
            None,
        )
        .await
        .unwrap();

    // Add facts
    store
        .add_fact("general", "Rust is a systems language", None, None)
        .await
        .unwrap();

    // Both should work independently
    let messages = store.get_recent_messages(&sid, 10).await.unwrap();
    assert_eq!(messages.len(), 2);

    let facts = store.list_facts("general", 10, 0).await.unwrap();
    assert_eq!(facts.len(), 1);

    let stats = store.stats().await.unwrap();
    assert_eq!(stats.total_sessions, 1);
    assert_eq!(stats.total_messages, 2);
    assert_eq!(stats.total_facts, 1);
}

#[tokio::test]
async fn reopen_database_preserves_data() {
    let tmp = TempDir::new().unwrap();
    let path = tmp.path().to_path_buf();

    // First open: add data
    {
        let config = MemoryConfig {
            base_dir: path.clone(),
            ..Default::default()
        };
        let store =
            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(768))).unwrap();
        store
            .add_fact("general", "Persistent fact", None, None)
            .await
            .unwrap();
        let sid = store.create_session("test").await.unwrap();
        store
            .add_message(&sid, Role::User, "Persistent message", Some(10), None)
            .await
            .unwrap();
    }

    // Second open: verify data persists
    {
        let config = MemoryConfig {
            base_dir: path,
            ..Default::default()
        };
        let store =
            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(768))).unwrap();

        let facts = store.list_facts("general", 10, 0).await.unwrap();
        assert_eq!(facts.len(), 1);
        assert_eq!(facts[0].content, "Persistent fact");

        let sessions = store.list_sessions(10, 0).await.unwrap();
        assert_eq!(sessions.len(), 1);
    }
}

#[tokio::test]
async fn vacuum_works() {
    let (store, _tmp) = test_store();
    store
        .add_fact("general", "Vacuumable fact", None, None)
        .await
        .unwrap();
    store.vacuum().await.unwrap();
}

#[test]
fn chunk_text_exposed_on_store() {
    let (store, _tmp) = test_store();
    let chunks = store.chunk_text("Hello world, this is a test.");
    assert_eq!(chunks.len(), 1);
}

#[tokio::test]
async fn embed_exposed_on_store() {
    let (store, _tmp) = test_store();
    let embedding = store.embed("Hello world").await.unwrap();
    assert_eq!(embedding.len(), 768);
}

#[tokio::test]
async fn embed_batch_exposed_on_store() {
    let (store, _tmp) = test_store();
    let embeddings = store.embed_batch(&["Hello", "World"]).await.unwrap();
    assert_eq!(embeddings.len(), 2);
    assert_eq!(embeddings[0].len(), 768);
    assert_eq!(embeddings[1].len(), 768);
}

#[tokio::test]
async fn v2_migration_preserves_existing_data() {
    let tmp = TempDir::new().unwrap();
    let path = tmp.path().to_path_buf();

    // First open: add data (triggers V1 + V2 + V3 migrations)
    {
        let config = MemoryConfig {
            base_dir: path.clone(),
            ..Default::default()
        };
        let store =
            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(768))).unwrap();

        let sid = store.create_session("test").await.unwrap();
        store
            .add_message(&sid, Role::User, "V1 message", Some(10), None)
            .await
            .unwrap();
        store
            .add_fact("general", "V1 fact", None, None)
            .await
            .unwrap();
    }

    // Second open: verify data persists and V2 schema is present
    {
        let config = MemoryConfig {
            base_dir: path,
            ..Default::default()
        };
        let store =
            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(768))).unwrap();

        // Existing messages preserved
        let sessions = store.list_sessions(10, 0).await.unwrap();
        assert_eq!(sessions.len(), 1);
        let messages = store
            .get_recent_messages(&sessions[0].id, 10)
            .await
            .unwrap();
        assert_eq!(messages.len(), 1);
        assert_eq!(messages[0].content, "V1 message");

        // Facts preserved
        let facts = store.list_facts("general", 10, 0).await.unwrap();
        assert_eq!(facts.len(), 1);
        assert_eq!(facts[0].content, "V1 fact");

        // V2 schema works: can add embedded messages
        let sid = &sessions[0].id;
        store
            .add_message_embedded(sid, Role::User, "V2 embedded message", Some(10), None)
            .await
            .unwrap();

        // Can search conversations
        let results = store
            .search_conversations("embedded", None, None)
            .await
            .unwrap();
        assert!(!results.is_empty());
    }
}

// ─── V2 New Tests ───────────────────────────────────────────────

#[tokio::test]
async fn test_v3_migration() {
    let (store, _tmp) = test_store();

    // V3 migration should have run at open time.
    // Verify embeddings_dirty is accessible and defaults to false.
    let dirty = store.embeddings_are_dirty().await.unwrap();
    assert!(!dirty, "Fresh DB should not have dirty embeddings");
}

#[tokio::test]
async fn test_embedding_dirty_flag() {
    let tmp = TempDir::new().unwrap();
    let path = tmp.path().to_path_buf();

    // Open store with model "model-a" at 768 dims
    {
        let config = MemoryConfig {
            base_dir: path.clone(),
            ..Default::default()
        };
        let store =
            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(768))).unwrap();
        store.add_fact("ns", "test fact", None, None).await.unwrap();
        assert!(!store.embeddings_are_dirty().await.unwrap());
    }

    // Reopen with different dimensions — triggers mismatch
    {
        let mut config = MemoryConfig {
            base_dir: path.clone(),
            ..Default::default()
        };
        config.embedding.dimensions = 256;
        config.embedding.model = "model-b".to_string();
        let store =
            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(256))).unwrap();
        assert!(
            store.embeddings_are_dirty().await.unwrap(),
            "Embeddings should be dirty after model change"
        );

        // Reembed clears the flag
        let count = store.reembed_all().await.unwrap();
        assert!(count >= 1, "Should have re-embedded at least 1 item");
        assert!(
            !store.embeddings_are_dirty().await.unwrap(),
            "Embeddings should be clean after reembed_all"
        );
    }
}

#[tokio::test]
async fn test_reembed_all_includes_messages() {
    let (store, _tmp) = test_store();
    let session = store.create_session("test").await.unwrap();

    // Add an embedded message
    store
        .add_message_embedded(&session, Role::User, "fluid dynamics", Some(5), None)
        .await
        .unwrap();

    // Add a non-embedded message (should be skipped)
    store
        .add_message(&session, Role::User, "not embedded", Some(5), None)
        .await
        .unwrap();

    // reembed_all should count the embedded message
    let count = store.reembed_all().await.unwrap();
    assert!(
        count >= 1,
        "At least the one embedded message should be re-embedded"
    );
}

#[tokio::test]
async fn test_auto_token_count() {
    let (store, _tmp) = test_store();
    let session = store.create_session("test").await.unwrap();

    // Add message with token_count = None — should auto-compute
    store
        .add_message(&session, Role::User, "hello world testing", None, None)
        .await
        .unwrap();
    let messages = store.get_recent_messages(&session, 10).await.unwrap();
    // Should have auto-computed token count (19 chars / 4 ≈ 4), not None
    assert!(messages[0].token_count.is_some());
    assert!(messages[0].token_count.unwrap() > 0);
}

#[cfg(feature = "admin-ops")]
#[tokio::test]
async fn search_cache_cleared_after_update_fact() {
    let (store, _tmp) = test_store();

    // Add a fact with unique content easily matched by FTS5
    let fact_id = store
        .add_fact("general", "xyzzy unique marker phrase for cache test", None, None)
        .await
        .unwrap();

    // First search populates the cache
    let results1 = store
        .search("xyzzy unique marker", Some(5), None, None)
        .await
        .unwrap();
    assert!(
        !results1.is_empty(),
        "Should find the fact with xyzzy marker before update"
    );
    assert!(results1.iter().any(|r| r.content.contains("xyzzy unique marker")));

    // Update the fact to completely different content
    store
        .update_fact(&fact_id, "completely replaced content nothing in common")
        .await
        .unwrap();

    // Second search with same query: cache must have been cleared, so fresh DB query runs
    let results2 = store
        .search("xyzzy unique marker", Some(5), None, None)
        .await
        .unwrap();

    // Fresh search should not return the old cached content
    assert!(
        results2.iter().all(|r| !r.content.contains("xyzzy unique marker")),
        "Search cache must be cleared after update_fact — old stale content should not appear"
    );
}

#[cfg(feature = "admin-ops")]
#[tokio::test]
async fn search_cache_cleared_after_delete_namespace() {
    let (store, _tmp) = test_store();

    store
        .add_fact("testns", "qqqq distinctive namespace cache fact", None, None)
        .await
        .unwrap();

    let results1 = store
        .search("qqqq distinctive namespace", Some(5), None, None)
        .await
        .unwrap();
    assert!(!results1.is_empty(), "Should find qqqq fact before namespace delete");

    store.delete_namespace("testns").await.unwrap();

    let results2 = store
        .search("qqqq distinctive namespace", Some(5), None, None)
        .await
        .unwrap();
    assert!(
        results2.iter().all(|r| !r.content.contains("qqqq distinctive")),
        "Cache must be cleared after delete_namespace"
    );
}