Skip to main content

mnemo_core/query/
consolidate.rs

1//! Topic-document consolidation primitive (v0.5.0).
2//!
3//! Anchored on **Infini-Memory** (arXiv:2606.10677) — "each topic document
4//! serves as a semantic unit for collecting related evidence, preserving
5//! metadata, and revising facts over time."
6//!
7//! [`execute`] groups a *caller-chosen* set of member memories into a single
8//! revisable **topic document**: it collects evidence (the member ids), it
9//! preserves provenance (per-member source, timestamp, confidence), it supports
10//! fact revision (supersede an earlier topic document while keeping the old row
11//! and the hash-chain history), and the result is retrievable as a unit (a
12//! normal recallable [`MemoryRecord`] plus `consolidated_from` relations).
13//!
14//! This is the caller-driven, by-id, revisable sibling of the offline
15//! tag-cluster pass in [`super::lifecycle::run_consolidation`]. It is
16//! deterministic (no LLM): with no caller `summary`, the document content is a
17//! stable join of the member contents ordered by `(created_at, id)`, so the
18//! same inputs always yield the same document. Being a plain engine primitive,
19//! it flows identically through MCP, REST, and gRPC.
20
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24use crate::error::{Error, Result};
25use crate::hash::{compute_chain_hash, compute_content_hash};
26use crate::model::acl::Permission;
27use crate::model::event::EventType;
28use crate::model::memory::{ConsolidationState, MemoryRecord, MemoryType, Scope, SourceType};
29use crate::model::relation::Relation;
30use crate::query::MnemoEngine;
31#[allow(unused_imports)]
32use base64::Engine as _;
33
34/// Request to consolidate a set of member memories into one topic document.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ConsolidateRequest {
37    /// The member memories to collect as evidence. Must be non-empty.
38    /// Duplicates are ignored. Every id must exist, be readable by the
39    /// resolved agent, and not be soft-deleted.
40    pub memory_ids: Vec<Uuid>,
41    /// The topic document's name / fact key. Stored in `metadata.topic`,
42    /// used as the document tag, and (absent `summary`) as the heading.
43    pub topic_name: String,
44    /// Agent that owns the new topic document. Defaults to
45    /// `engine.default_agent_id`.
46    pub agent_id: Option<String>,
47    /// Optional caller-supplied document body. When omitted (or blank) the
48    /// body is synthesised deterministically from the member contents.
49    pub summary: Option<String>,
50    /// Optional id of an existing topic document this one revises. When set,
51    /// the new document becomes `version = old.version + 1` with
52    /// `prev_version_id = old.id`; the old document is retained (marked
53    /// `Consolidated` with a `superseded_by` pointer, NOT deleted, so the hash
54    /// chain stays whole), and a [`EventType::MemoryRevised`] audit event is
55    /// emitted alongside the consolidation event.
56    pub supersede: Option<Uuid>,
57    /// Optional thread/session scope for the topic document and its events.
58    pub thread_id: Option<String>,
59    /// Optional caller metadata, merged under the provenance keys this
60    /// primitive writes (`topic`, `consolidated_from`, `members`, `revision_of`).
61    pub metadata: Option<serde_json::Value>,
62}
63
64impl ConsolidateRequest {
65    /// Construct a request with only the required fields set.
66    pub fn new(memory_ids: Vec<Uuid>, topic_name: String) -> Self {
67        Self {
68            memory_ids,
69            topic_name,
70            agent_id: None,
71            summary: None,
72            supersede: None,
73            thread_id: None,
74            metadata: None,
75        }
76    }
77}
78
79/// Result of a consolidation.
80#[non_exhaustive]
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ConsolidateResponse {
83    /// Id of the newly created topic document.
84    pub topic_document_id: Uuid,
85    /// Echoes the request topic name.
86    pub topic_name: String,
87    /// Number of distinct member memories collected.
88    pub source_count: usize,
89    /// Version of the topic document (1, or `old.version + 1` on revision).
90    pub version: u32,
91    /// Id of the topic document this one superseded, if any.
92    pub superseded_id: Option<Uuid>,
93    /// The distinct member ids, in the deterministic order used for synthesis.
94    pub member_ids: Vec<Uuid>,
95    /// Hex-encoded SHA-256 content hash of the (plaintext) document body.
96    pub content_hash: String,
97    /// Id of the emitted [`EventType::MemoryConsolidated`] audit event.
98    pub consolidation_event_id: Uuid,
99    /// Id of the emitted [`EventType::MemoryRevised`] audit event (revision only).
100    pub revision_event_id: Option<Uuid>,
101}
102
103/// Decrypt a record's content in place if engine-level encryption is configured.
104/// Mirrors the read-path decryption used by `recall`.
105fn decrypt_in_place(engine: &MnemoEngine, record: &mut MemoryRecord) {
106    if let Some(ref enc) = engine.encryption {
107        match base64::engine::general_purpose::STANDARD.decode(&record.content) {
108            Ok(bytes) => match enc.decrypt(&bytes) {
109                Ok(plain) => match String::from_utf8(plain) {
110                    Ok(text) => record.content = text,
111                    Err(e) => {
112                        tracing::error!(memory_id = %record.id, error = %e, "decrypted content is not valid UTF-8");
113                        record.content = "[content unavailable: decryption error]".to_string();
114                    }
115                },
116                Err(e) => {
117                    tracing::error!(memory_id = %record.id, error = %e, "failed to decrypt member content");
118                    record.content = "[content unavailable: decryption error]".to_string();
119                }
120            },
121            Err(e) => {
122                tracing::error!(memory_id = %record.id, error = %e, "failed to decode encrypted member content");
123                record.content = "[content unavailable: decryption error]".to_string();
124            }
125        }
126    }
127}
128
129pub async fn execute(
130    engine: &MnemoEngine,
131    request: ConsolidateRequest,
132) -> Result<ConsolidateResponse> {
133    // --- Validate ---------------------------------------------------------
134    if request.memory_ids.is_empty() {
135        return Err(Error::Validation("memory_ids cannot be empty".to_string()));
136    }
137    let topic = request.topic_name.trim().to_string();
138    if topic.is_empty() {
139        return Err(Error::Validation("topic_name cannot be empty".to_string()));
140    }
141    let agent_id = request
142        .agent_id
143        .clone()
144        .unwrap_or_else(|| engine.default_agent_id.clone());
145    super::validate_agent_id(&agent_id)?;
146
147    // --- Collect evidence: fetch, permission-gate, decrypt ----------------
148    // Only `accessible ∩ requested` proceeds; a missing/denied/deleted member
149    // aborts the whole operation so nothing partial is written.
150    let mut members: Vec<MemoryRecord> = Vec::with_capacity(request.memory_ids.len());
151    let mut seen: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
152    for id in &request.memory_ids {
153        if !seen.insert(*id) {
154            continue;
155        }
156        let mut record = engine
157            .storage
158            .get_memory(*id)
159            .await?
160            .ok_or_else(|| Error::NotFound(format!("memory {id} not found")))?;
161        if record.is_deleted() {
162            return Err(Error::Validation(format!(
163                "memory {id} is deleted and cannot be consolidated"
164            )));
165        }
166        if !engine
167            .storage
168            .check_permission(*id, &agent_id, Permission::Read)
169            .await?
170        {
171            return Err(Error::PermissionDenied(format!(
172                "agent {agent_id} cannot read memory {id}"
173            )));
174        }
175        decrypt_in_place(engine, &mut record);
176        members.push(record);
177    }
178    // Deterministic ordering: oldest first, ties broken by id.
179    members.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
180
181    // --- Revision bookkeeping --------------------------------------------
182    let (version, prev_version_id, superseded_id) = match request.supersede {
183        Some(old_id) => {
184            let old = engine.storage.get_memory(old_id).await?.ok_or_else(|| {
185                Error::NotFound(format!("topic document {old_id} to supersede not found"))
186            })?;
187            if old.agent_id != agent_id {
188                return Err(Error::PermissionDenied(format!(
189                    "agent {agent_id} cannot supersede topic document {old_id}"
190                )));
191            }
192            (old.version.saturating_add(1), Some(old_id), Some(old_id))
193        }
194        None => (1, None, None),
195    };
196
197    // --- Synthesise the topic-document body (deterministic) ---------------
198    let content_plain = match request.summary.as_ref() {
199        Some(s) if !s.trim().is_empty() => s.clone(),
200        _ => {
201            let mut body = format!("# {topic}\n\n");
202            for (i, m) in members.iter().enumerate() {
203                if i > 0 {
204                    body.push_str("\n\n");
205                }
206                body.push_str(&m.content);
207            }
208            body
209        }
210    };
211
212    // --- Preserve metadata / provenance ----------------------------------
213    let consolidated_from: Vec<String> = members.iter().map(|m| m.id.to_string()).collect();
214    let member_meta: Vec<serde_json::Value> = members
215        .iter()
216        .map(|m| {
217            serde_json::json!({
218                "id": m.id.to_string(),
219                "source_type": m.source_type.to_string(),
220                "source_id": m.source_id,
221                "created_at": m.created_at,
222                "importance": m.importance,
223            })
224        })
225        .collect();
226    let mut meta_map = match request.metadata.clone() {
227        Some(serde_json::Value::Object(m)) => m,
228        _ => serde_json::Map::new(),
229    };
230    meta_map.insert("topic".to_string(), serde_json::json!(topic));
231    meta_map.insert(
232        "consolidated_from".to_string(),
233        serde_json::json!(consolidated_from),
234    );
235    meta_map.insert("members".to_string(), serde_json::json!(member_meta));
236    if let Some(sid) = superseded_id {
237        meta_map.insert(
238            "revision_of".to_string(),
239            serde_json::json!(sid.to_string()),
240        );
241    }
242    let metadata = serde_json::Value::Object(meta_map);
243
244    // --- Build the topic-document record ----------------------------------
245    let now = chrono::Utc::now().to_rfc3339();
246    let id = Uuid::now_v7();
247    let content_hash = compute_content_hash(&content_plain, &agent_id, &now);
248    let prev_hash_raw = engine
249        .storage
250        .get_latest_memory_hash(&agent_id, request.thread_id.as_deref())
251        .await?;
252    let prev_hash = Some(compute_chain_hash(&content_hash, prev_hash_raw.as_deref()));
253    let importance = members.iter().map(|m| m.importance).fold(0.0_f32, f32::max);
254    let scope = members.first().map(|m| m.scope).unwrap_or(Scope::Private);
255    let org_id = members
256        .iter()
257        .find_map(|m| m.org_id.clone())
258        .or_else(|| engine.default_org_id.clone());
259    let embedding = engine.embedding.embed(&content_plain).await?;
260
261    let mut record = MemoryRecord {
262        id,
263        agent_id: agent_id.clone(),
264        content: content_plain.clone(),
265        memory_type: MemoryType::Semantic,
266        scope,
267        importance,
268        tags: vec![topic.clone()],
269        metadata,
270        embedding: Some(embedding.clone()),
271        content_hash: content_hash.clone(),
272        prev_hash,
273        source_type: SourceType::Consolidation,
274        source_id: None,
275        consolidation_state: ConsolidationState::Active,
276        access_count: 0,
277        org_id,
278        thread_id: request.thread_id.clone(),
279        created_at: now.clone(),
280        updated_at: now.clone(),
281        last_accessed_at: None,
282        expires_at: None,
283        deleted_at: None,
284        decay_rate: None,
285        created_by: Some("consolidate".to_string()),
286        version,
287        prev_version_id,
288        quarantined: false,
289        quarantine_reason: None,
290        decay_function: None,
291    };
292
293    // Encrypt at rest after hashing/embedding, exactly like `remember`.
294    if let Some(ref enc) = engine.encryption {
295        let encrypted = enc.encrypt(record.content.as_bytes())?;
296        record.content = base64::engine::general_purpose::STANDARD.encode(&encrypted);
297    }
298
299    // --- Persist + index --------------------------------------------------
300    engine.storage.insert_memory(&record).await?;
301    engine.index.add(id, &embedding)?;
302    if let Some(ref ft) = engine.full_text {
303        ft.add(id, &record.content)?;
304        ft.commit()?;
305    }
306
307    // Evidence relations: topic_document --consolidated_from--> member.
308    for m in &members {
309        let relation = Relation {
310            id: Uuid::now_v7(),
311            source_id: id,
312            target_id: m.id,
313            relation_type: "consolidated_from".to_string(),
314            weight: 1.0,
315            metadata: serde_json::Value::Object(serde_json::Map::new()),
316            created_at: now.clone(),
317        };
318        if let Err(e) = engine.storage.insert_relation(&relation).await {
319            tracing::error!(relation_id = %relation.id, error = %e, "failed to insert consolidation relation");
320        }
321    }
322
323    // --- Revision: mark the old document superseded, keep its history -----
324    // The new document is the current version (`version = old.version + 1`,
325    // `prev_version_id = old.id`). The old document is RETAINED (not
326    // soft-deleted): deleting a mid-chain record would orphan the next
327    // record's `prev_hash` and break `verify_integrity`. Instead it is
328    // marked `Consolidated` with a `superseded_by` pointer. Recall callers
329    // collapse to the current view with the current-fact resolver keyed on
330    // the shared `topic` metadata (revisions reuse the same `topic_name`).
331    if let Some(old_id) = superseded_id
332        && let Some(mut old) = engine.storage.get_memory(old_id).await?
333    {
334        if let serde_json::Value::Object(ref mut m) = old.metadata {
335            m.insert(
336                "superseded_by".to_string(),
337                serde_json::json!(id.to_string()),
338            );
339        }
340        old.consolidation_state = ConsolidationState::Consolidated;
341        old.updated_at = chrono::Utc::now().to_rfc3339();
342        if let Err(e) = engine.storage.update_memory(&old).await {
343            tracing::error!(memory_id = %old_id, error = %e, "failed to mark superseded topic document");
344        }
345    }
346
347    // --- Audit events (hash-chained) -------------------------------------
348    let consolidation_event = super::event_builder::build_event(
349        engine,
350        &agent_id,
351        EventType::MemoryConsolidated,
352        serde_json::json!({
353            "topic": topic,
354            "consolidated_from": consolidated_from,
355            "topic_document_id": id.to_string(),
356            "version": version,
357        }),
358        &id.to_string(),
359        request.thread_id.clone(),
360    )
361    .await;
362    let consolidation_event_id = consolidation_event.id;
363    if let Err(e) = engine.storage.insert_event(&consolidation_event).await {
364        tracing::error!(event_id = %consolidation_event.id, error = %e, "failed to insert consolidation event");
365    }
366
367    let revision_event_id = if let Some(old_id) = superseded_id {
368        let revision_event = super::event_builder::build_event(
369            engine,
370            &agent_id,
371            EventType::MemoryRevised,
372            serde_json::json!({
373                "superseded_id": old_id.to_string(),
374                "superseded_by": id.to_string(),
375                "topic": topic,
376            }),
377            &id.to_string(),
378            request.thread_id.clone(),
379        )
380        .await;
381        let rid = revision_event.id;
382        if let Err(e) = engine.storage.insert_event(&revision_event).await {
383            tracing::error!(event_id = %revision_event.id, error = %e, "failed to insert revision event");
384        }
385        Some(rid)
386    } else {
387        None
388    };
389
390    let member_ids: Vec<Uuid> = members.iter().map(|m| m.id).collect();
391    let source_count = members.len();
392
393    // Cache the new topic document like the write path does.
394    if let Some(ref cache) = engine.cache {
395        cache.put(record);
396    }
397
398    Ok(ConsolidateResponse {
399        topic_document_id: id,
400        topic_name: topic,
401        source_count,
402        version,
403        superseded_id,
404        member_ids,
405        content_hash: hex::encode(&content_hash),
406        consolidation_event_id,
407        revision_event_id,
408    })
409}