Skip to main content

semantic_memory/
knowledge.rs

1//! Fact CRUD with FTS5 synchronization.
2//!
3//! Every fact operation that touches `facts_fts` is transactional.
4
5use crate::db;
6use crate::db::{bytes_to_embedding, parse_optional_json, with_transaction};
7#[cfg(feature = "hnsw")]
8use crate::db::{enqueue_pending_index_op, PendingIndexOpKind};
9#[cfg(feature = "hnsw")]
10use crate::episodes;
11use crate::error::MemoryError;
12use crate::quantize::{self, Quantizer};
13use crate::types::{Fact, NamespaceDeleteReport};
14use crate::{merge_trace_ctx, MemoryStore};
15use rusqlite::{params, Connection};
16use stack_ids::TraceCtx;
17
18/// Insert a fact and its FTS entry in a transaction.
19#[allow(dead_code)]
20pub fn insert_fact_with_fts(
21    conn: &Connection,
22    fact_id: &str,
23    namespace: &str,
24    content: &str,
25    embedding_bytes: &[u8],
26    source: Option<&str>,
27    metadata: Option<&serde_json::Value>,
28) -> Result<(), MemoryError> {
29    insert_fact_with_fts_q8(
30        conn,
31        fact_id,
32        namespace,
33        content,
34        embedding_bytes,
35        None,
36        source,
37        metadata,
38        None,
39    )
40}
41
42/// Insert a fact with both f32 and quantized embeddings.
43#[allow(clippy::too_many_arguments)]
44pub fn insert_fact_with_fts_q8(
45    conn: &Connection,
46    fact_id: &str,
47    namespace: &str,
48    content: &str,
49    embedding_bytes: &[u8],
50    q8_bytes: Option<&[u8]>,
51    source: Option<&str>,
52    metadata: Option<&serde_json::Value>,
53    sparse: Option<(&crate::SparseWeights, &str)>,
54) -> Result<(), MemoryError> {
55    let metadata_str = metadata.map(|m| m.to_string());
56    with_transaction(conn, |tx| {
57        tx.execute(
58            "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
59             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
60            params![
61                fact_id,
62                namespace,
63                content,
64                source,
65                embedding_bytes,
66                q8_bytes,
67                metadata_str
68            ],
69        )?;
70
71        tx.execute(
72            "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
73            params![fact_id],
74        )?;
75        let fts_rowid = tx.last_insert_rowid();
76
77        tx.execute(
78            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
79            params![fts_rowid, content],
80        )?;
81
82        #[cfg(feature = "hnsw")]
83        enqueue_pending_index_op(
84            tx,
85            &format!("fact:{}", fact_id),
86            "fact",
87            PendingIndexOpKind::Upsert,
88        )?;
89        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
90        if let Some((weights, representation)) = sparse {
91            db::store_sparse_vector(tx, &format!("fact:{fact_id}"), weights, representation)?;
92        }
93
94        Ok(())
95    })
96}
97
98/// Insert a fact within an existing transaction (no nested transaction).
99///
100/// Used by the import boundary where the outer transaction is already active.
101#[allow(clippy::too_many_arguments)]
102pub fn insert_fact_in_tx(
103    tx: &rusqlite::Transaction<'_>,
104    fact_id: &str,
105    namespace: &str,
106    content: &str,
107    embedding_bytes: &[u8],
108    q8_bytes: Option<&[u8]>,
109    source: Option<&str>,
110    metadata: Option<&serde_json::Value>,
111) -> Result<(), MemoryError> {
112    let metadata_str = metadata.map(|m| m.to_string());
113    tx.execute(
114        "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
115         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
116        params![
117            fact_id,
118            namespace,
119            content,
120            source,
121            embedding_bytes,
122            q8_bytes,
123            metadata_str
124        ],
125    )?;
126
127    tx.execute(
128        "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
129        params![fact_id],
130    )?;
131    let fts_rowid = tx.last_insert_rowid();
132
133    tx.execute(
134        "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
135        params![fts_rowid, content],
136    )?;
137
138    #[cfg(feature = "hnsw")]
139    enqueue_pending_index_op(
140        tx,
141        &format!("fact:{}", fact_id),
142        "fact",
143        PendingIndexOpKind::Upsert,
144    )?;
145    db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
146
147    Ok(())
148}
149
150/// Delete a fact and its FTS entry in a transaction.
151#[allow(dead_code)] // public API — used by external consumers, not internally
152pub fn delete_fact_with_fts(conn: &Connection, fact_id: &str) -> Result<(), MemoryError> {
153    with_transaction(conn, |tx| {
154        let fts_rowid: i64 = tx
155            .query_row(
156                "SELECT rowid FROM facts_rowid_map WHERE fact_id = ?1",
157                params![fact_id],
158                |row| row.get(0),
159            )
160            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
161
162        let content: String = tx
163            .query_row(
164                "SELECT content FROM facts WHERE id = ?1",
165                params![fact_id],
166                |row| row.get(0),
167            )
168            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
169
170        tx.execute(
171            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
172            params![fts_rowid, content],
173        )?;
174        tx.execute(
175            "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
176            params![fact_id],
177        )?;
178        tx.execute(
179            "DELETE FROM episode_causes WHERE cause_node_id IN (?1, ?2)",
180            params![fact_id, format!("fact:{fact_id}")],
181        )?;
182        tx.execute(
183            "DELETE FROM derivation_edges
184             WHERE (source_kind = 'fact' AND source_id = ?1)
185                OR (target_kind = 'fact' AND target_id = ?1)",
186            params![fact_id],
187        )?;
188        tx.execute("DELETE FROM facts WHERE id = ?1", params![fact_id])?;
189
190        #[cfg(feature = "hnsw")]
191        enqueue_pending_index_op(
192            tx,
193            &format!("fact:{}", fact_id),
194            "fact",
195            PendingIndexOpKind::Delete,
196        )?;
197        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
198
199        Ok(())
200    })
201}
202
203/// Update a fact's content and embeddings, with FTS synchronization.
204#[allow(dead_code)] // public API — used by external consumers, not internally
205pub fn update_fact_with_fts(
206    conn: &Connection,
207    fact_id: &str,
208    new_content: &str,
209    new_embedding_bytes: &[u8],
210    new_q8_bytes: Option<&[u8]>,
211) -> Result<(), MemoryError> {
212    with_transaction(conn, |tx| {
213        let (fts_rowid, old_content): (i64, String) = tx
214            .query_row(
215                "SELECT fm.rowid, f.content
216                 FROM facts f
217                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
218                 WHERE f.id = ?1",
219                params![fact_id],
220                |row| Ok((row.get(0)?, row.get(1)?)),
221            )
222            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
223
224        tx.execute(
225            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
226            params![fts_rowid, old_content],
227        )?;
228
229        tx.execute(
230            "UPDATE facts
231             SET content = ?1,
232                 embedding = ?2,
233                 embedding_q8 = ?3,
234                 updated_at = datetime('now')
235             WHERE id = ?4",
236            params![new_content, new_embedding_bytes, new_q8_bytes, fact_id],
237        )?;
238
239        tx.execute(
240            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
241            params![fts_rowid, new_content],
242        )?;
243        tx.execute(
244            "DELETE FROM derivation_edges
245             WHERE (source_kind = 'fact' AND source_id = ?1)
246                OR (target_kind = 'fact' AND target_id = ?1)",
247            params![fact_id],
248        )?;
249
250        #[cfg(feature = "hnsw")]
251        enqueue_pending_index_op(
252            tx,
253            &format!("fact:{}", fact_id),
254            "fact",
255            PendingIndexOpKind::Upsert,
256        )?;
257        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
258
259        Ok(())
260    })
261}
262
263/// Delete all namespace-scoped memory atomically and report every affected surface.
264pub fn delete_namespace(
265    conn: &Connection,
266    namespace: &str,
267) -> Result<NamespaceDeleteReport, MemoryError> {
268    with_transaction(conn, |tx| {
269        let mut report = NamespaceDeleteReport::default();
270        let delete_session = |session_id: &str| -> Result<(usize, usize), MemoryError> {
271            let message_data: Vec<(i64, String, i64, bool)> = {
272                let mut stmt = tx.prepare(
273                    "SELECT m.id, m.content, mm.rowid, m.embedding IS NOT NULL
274                     FROM messages m
275                     JOIN messages_rowid_map mm ON mm.message_id = m.id
276                     WHERE m.session_id = ?1",
277                )?;
278                let rows = stmt.query_map(params![session_id], |row| {
279                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
280                })?;
281                rows.collect::<Result<Vec<_>, _>>()?
282            };
283
284            for (message_id, content, fts_rowid, has_embedding) in &message_data {
285                #[cfg(not(feature = "hnsw"))]
286                let _ = (message_id, has_embedding);
287                tx.execute(
288                    "INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', ?1, ?2)",
289                    params![fts_rowid, content],
290                )?;
291                #[cfg(feature = "hnsw")]
292                if *has_embedding {
293                    enqueue_pending_index_op(
294                        tx,
295                        &format!("msg:{}", message_id),
296                        "message",
297                        PendingIndexOpKind::Delete,
298                    )?;
299                }
300            }
301
302            let affected = tx.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
303            if affected == 0 {
304                return Err(MemoryError::SessionNotFound(session_id.to_string()));
305            }
306            let hnsw_ops = message_data
307                .iter()
308                .filter(|(_, _, _, has_embedding)| *has_embedding)
309                .count();
310            Ok((message_data.len(), hnsw_ops))
311        };
312
313        let document_ids: Vec<String> = {
314            let mut stmt = tx.prepare("SELECT id FROM documents WHERE namespace = ?1")?;
315            let ids = stmt
316                .query_map(params![namespace], |row| row.get(0))?
317                .collect::<Result<Vec<_>, _>>()?;
318            ids
319        };
320
321        let session_ids: Vec<String> = {
322            let mut stmt = tx.prepare("SELECT id, metadata FROM sessions")?;
323            let rows = stmt.query_map([], |row| {
324                Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
325            })?;
326            let mut ids = Vec::new();
327            for row in rows {
328                let (session_id, metadata_raw) = row?;
329                let metadata = parse_optional_json(
330                    "sessions",
331                    &session_id,
332                    "metadata",
333                    metadata_raw.as_deref(),
334                )?;
335                let namespace_matches = metadata
336                    .as_ref()
337                    .and_then(|value| {
338                        value
339                            .get("namespace")
340                            .or_else(|| value.get("scope_namespace"))
341                    })
342                    .and_then(|value| value.as_str())
343                    == Some(namespace);
344                if namespace_matches {
345                    ids.push(session_id);
346                }
347            }
348            ids
349        };
350
351        for session_id in &session_ids {
352            let (messages, hnsw_ops) = delete_session(session_id)?;
353            report.messages += messages;
354            report.hnsw_ops += hnsw_ops;
355        }
356        report.sessions = session_ids.len();
357
358        let delete_derivation_edges_for_id = |kind: &str, id: &str| -> Result<(), MemoryError> {
359            tx.execute(
360                "DELETE FROM derivation_edges
361                 WHERE (source_kind = ?1 AND source_id = ?2)
362                    OR (target_kind = ?1 AND target_id = ?2)",
363                params![kind, id],
364            )?;
365            Ok(())
366        };
367
368        let delete_derivation_edges_for_ids =
369            |kind: &str, ids: &[String]| -> Result<(), MemoryError> {
370                for id in ids {
371                    delete_derivation_edges_for_id(kind, id)?;
372                }
373                Ok(())
374            };
375
376        let facts: Vec<(String, i64, String)> = {
377            let mut stmt = tx.prepare(
378                "SELECT f.id, fm.rowid, f.content
379                 FROM facts f
380                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
381                 WHERE f.namespace = ?1",
382            )?;
383            let facts = stmt
384                .query_map(params![namespace], |row| {
385                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
386                })?
387                .collect::<Result<Vec<_>, _>>()?;
388            facts
389        };
390
391        for (fact_id, fts_rowid, content) in &facts {
392            tx.execute(
393                "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
394                params![fts_rowid, content],
395            )?;
396            tx.execute(
397                "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
398                params![fact_id],
399            )?;
400
401            #[cfg(feature = "hnsw")]
402            enqueue_pending_index_op(
403                tx,
404                &format!("fact:{}", fact_id),
405                "fact",
406                PendingIndexOpKind::Delete,
407            )?;
408            #[cfg(feature = "hnsw")]
409            {
410                report.hnsw_ops += 1;
411            }
412        }
413        tx.execute("DELETE FROM facts WHERE namespace = ?1", params![namespace])?;
414        report.facts = facts.len();
415
416        for doc_id in &document_ids {
417            let mut stmt = tx.prepare(
418                "SELECT c.id, c.content, cm.rowid
419                 FROM chunks c
420                 JOIN chunks_rowid_map cm ON cm.chunk_id = c.id
421                 WHERE c.document_id = ?1",
422            )?;
423            let chunk_rows: Vec<(String, String, i64)> = stmt
424                .query_map(params![doc_id], |row| {
425                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
426                })?
427                .collect::<Result<Vec<_>, _>>()?;
428            report.chunks += chunk_rows.len();
429
430            for (chunk_id, content, fts_rowid) in &chunk_rows {
431                tx.execute(
432                    "INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', ?1, ?2)",
433                    params![fts_rowid, content],
434                )?;
435                tx.execute(
436                    "DELETE FROM chunks_rowid_map WHERE chunk_id = ?1",
437                    params![chunk_id],
438                )?;
439                #[cfg(feature = "hnsw")]
440                enqueue_pending_index_op(
441                    tx,
442                    &format!("chunk:{}", chunk_id),
443                    "chunk",
444                    PendingIndexOpKind::Delete,
445                )?;
446                #[cfg(feature = "hnsw")]
447                {
448                    report.hnsw_ops += 1;
449                }
450            }
451
452            tx.execute("DELETE FROM chunks WHERE document_id = ?1", params![doc_id])?;
453        }
454
455        for doc_id in &document_ids {
456            let mut stmt = tx.prepare(
457                "SELECT e.episode_id, e.search_text, erm.rowid
458                 FROM episodes e
459                 JOIN episodes_rowid_map erm ON erm.episode_id = e.episode_id
460                 WHERE e.document_id = ?1",
461            )?;
462            let episode_rows: Vec<(String, String, i64)> = stmt
463                .query_map(params![doc_id], |row| {
464                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
465                })?
466                .collect::<Result<Vec<_>, _>>()?;
467            report.episodes += episode_rows.len();
468
469            for (episode_id, search_text, fts_rowid) in &episode_rows {
470                tx.execute(
471                    "INSERT INTO episodes_fts(episodes_fts, rowid, content) VALUES ('delete', ?1, ?2)",
472                    params![fts_rowid, search_text],
473                )?;
474                tx.execute(
475                    "DELETE FROM episodes_rowid_map WHERE episode_id = ?1",
476                    params![episode_id],
477                )?;
478                tx.execute(
479                    "DELETE FROM episode_causes WHERE episode_id = ?1",
480                    params![episode_id],
481                )?;
482                #[cfg(feature = "hnsw")]
483                enqueue_pending_index_op(
484                    tx,
485                    &episodes::episode_item_key(episode_id),
486                    "episode",
487                    PendingIndexOpKind::Delete,
488                )?;
489                #[cfg(feature = "hnsw")]
490                {
491                    report.hnsw_ops += 1;
492                }
493            }
494
495            tx.execute(
496                "DELETE FROM episodes WHERE document_id = ?1",
497                params![doc_id],
498            )?;
499            tx.execute("DELETE FROM documents WHERE id = ?1", params![doc_id])?;
500        }
501        report.documents = document_ids.len();
502
503        let claim_ids: Vec<String> = {
504            let mut stmt =
505                tx.prepare("SELECT claim_id FROM claim_versions WHERE scope_namespace = ?1")?;
506            let ids = stmt
507                .query_map(params![namespace], |row| row.get(0))?
508                .collect::<Result<Vec<_>, _>>()?;
509            ids
510        };
511
512        let claim_version_ids: Vec<String> = {
513            let mut stmt = tx.prepare(
514                "SELECT claim_version_id FROM claim_versions WHERE scope_namespace = ?1",
515            )?;
516            let ids = stmt
517                .query_map(params![namespace], |row| row.get(0))?
518                .collect::<Result<Vec<_>, _>>()?;
519            ids
520        };
521
522        let relation_version_ids: Vec<String> = {
523            let mut stmt = tx.prepare(
524                "SELECT relation_version_id FROM relation_versions WHERE scope_namespace = ?1",
525            )?;
526            let ids = stmt
527                .query_map(params![namespace], |row| row.get(0))?
528                .collect::<Result<Vec<_>, _>>()?;
529            ids
530        };
531
532        let alias_entity_ids: Vec<String> = {
533            let mut stmt = tx.prepare(
534                "SELECT canonical_entity_id FROM entity_aliases WHERE scope_namespace = ?1",
535            )?;
536            let ids = stmt
537                .query_map(params![namespace], |row| row.get(0))?
538                .collect::<Result<Vec<_>, _>>()?;
539            ids
540        };
541
542        let evidence_handles: Vec<String> = {
543            let mut stmt = tx.prepare(
544                "SELECT er.fetch_handle FROM evidence_refs er
545                 JOIN projection_import_log pil ON er.source_envelope_id = pil.source_envelope_id
546                 WHERE pil.scope_namespace = ?1",
547            )?;
548            let handles = stmt
549                .query_map(params![namespace], |row| row.get(0))?
550                .collect::<Result<Vec<_>, _>>()?;
551            handles
552        };
553
554        let episode_ids: Vec<String> = {
555            let mut stmt = tx.prepare(
556                "SELECT episode_id FROM episode_links
557                 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
558            )?;
559            let ids = stmt
560                .query_map(params![namespace], |row| row.get(0))?
561                .collect::<Result<Vec<_>, _>>()?;
562            ids
563        };
564
565        delete_derivation_edges_for_ids("claim", &claim_ids)?;
566        delete_derivation_edges_for_ids("claim_version", &claim_version_ids)?;
567        delete_derivation_edges_for_ids("relation_version", &relation_version_ids)?;
568        delete_derivation_edges_for_ids("entity", &alias_entity_ids)?;
569        delete_derivation_edges_for_ids("evidence_ref", &evidence_handles)?;
570        delete_derivation_edges_for_ids("episode", &episode_ids)?;
571
572        report.projection_rows += tx.execute(
573            "DELETE FROM claim_versions WHERE scope_namespace = ?1",
574            params![namespace],
575        )?;
576        report.projection_rows += tx.execute(
577            "DELETE FROM relation_versions WHERE scope_namespace = ?1",
578            params![namespace],
579        )?;
580        report.projection_rows += tx.execute(
581            "DELETE FROM entity_aliases WHERE scope_namespace = ?1",
582            params![namespace],
583        )?;
584        report.projection_rows += tx.execute(
585            "DELETE FROM evidence_refs
586             WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
587            params![namespace],
588        )?;
589        report.projection_rows += tx.execute(
590            "DELETE FROM episode_links
591             WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
592            params![namespace],
593        )?;
594        report.projection_rows += tx.execute(
595            "DELETE FROM projection_import_failures WHERE scope_namespace = ?1",
596            params![namespace],
597        )?;
598        report.projection_rows += tx.execute(
599            "DELETE FROM projection_import_log WHERE scope_namespace = ?1",
600            params![namespace],
601        )?;
602
603        Ok(report)
604    })
605}
606
607/// Get a fact by ID.
608pub fn get_fact(conn: &Connection, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
609    let result = conn.query_row(
610        "SELECT id, namespace, content, source, created_at, updated_at, metadata
611         FROM facts WHERE id = ?1",
612        params![fact_id],
613        |row| {
614            Ok((
615                row.get::<_, String>(0)?,
616                row.get::<_, String>(1)?,
617                row.get::<_, String>(2)?,
618                row.get::<_, Option<String>>(3)?,
619                row.get::<_, String>(4)?,
620                row.get::<_, String>(5)?,
621                row.get::<_, Option<String>>(6)?,
622            ))
623        },
624    );
625
626    match result {
627        Ok((id, namespace, content, source, created_at, updated_at, metadata_raw)) => {
628            Ok(Some(Fact {
629                metadata: parse_optional_json("facts", &id, "metadata", metadata_raw.as_deref())?,
630                id,
631                namespace,
632                content,
633                source,
634                created_at,
635                updated_at,
636            }))
637        }
638        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
639        Err(err) => Err(MemoryError::Database(err)),
640    }
641}
642
643/// Get a fact embedding vector.
644pub fn get_fact_embedding(
645    conn: &Connection,
646    fact_id: &str,
647) -> Result<Option<Vec<f32>>, MemoryError> {
648    let result: Result<Option<Vec<u8>>, _> = conn.query_row(
649        "SELECT embedding FROM facts WHERE id = ?1",
650        params![fact_id],
651        |row| row.get(0),
652    );
653
654    match result {
655        Ok(Some(bytes)) => Ok(Some(bytes_to_embedding(&bytes)?)),
656        Ok(None) => Ok(None),
657        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
658        Err(err) => Err(MemoryError::Database(err)),
659    }
660}
661
662/// List the distinct namespaces that currently contain facts.
663pub fn list_fact_namespaces(conn: &Connection) -> Result<Vec<String>, MemoryError> {
664    let mut stmt = conn.prepare("SELECT DISTINCT namespace FROM facts ORDER BY namespace")?;
665    let rows = stmt
666        .query_map([], |row| row.get::<_, String>(0))?
667        .collect::<Result<Vec<_>, _>>()?;
668    Ok(rows)
669}
670
671/// List facts within a namespace.
672#[allow(dead_code)] // retained as an internal compatibility seam for older callers
673pub fn list_facts(
674    conn: &Connection,
675    namespace: &str,
676    limit: usize,
677    offset: usize,
678) -> Result<Vec<Fact>, MemoryError> {
679    list_facts_with_view(conn, namespace, limit, offset, &StateView::Current)
680}
681
682/// Authority state selected by a fact retrieval.
683#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
684pub enum StateView {
685    Current,
686    HistoricalAt(String),
687    RecordedAsOf(String),
688    IncludeSuperseded,
689}
690
691pub(crate) fn fact_is_visible_with_view(
692    conn: &Connection,
693    fact_id: &str,
694    view: &StateView,
695) -> Result<bool, MemoryError> {
696    let forgotten: bool = conn.query_row(
697        "SELECT EXISTS(SELECT 1 FROM forgotten_facts WHERE fact_id = ?1)",
698        params![fact_id],
699        |row| row.get(0),
700    )?;
701    if forgotten {
702        return Ok(false);
703    }
704    let cutoff = match view {
705        StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
706            let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
707                MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
708            })?;
709            Some(
710                parsed
711                    .with_timezone(&chrono::Utc)
712                    .format("%Y-%m-%d %H:%M:%S%.6f")
713                    .to_string(),
714            )
715        }
716        _ => None,
717    };
718    let include_superseded = matches!(view, StateView::IncludeSuperseded);
719    let visible: i64 = conn.query_row(
720        "SELECT EXISTS(
721             SELECT 1 FROM facts f
722             WHERE f.id = ?1
723               AND (?2 IS NULL OR f.created_at <= ?2)
724               AND (?3 = 1 OR NOT EXISTS (
725                   SELECT 1 FROM graph_edges ge
726                   WHERE ge.target = 'fact:' || f.id
727                     AND ge.is_invalidated = 0
728                     AND COALESCE(
729                         json_extract(ge.edge_type, '$.relation'),
730                         json_extract(ge.edge_type, '$.entity.relation')
731                     ) IN ('supersedes', 'redacts')
732                     AND (?2 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?2)
733               ))
734         )",
735        params![fact_id, cutoff, include_superseded],
736        |row| row.get(0),
737    )?;
738    Ok(visible != 0)
739}
740
741/// List facts under an explicit authority-state view. Inconsistent lineage is rejected.
742pub fn list_facts_with_view(
743    conn: &Connection,
744    namespace: &str,
745    limit: usize,
746    offset: usize,
747    view: &StateView,
748) -> Result<Vec<Fact>, MemoryError> {
749    let cutoff = match view {
750        StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
751            let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
752                MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
753            })?;
754            Some(
755                parsed
756                    .with_timezone(&chrono::Utc)
757                    .format("%Y-%m-%d %H:%M:%S%.6f")
758                    .to_string(),
759            )
760        }
761        _ => None,
762    };
763    let inconsistent: i64 = conn.query_row(
764        "SELECT COUNT(*) FROM (
765             SELECT target FROM graph_edges
766             WHERE is_invalidated = 0
767               AND COALESCE(
768                   json_extract(edge_type, '$.relation'),
769                   json_extract(edge_type, '$.entity.relation')
770               ) IN ('supersedes', 'redacts')
771               AND (?1 IS NULL OR COALESCE(recorded_time, recorded_at) <= ?1)
772             GROUP BY target HAVING COUNT(DISTINCT source) > 1
773         )",
774        params![cutoff.as_deref()],
775        |row| row.get(0),
776    )?;
777    if inconsistent != 0 {
778        return Err(MemoryError::Other(
779            "inconsistent fact lineage: multiple active heads".into(),
780        ));
781    }
782    let include_superseded = matches!(view, StateView::IncludeSuperseded);
783    let mut stmt = conn.prepare(
784        "SELECT id, namespace, content, source, created_at, updated_at, metadata
785         FROM facts
786         WHERE namespace = ?1
787           AND NOT EXISTS (
788               SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = facts.id
789           )
790           AND (?4 IS NULL OR created_at <= ?4)
791           AND (?5 = 1 OR NOT EXISTS (
792               SELECT 1 FROM graph_edges ge
793               WHERE ge.target = 'fact:' || facts.id
794                 AND ge.is_invalidated = 0
795                 AND COALESCE(
796                     json_extract(ge.edge_type, '$.relation'),
797                     json_extract(ge.edge_type, '$.entity.relation')
798                 ) IN ('supersedes', 'redacts')
799                 AND (?4 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?4)
800           ))
801         ORDER BY updated_at DESC
802         LIMIT ?2 OFFSET ?3",
803    )?;
804
805    let facts = stmt
806        .query_map(
807            params![
808                namespace,
809                limit as i64,
810                offset as i64,
811                cutoff,
812                include_superseded
813            ],
814            |row| {
815                Ok((
816                    row.get::<_, String>(0)?,
817                    row.get::<_, String>(1)?,
818                    row.get::<_, String>(2)?,
819                    row.get::<_, Option<String>>(3)?,
820                    row.get::<_, String>(4)?,
821                    row.get::<_, String>(5)?,
822                    row.get::<_, Option<String>>(6)?,
823                ))
824            },
825        )?
826        .collect::<Result<Vec<_>, _>>()?
827        .into_iter()
828        .map(
829            |(id, namespace, content, source, created_at, updated_at, metadata_raw)| {
830                Ok(Fact {
831                    metadata: parse_optional_json(
832                        "facts",
833                        &id,
834                        "metadata",
835                        metadata_raw.as_deref(),
836                    )?,
837                    id,
838                    namespace,
839                    content,
840                    source,
841                    created_at,
842                    updated_at,
843                })
844            },
845        )
846        .collect::<Result<Vec<_>, MemoryError>>()?;
847
848    Ok(facts)
849}
850
851impl MemoryStore {
852    /// Explicitly ungoverned compatibility write.
853    ///
854    /// This preserves the pre-authority raw storage API for migrations and local tooling. It does
855    /// not create an origin label and its output is therefore denied by every governed path.
856    pub async fn add_fact_raw_compat(
857        &self,
858        namespace: &str,
859        content: &str,
860        source: Option<&str>,
861        metadata: Option<serde_json::Value>,
862        trace_ctx: Option<&TraceCtx>,
863    ) -> Result<Fact, MemoryError> {
864        let id = self
865            .add_fact_with_trace(namespace, content, source, metadata, trace_ctx)
866            .await?;
867        self.get_fact(&id)
868            .await?
869            .ok_or(MemoryError::FactNotFound(id))
870    }
871
872    /// Store a fact with automatic embedding. Returns the fact ID (UUID v4).
873    ///
874    /// This is a non-authoritative storage primitive. Governed mutations must
875    /// use [`MemoryStore::authority`] so admission and lineage are enforced.
876    pub async fn add_fact(
877        &self,
878        namespace: &str,
879        content: &str,
880        source: Option<&str>,
881        metadata: Option<serde_json::Value>,
882    ) -> Result<String, MemoryError> {
883        self.add_fact_with_trace(namespace, content, source, metadata, None)
884            .await
885    }
886
887    /// Store a fact with automatic embedding and optional trace metadata.
888    pub async fn add_fact_with_trace(
889        &self,
890        namespace: &str,
891        content: &str,
892        source: Option<&str>,
893        metadata: Option<serde_json::Value>,
894        trace_ctx: Option<&TraceCtx>,
895    ) -> Result<String, MemoryError> {
896        self.validate_content("fact.content", content)?;
897
898        // Dedup: check if a fact with the same content already exists.
899        // This prevents the 4-5% DB bloat from duplicate ingestion.
900        let ns_check = namespace.to_string();
901        let ct_check = content.to_string();
902        let existing_id = self
903            .with_read_conn(move |conn| {
904                let result: Option<String> = conn
905                    .query_row(
906                        "SELECT id FROM facts WHERE content = ?1 AND namespace = ?2 LIMIT 1",
907                        rusqlite::params![&ct_check, &ns_check],
908                        |row| row.get::<_, String>(0),
909                    )
910                    .ok();
911                Ok(result)
912            })
913            .await?;
914
915        if let Some(id) = existing_id {
916            return Ok(id);
917        }
918
919        let (embedding, sparse, sparse_representation) =
920            self.embed_text_with_sparse_internal(content).await?;
921        self.validate_embedding_dimensions(&embedding)?;
922        let embedding_bytes = db::embedding_to_bytes(&embedding);
923        let fact_id = uuid::Uuid::new_v4().to_string();
924        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
925
926        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
927        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
928        let q8_bytes = quantizer
929            .quantize(&embedding)
930            .map(|qv| quantize::pack_quantized(&qv))
931            .ok();
932
933        let ns = namespace.to_string();
934        let ct = content.to_string();
935        let fid = fact_id.clone();
936        let src = source.map(|s| s.to_string());
937        let meta = merge_trace_ctx(metadata, trace_ctx);
938        self.with_write_conn(move |conn| {
939            let current_count: usize = conn.query_row(
940                "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
941                rusqlite::params![&ns],
942                |row| row.get(0),
943            )?;
944            if current_count >= max_facts_per_namespace {
945                return Err(MemoryError::NamespaceFull {
946                    namespace: ns.clone(),
947                    count: current_count,
948                    limit: max_facts_per_namespace,
949                });
950            }
951            insert_fact_with_fts_q8(
952                conn,
953                &fid,
954                &ns,
955                &ct,
956                &embedding_bytes,
957                q8_bytes.as_deref(),
958                src.as_deref(),
959                meta.as_ref(),
960                sparse.as_ref().zip(sparse_representation.as_deref()),
961            )
962        })
963        .await?;
964
965        self.clear_search_cache();
966
967        #[cfg(feature = "hnsw")]
968        self.sync_pending_hnsw_ops_best_effort("add_fact").await;
969
970        Ok(fact_id)
971    }
972
973    /// Store a fact with a pre-computed embedding.
974    pub async fn add_fact_with_embedding(
975        &self,
976        namespace: &str,
977        content: &str,
978        embedding: &[f32],
979        source: Option<&str>,
980        metadata: Option<serde_json::Value>,
981    ) -> Result<String, MemoryError> {
982        self.add_fact_with_embedding_and_trace(
983            namespace, content, embedding, source, metadata, None,
984        )
985        .await
986    }
987
988    /// Store a fact with a pre-computed embedding and optional trace metadata.
989    pub async fn add_fact_with_embedding_and_trace(
990        &self,
991        namespace: &str,
992        content: &str,
993        embedding: &[f32],
994        source: Option<&str>,
995        metadata: Option<serde_json::Value>,
996        trace_ctx: Option<&TraceCtx>,
997    ) -> Result<String, MemoryError> {
998        self.validate_content("fact.content", content)?;
999        self.validate_embedding_dimensions(embedding)?;
1000        let embedding_bytes = db::embedding_to_bytes(embedding);
1001        let sparse = self.inner.config.search.derive_sparse_from_dense.then(|| {
1002            crate::SparseWeights::from_dense(
1003                embedding,
1004                self.inner.config.search.sparse_derive_top_k,
1005                self.inner.config.search.sparse_derive_min_weight,
1006            )
1007        });
1008        let fact_id = uuid::Uuid::new_v4().to_string();
1009        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1010
1011        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
1012        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1013        let q8_bytes = quantizer
1014            .quantize(embedding)
1015            .map(|qv| quantize::pack_quantized(&qv))
1016            .ok();
1017
1018        let ns = namespace.to_string();
1019        let ct = content.to_string();
1020        let fid = fact_id.clone();
1021        let src = source.map(|s| s.to_string());
1022        let meta = merge_trace_ctx(metadata, trace_ctx);
1023        self.with_write_conn(move |conn| {
1024            let current_count: usize = conn.query_row(
1025                "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1026                rusqlite::params![&ns],
1027                |row| row.get(0),
1028            )?;
1029            if current_count >= max_facts_per_namespace {
1030                return Err(MemoryError::NamespaceFull {
1031                    namespace: ns.clone(),
1032                    count: current_count,
1033                    limit: max_facts_per_namespace,
1034                });
1035            }
1036            insert_fact_with_fts_q8(
1037                conn,
1038                &fid,
1039                &ns,
1040                &ct,
1041                &embedding_bytes,
1042                q8_bytes.as_deref(),
1043                src.as_deref(),
1044                meta.as_ref(),
1045                sparse
1046                    .as_ref()
1047                    .map(|weights| (weights, "generic_dense_derived_sparse")),
1048            )
1049        })
1050        .await?;
1051
1052        self.clear_search_cache();
1053
1054        #[cfg(feature = "hnsw")]
1055        self.sync_pending_hnsw_ops_best_effort("add_fact_with_embedding")
1056            .await;
1057
1058        Ok(fact_id)
1059    }
1060
1061    /// **DANGER**: This physically mutates/deletes a truth-bearing row.
1062    /// This is admin-only and gated behind the `admin-ops` feature.
1063    /// Default agent-facing APIs should use supersession (add a new fact
1064    /// with a supersession link) instead of hard delete/update.
1065    #[cfg(feature = "admin-ops")]
1066    pub async fn update_fact(&self, fact_id: &str, content: &str) -> Result<(), MemoryError> {
1067        self.validate_content("fact.content", content)?;
1068        let (embedding, sparse, sparse_representation) =
1069            self.embed_text_with_sparse_internal(content).await?;
1070        self.validate_embedding_dimensions(&embedding)?;
1071        let embedding_bytes = db::embedding_to_bytes(&embedding);
1072        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1073        let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1074            .quantize(&embedding)
1075            .map(|qv| quantize::pack_quantized(&qv))
1076            .ok();
1077
1078        let fid = fact_id.to_string();
1079        let ct = content.to_string();
1080        self.with_write_conn(move |conn| {
1081            update_fact_with_fts(conn, &fid, &ct, &embedding_bytes, q8_bytes.as_deref())?;
1082            let item_key = format!("fact:{fid}");
1083            if let Some((weights, representation)) =
1084                sparse.as_ref().zip(sparse_representation.as_deref())
1085            {
1086                db::store_sparse_vector(conn, &item_key, weights, representation)?;
1087            } else {
1088                db::delete_sparse_vector(conn, &item_key)?;
1089            }
1090            Ok(())
1091        })
1092        .await?;
1093
1094        #[cfg(feature = "hnsw")]
1095        self.sync_pending_hnsw_ops_best_effort("update_fact").await;
1096
1097        self.clear_search_cache();
1098
1099        Ok(())
1100    }
1101
1102    /// **DANGER**: This physically mutates/deletes a truth-bearing row.
1103    /// This is admin-only and gated behind the `admin-ops` feature.
1104    /// Default agent-facing APIs should use supersession (add a new fact
1105    /// with a supersession link) instead of hard delete/update.
1106    #[cfg(feature = "admin-ops")]
1107    pub async fn delete_fact(&self, fact_id: &str) -> Result<(), MemoryError> {
1108        let fid = fact_id.to_string();
1109        self.with_write_conn(move |conn| delete_fact_with_fts(conn, &fid))
1110            .await?;
1111
1112        #[cfg(feature = "hnsw")]
1113        self.sync_pending_hnsw_ops_best_effort("delete_fact").await;
1114
1115        self.clear_search_cache();
1116
1117        Ok(())
1118    }
1119
1120    /// Delete all memory in a namespace and return a per-surface report.
1121    pub async fn delete_namespace(
1122        &self,
1123        namespace: &str,
1124    ) -> Result<NamespaceDeleteReport, MemoryError> {
1125        let ns = namespace.to_string();
1126        let count = self
1127            .with_write_conn(move |conn| delete_namespace(conn, &ns))
1128            .await?;
1129
1130        #[cfg(feature = "hnsw")]
1131        self.sync_pending_hnsw_ops_best_effort("delete_namespace")
1132            .await;
1133
1134        self.clear_search_cache();
1135
1136        Ok(count)
1137    }
1138
1139    /// Get a fact by ID.
1140    pub async fn get_fact(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1141        let fid = fact_id.to_string();
1142        self.with_read_conn(move |conn| get_fact(conn, &fid)).await
1143    }
1144
1145    /// Explicitly ungoverned compatibility read. Prefer `authority().get_fact_governed`.
1146    pub async fn get_fact_raw_compat(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1147        self.get_fact(fact_id).await
1148    }
1149
1150    /// Get a fact's embedding vector.
1151    pub async fn get_fact_embedding(&self, fact_id: &str) -> Result<Option<Vec<f32>>, MemoryError> {
1152        let fid = fact_id.to_string();
1153        self.with_read_conn(move |conn| get_fact_embedding(conn, &fid))
1154            .await
1155    }
1156
1157    /// List all facts in a namespace using the default `Current` view.
1158    pub async fn list_facts(
1159        &self,
1160        namespace: &str,
1161        limit: usize,
1162        offset: usize,
1163    ) -> Result<Vec<Fact>, MemoryError> {
1164        self.list_facts_with_view(namespace, limit, offset, StateView::Current)
1165            .await
1166    }
1167
1168    /// List facts under an explicit bitemporal authority-state view.
1169    pub async fn list_facts_with_view(
1170        &self,
1171        namespace: &str,
1172        limit: usize,
1173        offset: usize,
1174        view: StateView,
1175    ) -> Result<Vec<Fact>, MemoryError> {
1176        let ns = namespace.to_string();
1177        self.with_read_conn(move |conn| list_facts_with_view(conn, &ns, limit, offset, &view))
1178            .await
1179    }
1180
1181    /// List the distinct namespaces that currently contain facts.
1182    pub async fn list_fact_namespaces(&self) -> Result<Vec<String>, MemoryError> {
1183        self.with_read_conn(move |conn| list_fact_namespaces(conn))
1184            .await
1185    }
1186}
1187
1188#[cfg(test)]
1189mod state_view_regression_tests {
1190    use super::*;
1191    use crate::db::run_migrations;
1192    use rusqlite::Connection;
1193
1194    fn seeded() -> Connection {
1195        let conn = Connection::open_in_memory().unwrap();
1196        run_migrations(&conn).unwrap();
1197        for (id, content, created) in [
1198            ("old", "same topic old", "2026-07-10 21:00:00"),
1199            ("new", "same topic new", "2026-07-10 21:12:01"),
1200        ] {
1201            conn.execute(
1202                "INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES (?1, 'n', ?2, ?3, ?3)",
1203                params![id, content, created],
1204            ).unwrap();
1205        }
1206        conn
1207    }
1208
1209    fn supersedes(conn: &Connection, source: &str, target: &str, recorded: &str) {
1210        conn.execute(
1211            "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1212             VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"type\":\"entity\",\"relation\":\"supersedes\"}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1213            params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1214        ).unwrap();
1215    }
1216
1217    fn supersedes_canonical(conn: &Connection, source: &str, target: &str, recorded: &str) {
1218        conn.execute(
1219            "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1220             VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"entity\":{\"relation\":\"supersedes\"}}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1221            params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1222        ).unwrap();
1223    }
1224
1225    #[test]
1226    fn historical_view_excludes_future_fact_and_reconstructs_pre_supersession_head() {
1227        let conn = seeded();
1228        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1229        let rows = list_facts_with_view(
1230            &conn,
1231            "n",
1232            10,
1233            0,
1234            &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1235        )
1236        .unwrap();
1237        assert_eq!(
1238            rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1239            ["old"]
1240        );
1241    }
1242
1243    #[test]
1244    fn historical_view_preserves_pre_adjudication_conflict() {
1245        let conn = seeded();
1246        conn.execute(
1247            "UPDATE facts SET created_at = '2026-07-10 21:10:00', updated_at = '2026-07-10 21:10:00' WHERE id = 'new'",
1248            [],
1249        )
1250        .unwrap();
1251        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1252
1253        let rows = list_facts_with_view(
1254            &conn,
1255            "n",
1256            10,
1257            0,
1258            &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1259        )
1260        .unwrap();
1261        let ids = rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>();
1262        assert!(
1263            ids.contains(&"old"),
1264            "prior observation must remain visible"
1265        );
1266        assert!(ids.contains(&"new"), "conflicting observation created before the cutoff must remain visible until adjudication");
1267    }
1268
1269    #[test]
1270    fn current_view_excludes_superseded_fact() {
1271        let conn = seeded();
1272        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1273        let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1274        assert_eq!(
1275            rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1276            ["new"]
1277        );
1278    }
1279
1280    #[test]
1281    fn current_view_accepts_canonical_entity_edge_serialization() {
1282        let conn = seeded();
1283        supersedes_canonical(&conn, "new", "old", "2026-07-10 21:12:01");
1284        let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1285        assert_eq!(
1286            rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>(),
1287            vec!["new"]
1288        );
1289    }
1290
1291    #[test]
1292    fn multiple_active_heads_fail_closed() {
1293        let conn = seeded();
1294        conn.execute("INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES ('other', 'n', 'same topic conflicting', '2026-07-10 21:13:00', '2026-07-10 21:13:00')", []).unwrap();
1295        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1296        supersedes(&conn, "other", "old", "2026-07-10 21:13:00");
1297        assert!(list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).is_err());
1298    }
1299}