Skip to main content

mentedb/
hosting.rs

1//! Primitives a hosting server needs on top of store and recall: field
2//! updates that keep a memory's id, explicit supersession, a stable paged
3//! listing narrowed by the bitmap index, per call hybrid recall options,
4//! batched access statistics, enrichment provenance, and the persisted
5//! enrichment watermark.
6
7use std::collections::{HashMap, HashSet};
8use std::io::Write;
9use std::path::Path;
10
11use mentedb_core::edge::EdgeType;
12use mentedb_core::error::MenteResult;
13use mentedb_core::memory::AttributeValue;
14use mentedb_core::types::{AgentId, MemoryId, Timestamp};
15use mentedb_core::{MemoryEdge, MemoryNode, MenteError};
16use tracing::warn;
17
18use crate::reranker::Reranker;
19use crate::{MenteDb, StoreOutcome};
20
21/// Attribute every derived memory carries, with the value [`ORIGIN_DERIVED`].
22pub const ORIGIN_ATTRIBUTE: &str = "origin";
23/// Value of [`ORIGIN_ATTRIBUTE`] on memories enrichment derived.
24pub const ORIGIN_DERIVED: &str = "derived";
25/// Attribute naming what kind of derived memory this is (see [`DerivedKind`]).
26pub const DERIVED_KIND_ATTRIBUTE: &str = "derived_kind";
27/// File under a database directory holding the enrichment watermark.
28const WATERMARK_FILE: &str = "enrichment_watermark";
29
30/// Per call options for [`MenteDb::recall_hybrid_scoped_with`].
31#[derive(Clone, Copy, Default)]
32pub struct HybridOptions<'a> {
33    /// MMR diversity weight for this call, overriding
34    /// `CognitiveConfig::mmr_lambda`. `1.0` disables the pass.
35    pub mmr_lambda: Option<f32>,
36    /// Reranker for this call, overriding any installed one. Applied only when
37    /// the call carries query text.
38    pub reranker: Option<&'a dyn Reranker>,
39    /// Choose the top k on relevance alone, then blend in decayed salience to
40    /// order just those. By default the blend is applied before the cut, so a
41    /// faded but relevant memory can lose its place to a fresher, weaker one.
42    pub relevance_first: bool,
43    /// Count the hits as accesses when `CognitiveConfig::refresh_access_on_search`
44    /// is on. Set by callers serving a user's search; internal recalls leave it
45    /// off.
46    pub record_access: bool,
47}
48
49/// Changes applied by [`MenteDb::update_memory_fields`]. Unset fields are
50/// left as they are.
51#[derive(Clone, Debug, Default)]
52pub struct MemoryPatch {
53    /// Merged into the memory's attributes: listed keys are set, others kept.
54    pub attributes: HashMap<String, AttributeValue>,
55    /// Replaces the memory's tags when set.
56    pub tags: Option<Vec<String>>,
57    /// Sets (`Some(Some(t))`) or clears (`Some(None)`) the end of the
58    /// validity window.
59    pub valid_until: Option<Option<Timestamp>>,
60}
61
62/// One page of [`MenteDb::list_memories_page`].
63#[derive(Clone, Debug, Default)]
64pub struct MemoryPage {
65    /// Matching memories, oldest first.
66    pub memories: Vec<MemoryNode>,
67    /// Whether more matches follow this page.
68    pub has_more: bool,
69}
70
71/// What a derived memory is, recorded in [`DERIVED_KIND_ATTRIBUTE`].
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum DerivedKind {
74    Fact,
75    Entity,
76    Community,
77    Profile,
78}
79
80impl DerivedKind {
81    pub fn as_str(self) -> &'static str {
82        match self {
83            Self::Fact => "fact",
84            Self::Entity => "entity",
85            Self::Community => "community",
86            Self::Profile => "profile",
87        }
88    }
89}
90
91/// Stamp a derived memory with its provenance: inherited source attributes
92/// (never overwriting a value the memory already has), `origin=derived`, and
93/// `derived_kind`.
94pub(crate) fn mark_derived(
95    node: &mut MemoryNode,
96    kind: DerivedKind,
97    inherited: &HashMap<String, AttributeValue>,
98) {
99    for (k, v) in inherited {
100        node.attributes
101            .entry(k.clone())
102            .or_insert_with(|| v.clone());
103    }
104    node.attributes.insert(
105        ORIGIN_ATTRIBUTE.to_string(),
106        AttributeValue::String(ORIGIN_DERIVED.to_string()),
107    );
108    node.attributes.insert(
109        DERIVED_KIND_ATTRIBUTE.to_string(),
110        AttributeValue::String(kind.as_str().to_string()),
111    );
112}
113
114/// The enrichment watermark persisted in `dir`, or 0 when none was written.
115pub(crate) fn read_enrichment_watermark(dir: &Path) -> u64 {
116    std::fs::read_to_string(dir.join(WATERMARK_FILE))
117        .ok()
118        .and_then(|s| s.trim().parse().ok())
119        .unwrap_or(0)
120}
121
122/// Persist the enrichment watermark: written to a temporary file, synced, and
123/// renamed over the old one, so a crash leaves either value, never a torn one.
124pub(crate) fn write_enrichment_watermark(dir: &Path, value: u64) -> std::io::Result<()> {
125    let tmp = dir.join(format!("{WATERMARK_FILE}.tmp"));
126    {
127        let mut f = std::fs::File::create(&tmp)?;
128        f.write_all(value.to_string().as_bytes())?;
129        f.sync_all()?;
130    }
131    std::fs::rename(&tmp, dir.join(WATERMARK_FILE))
132}
133
134fn now_micros() -> Timestamp {
135    std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .unwrap_or_default()
138        .as_micros() as u64
139}
140
141impl MenteDb {
142    /// Attribute values the configured `copy_source_attributes` keys take
143    /// from `sources`, the first source carrying a key winning.
144    pub(crate) fn inherited_attributes(
145        &self,
146        sources: &[MemoryId],
147    ) -> HashMap<String, AttributeValue> {
148        let keys = &self
149            .cognitive_config
150            .enrichment_config
151            .copy_source_attributes;
152        let mut out = HashMap::new();
153        if keys.is_empty() {
154            return out;
155        }
156        for id in sources {
157            if out.len() == keys.len() {
158                break;
159            }
160            let Ok(src) = self.get_memory(*id) else {
161                continue;
162            };
163            for key in keys {
164                if !out.contains_key(key)
165                    && let Some(v) = src.attributes.get(key)
166                {
167                    out.insert(key.clone(), v.clone());
168                }
169            }
170        }
171        out
172    }
173
174    /// Update a stored memory in place, keeping its id: merge attributes,
175    /// replace tags, set or clear `valid_until`. The write goes through the
176    /// WAL like any store, and the tag index follows a tag change.
177    pub fn update_memory_fields(
178        &self,
179        id: MemoryId,
180        patch: &MemoryPatch,
181    ) -> MenteResult<MemoryNode> {
182        let page = self.page_of(id).ok_or(MenteError::MemoryNotFound(id))?;
183        let old = self.storage.load_memory(page)?;
184        let mut node = old.clone();
185        for (k, v) in &patch.attributes {
186            node.attributes.insert(k.clone(), v.clone());
187        }
188        if let Some(tags) = &patch.tags {
189            node.tags = tags.clone();
190        }
191        if let Some(until) = patch.valid_until {
192            node.valid_until = until;
193        }
194        self.update_node(page, &node)?;
195        if node.tags != old.tags {
196            self.index.reindex_tags(&node);
197            if self
198                .scope_counts_ready
199                .load(std::sync::atomic::Ordering::Relaxed)
200            {
201                let hidden = &self.cognitive_config.hidden_count_tags;
202                let mut counts = self.scope_counts.write();
203                counts.apply(&old, -1, hidden);
204                counts.apply(&node, 1, hidden);
205            }
206        }
207        Ok(node)
208    }
209
210    /// Replace memory `old` with `new`: store `new`, close `old`'s validity
211    /// window at `at`, and record a `Supersedes` edge from `new` to `old`. The
212    /// old memory stays stored for history and point in time recall. Returns
213    /// the new memory's id.
214    ///
215    /// Fails when `old` does not exist, or when `new` is skipped as a
216    /// duplicate of an existing memory (nothing would replace `old`).
217    pub fn supersede_memory(
218        &self,
219        old: MemoryId,
220        new: MemoryNode,
221        at: Timestamp,
222    ) -> MenteResult<MemoryId> {
223        let previous = self.get_memory(old)?;
224        let new_id = new.id;
225        match self.store_with_outcome(new)? {
226            StoreOutcome::Stored => {}
227            StoreOutcome::Duplicate { of } => {
228                return Err(MenteError::Storage(format!(
229                    "the replacement duplicates memory {of}; nothing was stored"
230                )));
231            }
232        }
233        // Write inference may already have closed it at the store above.
234        if previous.valid_until.is_none_or(|u| u > at) {
235            self.update_memory_fields(
236                old,
237                &MemoryPatch {
238                    valid_until: Some(Some(at)),
239                    ..MemoryPatch::default()
240                },
241            )?;
242        }
243        let already_linked = self
244            .graph
245            .graph()
246            .outgoing(new_id)
247            .iter()
248            .any(|(t, e)| *t == old && e.edge_type == EdgeType::Supersedes);
249        if !already_linked {
250            self.relate(MemoryEdge {
251                source: new_id,
252                target: old,
253                edge_type: EdgeType::Supersedes,
254                weight: 1.0,
255                created_at: at,
256                valid_from: Some(at),
257                valid_until: None,
258                label: None,
259            })?;
260        }
261        Ok(new_id)
262    }
263
264    /// Whether an active `Supersedes` edge points at `id` at time `at`.
265    fn superseded_at(&self, id: MemoryId, at: Timestamp) -> bool {
266        self.graph
267            .graph()
268            .incoming(id)
269            .iter()
270            .any(|(_, e)| e.edge_type == EdgeType::Supersedes && e.is_valid_at(at))
271    }
272
273    /// Whether a memory is current at `at`: inside its validity window and not
274    /// superseded.
275    pub fn is_active_at(&self, node: &MemoryNode, at: Timestamp) -> bool {
276        node.is_valid_at(at) && !self.superseded_at(node.id, at)
277    }
278
279    /// A page of memories in a stable order (creation time, then insertion).
280    ///
281    /// `tag` and `agent` narrow through the bitmap index before anything is
282    /// loaded; `agent` admits that agent's memories and shared ones owned by
283    /// no agent. Without `include_inactive`, memories outside their validity
284    /// window at `at` or superseded are skipped. `offset` counts matching
285    /// memories. With `include_inactive` set, skipping to `offset` loads
286    /// nothing; otherwise each skipped memory is loaded to check it is active.
287    pub fn list_memories_page(
288        &self,
289        tag: Option<&str>,
290        agent: Option<AgentId>,
291        include_inactive: bool,
292        at: Timestamp,
293        offset: usize,
294        limit: usize,
295    ) -> MemoryPage {
296        let mut allowed: Option<HashSet<MemoryId>> =
297            tag.map(|t| self.index.bitmap.query_tag(t).into_iter().collect());
298        if let Some(a) = agent {
299            let keys = [
300                mentedb_index::manager::agent_index_key(a),
301                mentedb_index::manager::agent_index_key(AgentId::nil()),
302            ];
303            let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
304            let owned: HashSet<MemoryId> =
305                self.index.bitmap.query_tags_or(&refs).into_iter().collect();
306            allowed = Some(match allowed {
307                Some(prev) => prev.intersection(&owned).copied().collect(),
308                None => owned,
309            });
310        }
311        let ordered = self
312            .index
313            .temporal
314            .range(0, Timestamp::MAX)
315            .into_iter()
316            .filter(|id| allowed.as_ref().is_none_or(|a| a.contains(id)));
317
318        let mut page = MemoryPage::default();
319        let mut skipped = 0usize;
320        for id in ordered {
321            if include_inactive && skipped < offset {
322                skipped += 1;
323                continue;
324            }
325            let Ok(node) = self.get_memory(id) else {
326                continue;
327            };
328            if !include_inactive && !self.is_active_at(&node, at) {
329                continue;
330            }
331            if skipped < offset {
332                skipped += 1;
333                continue;
334            }
335            if page.memories.len() == limit {
336                page.has_more = true;
337                break;
338            }
339            page.memories.push(node);
340        }
341        page
342    }
343
344    /// Record search hits as accesses. Held in memory and written at the next
345    /// flush, so a burst of searches costs no write per hit.
346    pub fn note_access(&self, ids: impl IntoIterator<Item = MemoryId>) {
347        let now = now_micros();
348        let mut pending = self.pending_access.lock();
349        for id in ids {
350            let entry = pending.entry(id).or_insert((now, 0));
351            entry.0 = entry.0.max(now);
352            entry.1 = entry.1.saturating_add(1);
353        }
354    }
355
356    /// Accesses recorded by [`Self::note_access`] and not yet written.
357    pub fn pending_access_count(&self) -> usize {
358        self.pending_access.lock().len()
359    }
360
361    /// Write the batched access statistics. Called by flush.
362    pub(crate) fn apply_pending_access(&self) {
363        let pending = std::mem::take(&mut *self.pending_access.lock());
364        for (id, (at, hits)) in pending {
365            let Some(page) = self.page_of(id) else {
366                continue;
367            };
368            let Ok(mut node) = self.storage.load_memory(page) else {
369                continue;
370            };
371            node.accessed_at = node.accessed_at.max(at);
372            node.access_count = node.access_count.saturating_add(hits);
373            if let Err(e) = self.update_node(page, &node) {
374                warn!(%id, error = %e, "writing access statistics failed");
375            }
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::CognitiveConfig;
384    use mentedb_core::memory::MemoryType;
385
386    fn db(dir: &Path) -> MenteDb {
387        MenteDb::open_with_config(
388            dir,
389            CognitiveConfig {
390                write_inference: false,
391                memory_dedup: false,
392                ..CognitiveConfig::default()
393            },
394        )
395        .unwrap()
396    }
397
398    fn node(agent: AgentId, content: &str, tags: &[&str]) -> MemoryNode {
399        let mut n = MemoryNode::new(agent, MemoryType::Semantic, content.into(), vec![]);
400        n.tags = tags.iter().map(|t| t.to_string()).collect();
401        n
402    }
403
404    #[test]
405    fn update_merges_attributes_replaces_tags_and_reindexes() {
406        let dir = tempfile::tempdir().unwrap();
407        let db = db(dir.path());
408        let mut n = node(AgentId::nil(), "the sky is blue", &["a"]);
409        n.attributes
410            .insert("keep".into(), AttributeValue::String("x".into()));
411        let id = n.id;
412        db.store(n).unwrap();
413
414        let mut patch = MemoryPatch {
415            tags: Some(vec!["b".into()]),
416            ..MemoryPatch::default()
417        };
418        patch
419            .attributes
420            .insert("added".into(), AttributeValue::Integer(7));
421        let updated = db.update_memory_fields(id, &patch).unwrap();
422        assert_eq!(updated.id, id);
423        assert!(updated.attributes.contains_key("keep"));
424        assert!(updated.attributes.contains_key("added"));
425        assert!(db.memory_ids_with_tag("a").is_empty());
426        assert_eq!(db.memory_ids_with_tag("b"), vec![id]);
427
428        // Durable: survives a reopen.
429        db.close().unwrap();
430        drop(db);
431        let db = super::tests::db(dir.path());
432        let back = db.get_memory(id).unwrap();
433        assert_eq!(back.tags, vec!["b".to_string()]);
434        assert!(matches!(
435            back.attributes.get("added"),
436            Some(AttributeValue::Integer(7))
437        ));
438    }
439
440    #[test]
441    fn supersede_closes_the_old_memory_and_links_the_new_one() {
442        let dir = tempfile::tempdir().unwrap();
443        let db = db(dir.path());
444        let old = node(AgentId::nil(), "phone number is 1234", &[]);
445        let old_id = old.id;
446        db.store(old).unwrap();
447        let at = now_micros();
448        let new = node(AgentId::nil(), "phone number is 4321", &[]);
449        let new_id = db.supersede_memory(old_id, new, at).unwrap();
450        assert_ne!(new_id, old_id);
451        let closed = db.get_memory(old_id).unwrap();
452        assert_eq!(closed.valid_until, Some(at));
453        let history = db.supersession_history(new_id, 4).unwrap();
454        assert_eq!(history.len(), 1);
455        assert_eq!(history[0].id, old_id);
456        assert!(!db.is_active_at(&closed, at + 1));
457        assert!(
458            db.supersede_memory(MemoryId::new(), node(AgentId::nil(), "x", &[]), at)
459                .is_err()
460        );
461    }
462
463    #[test]
464    fn pages_are_stable_and_narrowed_by_tag_agent_and_activity() {
465        let dir = tempfile::tempdir().unwrap();
466        let db = db(dir.path());
467        let a = AgentId::new();
468        let b = AgentId::new();
469        let mut ids = Vec::new();
470        for i in 0..10 {
471            let owner = if i % 2 == 0 { a } else { b };
472            let mut n = node(
473                owner,
474                &format!("memory {i}"),
475                if i < 6 { &["t"] } else { &[] },
476            );
477            n.created_at = 1_000 + i as u64;
478            ids.push(n.id);
479            db.store(n).unwrap();
480        }
481        let shared = node(AgentId::nil(), "shared", &["t"]);
482        let shared_id = shared.id;
483        db.store(shared).unwrap();
484        // Close one of agent a's tagged memories.
485        db.invalidate_memory(ids[2], 1).unwrap();
486        let now = now_micros();
487
488        let all = db.list_memories_page(None, None, true, now, 0, 100);
489        assert_eq!(all.memories.len(), 11);
490        let order: Vec<MemoryId> = all.memories.iter().map(|m| m.id).collect();
491        assert_eq!(&order[..10], &ids[..], "creation order");
492
493        let tagged_a = db.list_memories_page(Some("t"), Some(a), false, now, 0, 100);
494        let got: Vec<MemoryId> = tagged_a.memories.iter().map(|m| m.id).collect();
495        assert_eq!(got, vec![ids[0], ids[4], shared_id]);
496
497        let with_inactive = db.list_memories_page(Some("t"), Some(a), true, now, 0, 100);
498        assert_eq!(with_inactive.memories.len(), 4);
499
500        // Paging walks the same order without gaps or repeats.
501        let p1 = db.list_memories_page(None, None, true, now, 0, 4);
502        let p2 = db.list_memories_page(None, None, true, now, 4, 4);
503        let p3 = db.list_memories_page(None, None, true, now, 8, 4);
504        assert!(p1.has_more && p2.has_more && !p3.has_more);
505        let walked: Vec<MemoryId> = [p1, p2, p3]
506            .into_iter()
507            .flat_map(|p| p.memories.into_iter().map(|m| m.id))
508            .collect();
509        assert_eq!(walked, order);
510    }
511
512    #[test]
513    fn access_statistics_are_batched_until_flush() {
514        let dir = tempfile::tempdir().unwrap();
515        let db = db(dir.path());
516        let n = node(AgentId::nil(), "x", &[]);
517        let id = n.id;
518        db.store(n).unwrap();
519        let before = db.get_memory(id).unwrap();
520        db.note_access([id, id]);
521        assert_eq!(db.pending_access_count(), 1);
522        assert_eq!(db.get_memory(id).unwrap().access_count, before.access_count);
523        db.flush().unwrap();
524        assert_eq!(db.pending_access_count(), 0);
525        assert_eq!(
526            db.get_memory(id).unwrap().access_count,
527            before.access_count + 2
528        );
529    }
530
531    #[test]
532    fn watermark_round_trips() {
533        let dir = tempfile::tempdir().unwrap();
534        assert_eq!(read_enrichment_watermark(dir.path()), 0);
535        write_enrichment_watermark(dir.path(), 42).unwrap();
536        assert_eq!(read_enrichment_watermark(dir.path()), 42);
537    }
538
539    #[test]
540    fn enrichment_watermark_survives_reopen() {
541        let dir = tempfile::tempdir().unwrap();
542        let db = db(dir.path());
543        db.mark_enrichment_complete(12_345);
544        db.close().unwrap();
545        drop(db);
546        let db = super::tests::db(dir.path());
547        assert_eq!(db.last_enrichment_turn(), 12_345);
548    }
549
550    #[test]
551    fn decay_exemption_by_attribute() {
552        let dir = tempfile::tempdir().unwrap();
553        let db = db(dir.path());
554        let mut n = node(AgentId::nil(), "x", &[]);
555        assert!(!db.is_decay_exempt(&n));
556        n.attributes
557            .insert("decay_exempt".into(), AttributeValue::Boolean(true));
558        assert!(db.is_decay_exempt(&n));
559        n.attributes
560            .insert("decay_exempt".into(), AttributeValue::Boolean(false));
561        assert!(!db.is_decay_exempt(&n));
562    }
563
564    #[test]
565    fn derived_memories_carry_provenance() {
566        let dir = tempfile::tempdir().unwrap();
567        let mut cfg = CognitiveConfig {
568            write_inference: false,
569            memory_dedup: false,
570            ..CognitiveConfig::default()
571        };
572        cfg.enrichment_config.copy_source_attributes = vec!["doc".into(), "absent".into()];
573        let db = MenteDb::open_with_config(dir.path(), cfg).unwrap();
574        let mut src = node(AgentId::nil(), "source turn", &[]);
575        src.memory_type = MemoryType::Episodic;
576        src.attributes
577            .insert("doc".into(), AttributeValue::String("d-1".into()));
578        src.attributes
579            .insert("private".into(), AttributeValue::String("p".into()));
580        let src_id = src.id;
581        db.store(src).unwrap();
582
583        let fact = node(AgentId::nil(), "a fact", &[]);
584        let fact_id = fact.id;
585        let entity = node(AgentId::nil(), "an entity", &["entity:x"]);
586        let entity_id = entity.id;
587        db.store_enrichment_memories(vec![fact, entity], &[src_id])
588            .unwrap();
589        let fact = db.get_memory(fact_id).unwrap();
590        fn s<'a>(n: &'a MemoryNode, key: &str) -> Option<&'a str> {
591            match n.attributes.get(key) {
592                Some(AttributeValue::String(v)) => Some(v.as_str()),
593                _ => None,
594            }
595        }
596        assert_eq!(s(&fact, "doc"), Some("d-1"));
597        assert!(!fact.attributes.contains_key("private"));
598        assert_eq!(s(&fact, ORIGIN_ATTRIBUTE), Some("derived"));
599        assert_eq!(s(&fact, DERIVED_KIND_ATTRIBUTE), Some("fact"));
600        let entity = db.get_memory(entity_id).unwrap();
601        assert_eq!(s(&entity, DERIVED_KIND_ATTRIBUTE), Some("entity"));
602
603        let profile = db
604            .store_user_profile(
605                "likes tea",
606                mentedb_core::types::UserId::nil(),
607                AgentId::nil(),
608            )
609            .unwrap();
610        let profile = db.get_memory(profile).unwrap();
611        assert_eq!(s(&profile, DERIVED_KIND_ATTRIBUTE), Some("profile"));
612    }
613
614    #[test]
615    fn enrichment_candidate_types_are_configurable() {
616        let dir = tempfile::tempdir().unwrap();
617        let mut cfg = CognitiveConfig {
618            write_inference: false,
619            memory_dedup: false,
620            ..CognitiveConfig::default()
621        };
622        let semantic = node(AgentId::nil(), "a semantic note", &[]);
623        let mut episodic = node(AgentId::nil(), "a turn", &[]);
624        episodic.memory_type = MemoryType::Episodic;
625        {
626            let db = MenteDb::open_with_config(dir.path(), cfg.clone()).unwrap();
627            db.store(semantic).unwrap();
628            db.store(episodic).unwrap();
629            let c = db.enrichment_candidates(mentedb_core::types::UserId::nil(), AgentId::nil());
630            assert_eq!(c.len(), 1, "episodic only by default");
631            db.close().unwrap();
632        }
633        cfg.enrichment_config.candidate_types = vec![MemoryType::Episodic, MemoryType::Semantic];
634        let db = MenteDb::open_with_config(dir.path(), cfg).unwrap();
635        let c = db.enrichment_candidates(mentedb_core::types::UserId::nil(), AgentId::nil());
636        assert_eq!(c.len(), 2);
637    }
638}