Skip to main content

memory_core/store/
metrics.rs

1use rusqlite::params;
2
3use crate::types::MemoryMetric;
4use crate::Result;
5
6use super::Store;
7
8impl Store {
9    pub fn log_access(&self, action: &str, query: Option<&str>, memory_ids: &[i64]) -> Result<()> {
10        let ids_json = serde_json::to_string(memory_ids).unwrap_or_default();
11        self.conn().execute(
12            "INSERT INTO access_log (action, query, memory_ids) VALUES (?1, ?2, ?3)",
13            params![action, query, ids_json],
14        )?;
15        Ok(())
16    }
17
18    pub fn record_injection(&self, memory_ids: &[i64]) -> Result<()> {
19        let tx = self.conn().unchecked_transaction()?;
20        for id in memory_ids {
21            tx.execute(
22                "INSERT INTO metrics (memory_id, injections, last_injected_at)
23                 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
24                 ON CONFLICT(memory_id) DO UPDATE SET
25                    injections = injections + 1,
26                    last_injected_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
27                params![id],
28            )?;
29        }
30        tx.commit()?;
31        Ok(())
32    }
33
34    pub fn record_hit(&self, memory_id: i64) -> Result<()> {
35        self.conn().execute(
36            "INSERT INTO metrics (memory_id, hits, last_hit_at)
37             VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
38             ON CONFLICT(memory_id) DO UPDATE SET
39                hits = hits + 1,
40                last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
41            params![memory_id],
42        )?;
43        Ok(())
44    }
45
46    pub fn record_hit_batch(&self, memory_ids: &[i64]) -> Result<()> {
47        let tx = self.conn().unchecked_transaction()?;
48        for id in memory_ids {
49            tx.execute(
50                "INSERT INTO metrics (memory_id, hits, last_hit_at)
51                 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
52                 ON CONFLICT(memory_id) DO UPDATE SET
53                    hits = hits + 1,
54                    last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
55                params![id],
56            )?;
57        }
58        tx.commit()?;
59        Ok(())
60    }
61
62    /// Cumulative token stats across all time.
63    pub fn cumulative_stats(&self) -> Result<crate::types::TokenStats> {
64        let (injections, hits) = self.conn().query_row(
65            "SELECT COALESCE(SUM(injections), 0), COALESCE(SUM(hits), 0) FROM metrics",
66            [],
67            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
68        )?;
69        let unique = self.conn().query_row(
70            "SELECT COUNT(*) FROM metrics WHERE injections > 0",
71            [],
72            |row| row.get::<_, i64>(0),
73        )?;
74        Ok(crate::types::TokenStats {
75            injections,
76            hits,
77            unique_memories_injected: unique,
78        })
79    }
80
81    /// Token stats for a specific session (by time range from session start).
82    pub fn session_token_stats(&self, session_id: &str) -> Result<crate::types::TokenStats> {
83        // Get session start time
84        let started_at: String = self.conn().query_row(
85            "SELECT started_at FROM sessions WHERE id = ?1",
86            params![session_id],
87            |row| row.get(0),
88        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
89
90        // Count injection events: each access_log row with action 'context'
91        // contains a JSON array of memory_ids. Sum the array lengths.
92        let injection_rows: Vec<String> = {
93            let mut stmt = self.conn().prepare(
94                "SELECT memory_ids FROM access_log
95                 WHERE action = 'context'
96                 AND created_at >= ?1"
97            )?;
98            stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
99                .filter_map(|r| r.ok())
100                .collect()
101        };
102        let mut injections: i64 = 0;
103        let mut injected_set = std::collections::HashSet::new();
104        for ids_json in &injection_rows {
105            if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
106                injections += ids.len() as i64;
107                injected_set.extend(ids);
108            }
109        }
110
111        // Count hit events: search, detail, context
112        let hit_rows: Vec<String> = {
113            let mut stmt = self.conn().prepare(
114                "SELECT memory_ids FROM access_log
115                 WHERE action IN ('search', 'detail', 'context')
116                 AND created_at >= ?1"
117            )?;
118            stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
119                .filter_map(|r| r.ok())
120                .collect()
121        };
122        let mut hits: i64 = 0;
123        for ids_json in &hit_rows {
124            if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
125                hits += ids.len() as i64;
126            }
127        }
128
129        Ok(crate::types::TokenStats {
130            injections,
131            hits,
132            unique_memories_injected: injected_set.len() as i64,
133        })
134    }
135
136    pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
137        let mut stmt = self.conn().prepare(
138            "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
139        )?;
140        let results = stmt
141            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
142            .collect::<std::result::Result<Vec<_>, _>>()?;
143        Ok(results)
144    }
145
146    pub fn access_log_total(&self) -> Result<i64> {
147        Ok(self.conn().query_row(
148            "SELECT COUNT(*) FROM access_log",
149            [],
150            |row| row.get(0),
151        )?)
152    }
153
154    pub fn dedup_total(&self) -> Result<i64> {
155        Ok(self.conn().query_row(
156            "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
157            [],
158            |row| row.get(0),
159        )?)
160    }
161
162    pub fn revision_total(&self) -> Result<i64> {
163        Ok(self.conn().query_row(
164            "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
165            [],
166            |row| row.get(0),
167        )?)
168    }
169
170    pub fn low_roi_count(&self) -> Result<i64> {
171        Ok(self.conn().query_row(
172            "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
173            [],
174            |row| row.get(0),
175        )?)
176    }
177
178    pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
179        let mut stmt = self.conn().prepare(
180            "SELECT query, COUNT(*) as cnt FROM access_log
181             WHERE action = 'search' AND query IS NOT NULL
182             GROUP BY lower(query)
183             ORDER BY cnt DESC
184             LIMIT ?1",
185        )?;
186        let results = stmt
187            .query_map(rusqlite::params![limit], |row| {
188                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
189            })?
190            .collect::<std::result::Result<Vec<_>, _>>()?;
191        Ok(results)
192    }
193
194    pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
195        let mut stmt = self.conn().prepare(
196            "SELECT m.id, m.key, m.scope,
197                    COALESCE(mt.injections, 0),
198                    COALESCE(mt.hits, 0),
199                    COALESCE(mt.tokens_injected, 0),
200                    CASE WHEN COALESCE(mt.injections, 0) > 0
201                         THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
202                         ELSE 0.0 END
203             FROM memories m
204             LEFT JOIN metrics mt ON mt.memory_id = m.id
205             WHERE m.deleted_at IS NULL
206             ORDER BY COALESCE(mt.injections, 0) DESC
207             LIMIT 100",
208        )?;
209        let results = stmt
210            .query_map([], |row| {
211                Ok(MemoryMetric {
212                    id: row.get(0)?,
213                    key: row.get(1)?,
214                    scope: row.get(2)?,
215                    injections: row.get(3)?,
216                    hits: row.get(4)?,
217                    tokens_injected: row.get(5)?,
218                    hit_rate: row.get(6)?,
219                })
220            })?
221            .collect::<std::result::Result<Vec<_>, _>>()?;
222        Ok(results)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use crate::store::Store;
229    use crate::types::SaveParams;
230
231    fn make_memory(store: &Store, key: &str) -> i64 {
232        store
233            .save(SaveParams {
234                key: key.to_string(),
235                value: "test value".to_string(),
236                ..Default::default()
237            })
238            .unwrap()
239            .id()
240    }
241
242    #[test]
243    fn test_log_access_no_query() {
244        let store = Store::open_in_memory().unwrap();
245        store.log_access("search", None, &[]).unwrap();
246    }
247
248    #[test]
249    fn test_log_access_with_query_and_ids() {
250        let store = Store::open_in_memory().unwrap();
251        let id = make_memory(&store, "test/key");
252        store.log_access("search", Some("test query"), &[id]).unwrap();
253    }
254
255    #[test]
256    fn test_record_injection_increments() {
257        let store = Store::open_in_memory().unwrap();
258        let id = make_memory(&store, "test/key");
259
260        store.record_injection(&[id]).unwrap();
261        store.record_injection(&[id]).unwrap();
262
263        let metrics = store.get_metrics().unwrap();
264        let m = metrics.iter().find(|m| m.id == id).unwrap();
265        assert_eq!(m.injections, 2);
266    }
267
268    #[test]
269    fn test_record_hit_increments() {
270        let store = Store::open_in_memory().unwrap();
271        let id = make_memory(&store, "test/key");
272
273        store.record_hit(id).unwrap();
274        store.record_hit(id).unwrap();
275        store.record_hit(id).unwrap();
276
277        let metrics = store.get_metrics().unwrap();
278        let m = metrics.iter().find(|m| m.id == id).unwrap();
279        assert_eq!(m.hits, 3);
280    }
281
282    #[test]
283    fn test_hit_rate_calculation() {
284        let store = Store::open_in_memory().unwrap();
285        let id = make_memory(&store, "test/key");
286
287        store.record_injection(&[id]).unwrap();
288        store.record_injection(&[id]).unwrap();
289        store.record_hit(id).unwrap();
290
291        let metrics = store.get_metrics().unwrap();
292        let m = metrics.iter().find(|m| m.id == id).unwrap();
293        assert_eq!(m.injections, 2);
294        assert_eq!(m.hits, 1);
295        assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
296    }
297
298    #[test]
299    fn test_hit_rate_zero_when_no_injections() {
300        let store = Store::open_in_memory().unwrap();
301        let id = make_memory(&store, "test/key");
302
303        let metrics = store.get_metrics().unwrap();
304        let m = metrics.iter().find(|m| m.id == id).unwrap();
305        assert_eq!(m.hit_rate, 0.0);
306    }
307
308    #[test]
309    fn test_get_metrics_excludes_deleted() {
310        let store = Store::open_in_memory().unwrap();
311        let id = make_memory(&store, "test/key");
312        store.delete("test/key", None, false).unwrap();
313
314        let metrics = store.get_metrics().unwrap();
315        assert!(metrics.iter().find(|m| m.id == id).is_none());
316    }
317
318    #[test]
319    fn test_get_metrics_ordered_by_injections_desc() {
320        let store = Store::open_in_memory().unwrap();
321        let id1 = make_memory(&store, "key/one");
322        let id2 = make_memory(&store, "key/two");
323
324        store.record_injection(&[id1]).unwrap();
325        store.record_injection(&[id1]).unwrap();
326        store.record_injection(&[id1]).unwrap();
327        store.record_injection(&[id2]).unwrap();
328
329        let metrics = store.get_metrics().unwrap();
330        let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
331        let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
332        assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
333    }
334
335    #[test]
336    fn test_record_injection_multiple_ids() {
337        let store = Store::open_in_memory().unwrap();
338        let id1 = make_memory(&store, "key/one");
339        let id2 = make_memory(&store, "key/two");
340
341        store.record_injection(&[id1, id2]).unwrap();
342
343        let metrics = store.get_metrics().unwrap();
344        let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
345        let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
346        assert_eq!(m1.injections, 1);
347        assert_eq!(m2.injections, 1);
348    }
349}