Skip to main content

ainl_memory/
anchored_summary.rs

1//! Persistence helpers for [`ainl-context-compiler`](https://docs.rs/ainl-context-compiler)
2//! anchored summaries (Phase 6 of `SELF_LEARNING_INTEGRATION_MAP.md`).
3//!
4//! Anchored summaries are stored as **semantic-graph nodes** with a stable per-agent UUIDv5
5//! id. Writes use `INSERT OR REPLACE` (via [`crate::store::SqliteGraphStore::write_node`]) so
6//! repeated upserts overwrite the prior summary in place — no schema migration required.
7//!
8//! The summary payload itself is opaque JSON (the `AnchoredSummary` struct from the compiler
9//! crate) stored in the [`crate::node::SemanticNode::fact`] field, tagged
10//! `anchored_summary` for analytics filtering.
11//!
12//! Round-trip is therefore: caller serialises `AnchoredSummary` → `upsert_anchored_summary`
13//! stores by stable id → `fetch_anchored_summary` returns the most recent payload string.
14
15use crate::node::{AinlMemoryNode, AinlNodeType, MemoryCategory, SemanticNode};
16use crate::GraphMemory;
17use uuid::Uuid;
18
19/// Tag applied to all anchored-summary semantic nodes for downstream filtering.
20pub const ANCHORED_SUMMARY_TAG: &str = "anchored_summary";
21
22/// UUIDv5 namespace used to derive stable per-agent ids.
23///
24/// Constant; do not change — collisions with prior on-disk rows would lose history.
25const ANCHORED_SUMMARY_NS: Uuid = Uuid::from_bytes([
26    0x9e, 0x4f, 0x4d, 0xb8, 0x1c, 0x1c, 0x4a, 0x6e, 0xa0, 0x77, 0xe2, 0x55, 0x12, 0x67, 0x3d, 0x2c,
27]);
28
29/// Stable UUIDv5 id for a given agent's anchored-summary row.
30///
31/// Pure function; safe to call without a memory handle (e.g. when constructing test fixtures).
32#[must_use]
33pub fn anchored_summary_id(agent_id: &str) -> Uuid {
34    Uuid::new_v5(&ANCHORED_SUMMARY_NS, agent_id.as_bytes())
35}
36
37impl GraphMemory {
38    /// Upsert a serialized anchored summary for `agent_id`.
39    ///
40    /// `summary_payload` should be a JSON-serialized `AnchoredSummary` from the
41    /// `ainl-context-compiler` crate. Returns the stable node id (same value for repeated calls
42    /// with the same `agent_id`).
43    pub fn upsert_anchored_summary(
44        &self,
45        agent_id: &str,
46        summary_payload: &str,
47    ) -> Result<Uuid, String> {
48        let id = anchored_summary_id(agent_id);
49        let semantic = SemanticNode {
50            fact: summary_payload.to_string(),
51            confidence: 1.0,
52            source_turn_id: id,
53            topic_cluster: None,
54            source_episode_id: String::new(),
55            contradiction_ids: Vec::new(),
56            last_referenced_at: chrono::Utc::now().timestamp() as u64,
57            reference_count: 0,
58            decay_eligible: false,
59            tags: vec![ANCHORED_SUMMARY_TAG.to_string()],
60            recurrence_count: 0,
61            last_ref_snapshot: 0,
62        };
63        let node = AinlMemoryNode {
64            id,
65            memory_category: MemoryCategory::Semantic,
66            importance_score: 1.0,
67            agent_id: agent_id.to_string(),
68            project_id: None,
69            node_type: AinlNodeType::Semantic { semantic },
70            edges: Vec::new(),
71        };
72        self.write_node(&node)?;
73        Ok(id)
74    }
75
76    /// Fetch the most recent anchored-summary payload for `agent_id`, if any.
77    ///
78    /// Returns the raw JSON string (caller deserializes into `AnchoredSummary`).
79    pub fn fetch_anchored_summary(&self, agent_id: &str) -> Result<Option<String>, String> {
80        let id = anchored_summary_id(agent_id);
81        let node = self.store().read_node(id)?;
82        Ok(node.and_then(|n| match n.node_type {
83            AinlNodeType::Semantic { semantic } => Some(semantic.fact),
84            _ => None,
85        }))
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn anchored_summary_id_is_stable_per_agent() {
95        let a = anchored_summary_id("agent-alpha");
96        let b = anchored_summary_id("agent-alpha");
97        let c = anchored_summary_id("agent-beta");
98        assert_eq!(a, b);
99        assert_ne!(a, c);
100    }
101
102    #[test]
103    fn upsert_then_fetch_roundtrip() {
104        let dir = tempfile::tempdir().expect("tempdir");
105        let db_path = dir.path().join("anchored_summary_smoke.db");
106        let memory = GraphMemory::new(&db_path).expect("graph memory");
107
108        // First upsert
109        let payload_v1 = r#"{"schema_version":1,"sections":[{"id":"intent","label":"Intent","content":"v1"}],"token_estimate":1,"iteration":1}"#;
110        let id1 = memory
111            .upsert_anchored_summary("agent-rt", payload_v1)
112            .expect("upsert v1");
113        let fetched_v1 = memory
114            .fetch_anchored_summary("agent-rt")
115            .expect("fetch v1")
116            .expect("payload present");
117        assert_eq!(fetched_v1, payload_v1);
118
119        // Second upsert REPLACES (same id)
120        let payload_v2 = r#"{"schema_version":1,"sections":[{"id":"intent","label":"Intent","content":"v2"}],"token_estimate":1,"iteration":2}"#;
121        let id2 = memory
122            .upsert_anchored_summary("agent-rt", payload_v2)
123            .expect("upsert v2");
124        assert_eq!(id1, id2, "same agent must reuse the same node id");
125        let fetched_v2 = memory
126            .fetch_anchored_summary("agent-rt")
127            .expect("fetch v2")
128            .expect("payload present");
129        assert_eq!(fetched_v2, payload_v2);
130    }
131
132    #[test]
133    fn fetch_missing_returns_none() {
134        let dir = tempfile::tempdir().expect("tempdir");
135        let db_path = dir.path().join("anchored_summary_missing.db");
136        let memory = GraphMemory::new(&db_path).expect("graph memory");
137        let result = memory
138            .fetch_anchored_summary("nonexistent-agent")
139            .expect("query ok");
140        assert!(result.is_none());
141    }
142
143    #[test]
144    fn distinct_agents_isolated() {
145        let dir = tempfile::tempdir().expect("tempdir");
146        let db_path = dir.path().join("anchored_summary_isolation.db");
147        let memory = GraphMemory::new(&db_path).expect("graph memory");
148        memory
149            .upsert_anchored_summary("agent-a", r#"{"a":1}"#)
150            .expect("upsert a");
151        memory
152            .upsert_anchored_summary("agent-b", r#"{"b":2}"#)
153            .expect("upsert b");
154        let a = memory.fetch_anchored_summary("agent-a").unwrap().unwrap();
155        let b = memory.fetch_anchored_summary("agent-b").unwrap().unwrap();
156        assert_eq!(a, r#"{"a":1}"#);
157        assert_eq!(b, r#"{"b":2}"#);
158    }
159}