Skip to main content

engram/
fact.rs

1//! Core domain types for the Engram memory layer.
2//!
3//! - `Fact` — the atomic unit of memory: a piece of text with provenance,
4//!   temporal validity, and optional embedding.
5//! - `Entity` — a named thing (person, place, concept) extracted from facts.
6//! - `Relationship` — a typed, time-bounded edge between two entities.
7//! - `SubGraph` — a snapshot of entities and relationships.
8//! - `FactPatch` — a partial update payload for `FactStore::update_fact`.
9//! - `FactFilter` — query parameters for `FactStore::list_facts`.
10
11use crate::scope::Scope;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16// ---------------------------------------------------------------------------
17// Type aliases
18// ---------------------------------------------------------------------------
19
20pub type FactId = Uuid;
21pub type EntityId = Uuid;
22pub type RelationshipId = Uuid;
23
24// ---------------------------------------------------------------------------
25// MemoryTier
26// ---------------------------------------------------------------------------
27
28/// The retention tier of a `Fact`.
29///
30/// - `Working` — ephemeral; cleared between agent invocations.
31/// - `Conversation` — lasts for the duration of a session (default).
32/// - `Knowledge` — long-term; persisted across sessions indefinitely.
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum MemoryTier {
36    Working,
37    #[default]
38    Conversation,
39    Knowledge,
40}
41
42// ---------------------------------------------------------------------------
43// Fact
44// ---------------------------------------------------------------------------
45
46/// An atomic unit of agent memory.
47///
48/// A `Fact` is the fundamental record stored by Engram. It carries free-text
49/// content alongside rich provenance metadata, temporal validity bounds, an
50/// optional embedding vector for semantic search, and a supersession chain for
51/// versioned knowledge updates.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Fact {
54    pub id: FactId,
55    /// Human-readable text content of this fact.
56    pub text: String,
57    /// Memory scope this fact belongs to.
58    pub scope: Scope,
59    /// Retention tier.
60    pub tier: MemoryTier,
61    /// Optional free-form category tag (e.g. `"preference"`, `"task_result"`).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub category: Option<String>,
64    /// Identifier of the source that produced this fact (agent id, tool name, …).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub source: Option<String>,
67    /// Confidence score in [0.0, 1.0]. `None` means "unrated".
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub confidence: Option<f32>,
70    /// When this fact became valid. Defaults to creation time.
71    pub valid_from: DateTime<Utc>,
72    /// When this fact stops being valid. `None` means "still valid".
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub invalid_at: Option<DateTime<Utc>>,
75    pub created_at: DateTime<Utc>,
76    /// Dense embedding vector (e.g. 1536-dim for `text-embedding-3-small`).
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub embedding: Vec<f32>,
79    /// UUIDs of entities this fact references.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub entity_refs: Vec<EntityId>,
82    /// ID of the fact this one supersedes (if this is an update).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub supersedes: Option<FactId>,
85    /// ID of the fact that superseded this one (set when this fact is outdated).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub superseded_by: Option<FactId>,
88    /// How many times this fact has been retrieved.
89    #[serde(default)]
90    pub access_count: u64,
91    /// When this fact was last retrieved.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub last_accessed: Option<DateTime<Utc>>,
94    /// Arbitrary extra metadata.
95    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
96    pub metadata: serde_json::Map<String, serde_json::Value>,
97}
98
99impl Fact {
100    /// Create a new fact with required fields; all optional fields default to `None`/empty.
101    pub fn new(text: impl Into<String>, scope: Scope) -> Self {
102        let now = Utc::now();
103        Self {
104            id: Uuid::new_v4(),
105            text: text.into(),
106            scope,
107            tier: MemoryTier::default(),
108            category: None,
109            source: None,
110            confidence: None,
111            valid_from: now,
112            invalid_at: None,
113            created_at: now,
114            embedding: Vec::new(),
115            entity_refs: Vec::new(),
116            supersedes: None,
117            superseded_by: None,
118            access_count: 0,
119            last_accessed: None,
120            metadata: serde_json::Map::new(),
121        }
122    }
123
124    /// Returns `true` if this fact is currently valid (not yet expired).
125    pub fn is_valid(&self) -> bool {
126        self.is_valid_at(Utc::now())
127    }
128
129    /// Returns `true` if this fact was valid at the given point in time.
130    pub fn is_valid_at(&self, at: DateTime<Utc>) -> bool {
131        if at < self.valid_from {
132            return false;
133        }
134        match self.invalid_at {
135            Some(exp) => at < exp,
136            None => true,
137        }
138    }
139
140    // --- Builder helpers ---
141
142    pub fn with_tier(mut self, tier: MemoryTier) -> Self {
143        self.tier = tier;
144        self
145    }
146
147    pub fn with_category(mut self, category: impl Into<String>) -> Self {
148        self.category = Some(category.into());
149        self
150    }
151
152    pub fn with_confidence(mut self, confidence: f32) -> Self {
153        self.confidence = Some(confidence);
154        self
155    }
156
157    pub fn with_source(mut self, source: impl Into<String>) -> Self {
158        self.source = Some(source.into());
159        self
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Entity
165// ---------------------------------------------------------------------------
166
167/// A named, typed entity extracted from one or more facts.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Entity {
170    pub id: EntityId,
171    pub name: String,
172    pub entity_type: String,
173    pub scope: Scope,
174    /// Arbitrary key-value attributes for this entity.
175    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
176    pub attributes: serde_json::Map<String, serde_json::Value>,
177    pub created_at: DateTime<Utc>,
178    pub updated_at: DateTime<Utc>,
179}
180
181impl Entity {
182    pub fn new(name: impl Into<String>, scope: Scope) -> Self {
183        let now = Utc::now();
184        Self {
185            id: Uuid::new_v4(),
186            name: name.into(),
187            entity_type: "unknown".to_string(),
188            scope,
189            attributes: serde_json::Map::new(),
190            created_at: now,
191            updated_at: now,
192        }
193    }
194
195    pub fn with_type(mut self, entity_type: impl Into<String>) -> Self {
196        self.entity_type = entity_type.into();
197        self
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Relationship
203// ---------------------------------------------------------------------------
204
205/// A directed, typed, time-bounded edge between two entities.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Relationship {
208    pub id: RelationshipId,
209    /// Source entity.
210    pub source_id: EntityId,
211    /// Relation label (e.g. `"works_for"`, `"located_in"`).
212    pub relation: String,
213    /// Target entity.
214    pub target_id: EntityId,
215    pub scope: Scope,
216    pub valid_from: DateTime<Utc>,
217    /// When this relationship stops being valid. `None` means "still valid".
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub invalid_at: Option<DateTime<Utc>>,
220    pub created_at: DateTime<Utc>,
221}
222
223impl Relationship {
224    pub fn new(
225        source_id: EntityId,
226        relation: impl Into<String>,
227        target_id: EntityId,
228        scope: Scope,
229    ) -> Self {
230        let now = Utc::now();
231        Self {
232            id: Uuid::new_v4(),
233            source_id,
234            relation: relation.into(),
235            target_id,
236            scope,
237            valid_from: now,
238            invalid_at: None,
239            created_at: now,
240        }
241    }
242
243    /// Returns `true` if this relationship is currently valid.
244    pub fn is_valid(&self) -> bool {
245        self.is_valid_at(Utc::now())
246    }
247
248    /// Returns `true` if this relationship was valid at the given point in time.
249    pub fn is_valid_at(&self, at: DateTime<Utc>) -> bool {
250        if at < self.valid_from {
251            return false;
252        }
253        match self.invalid_at {
254            Some(exp) => at < exp,
255            None => true,
256        }
257    }
258}
259
260// ---------------------------------------------------------------------------
261// SubGraph
262// ---------------------------------------------------------------------------
263
264/// A snapshot of a portion of the entity-relationship graph.
265#[derive(Debug, Clone, Default, Serialize, Deserialize)]
266pub struct SubGraph {
267    pub entities: Vec<Entity>,
268    pub relationships: Vec<Relationship>,
269}
270
271// ---------------------------------------------------------------------------
272// FactPatch
273// ---------------------------------------------------------------------------
274
275/// Partial update payload for `FactStore::update_fact`.
276///
277/// Only `Some` fields are applied; `None` fields leave the stored value unchanged.
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279pub struct FactPatch {
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub text: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub tier: Option<MemoryTier>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub category: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub source: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub confidence: Option<f32>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub invalid_at: Option<DateTime<Utc>>,
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub embedding: Vec<f32>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub superseded_by: Option<FactId>,
296    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
297    pub metadata: serde_json::Map<String, serde_json::Value>,
298}
299
300// ---------------------------------------------------------------------------
301// FactFilter
302// ---------------------------------------------------------------------------
303
304/// Query parameters for `FactStore::list_facts`.
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct FactFilter {
307    /// Restrict to facts within this scope (and child scopes).
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub scope: Option<Scope>,
310    /// Filter by memory tier.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub tier: Option<MemoryTier>,
313    /// Filter by category tag.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub category: Option<String>,
316    /// When `true`, only return facts that are currently valid. Default `true`.
317    #[serde(default = "default_valid_only")]
318    pub valid_only: bool,
319    /// Point-in-time query — return facts that were valid at this instant.
320    /// If `None`, uses the current time (combined with `valid_only`).
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub as_of: Option<DateTime<Utc>>,
323    /// Substring filter on `Fact::text` (case-insensitive).
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub text_contains: Option<String>,
326    /// Maximum number of facts to return. Default 50.
327    #[serde(default = "default_limit")]
328    pub limit: u32,
329    /// Pagination offset. Default 0.
330    #[serde(default)]
331    pub offset: u32,
332}
333
334fn default_valid_only() -> bool {
335    true
336}
337
338fn default_limit() -> u32 {
339    50
340}
341
342impl FactFilter {
343    pub fn new() -> Self {
344        Self {
345            valid_only: true,
346            limit: 50,
347            ..Default::default()
348        }
349    }
350
351    pub fn with_scope(mut self, scope: Scope) -> Self {
352        self.scope = Some(scope);
353        self
354    }
355
356    pub fn with_tier(mut self, tier: MemoryTier) -> Self {
357        self.tier = Some(tier);
358        self
359    }
360
361    /// Also include facts that have been invalidated.
362    pub fn include_invalid(mut self) -> Self {
363        self.valid_only = false;
364        self
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Unit tests
370// ---------------------------------------------------------------------------
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn org_scope() -> Scope {
377        Scope::org("acme")
378    }
379
380    #[test]
381    fn fact_new_defaults() {
382        let f = Fact::new("Alice likes Rust", org_scope());
383        assert_eq!(f.text, "Alice likes Rust");
384        assert_eq!(f.tier, MemoryTier::Conversation);
385        assert!(f.is_valid());
386        assert!(f.embedding.is_empty());
387        assert_eq!(f.access_count, 0);
388    }
389
390    #[test]
391    fn fact_builder_methods() {
392        let f = Fact::new("test", org_scope())
393            .with_tier(MemoryTier::Knowledge)
394            .with_category("preference")
395            .with_confidence(0.9)
396            .with_source("gpt-4o");
397        assert_eq!(f.tier, MemoryTier::Knowledge);
398        assert_eq!(f.category.as_deref(), Some("preference"));
399        assert_eq!(f.confidence, Some(0.9));
400        assert_eq!(f.source.as_deref(), Some("gpt-4o"));
401    }
402
403    #[test]
404    fn fact_is_valid_at_before_valid_from() {
405        let future = Utc::now() + chrono::Duration::hours(1);
406        let mut f = Fact::new("future fact", org_scope());
407        f.valid_from = future;
408        assert!(!f.is_valid());
409    }
410
411    #[test]
412    fn fact_is_valid_at_after_invalid_at() {
413        let past = Utc::now() - chrono::Duration::hours(1);
414        let mut f = Fact::new("expired fact", org_scope());
415        f.invalid_at = Some(past);
416        assert!(!f.is_valid());
417    }
418
419    #[test]
420    fn entity_new_and_with_type() {
421        let e = Entity::new("Anthropic", org_scope()).with_type("organization");
422        assert_eq!(e.name, "Anthropic");
423        assert_eq!(e.entity_type, "organization");
424    }
425
426    #[test]
427    fn relationship_new_and_validity() {
428        let src = Uuid::new_v4();
429        let tgt = Uuid::new_v4();
430        let r = Relationship::new(src, "founded_by", tgt, org_scope());
431        assert_eq!(r.relation, "founded_by");
432        assert!(r.is_valid());
433    }
434
435    #[test]
436    fn fact_filter_defaults() {
437        let f = FactFilter::new();
438        assert!(f.valid_only);
439        assert_eq!(f.limit, 50);
440        assert_eq!(f.offset, 0);
441    }
442}