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, session_id: Option<&str>, action: &str, query: Option<&str>, memory_ids: &[i64], tokens_injected: i32) -> Result<()> {
10        let ids_json = serde_json::to_string(memory_ids).unwrap_or_default();
11        self.conn().execute(
12            "INSERT INTO access_log (session_id, action, query, memory_ids, tokens_injected) VALUES (?1, ?2, ?3, ?4, ?5)",
13            params![session_id, action, query, ids_json, tokens_injected],
14        )?;
15        Ok(())
16    }
17
18    /// Returns access_log entries for a session.
19    pub fn get_session_access_log(&self, session_id: &str, limit: Option<i32>) -> Result<Vec<crate::types::AccessLogEntry>> {
20        self.conn().query_row(
21            "SELECT id FROM sessions WHERE id = ?1",
22            params![session_id],
23            |r| r.get::<_, String>(0),
24        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
25
26        let lim = limit.unwrap_or(1000);
27        let mut stmt = self.conn().prepare(
28            "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
29             FROM access_log
30             WHERE session_id = ?1
31             ORDER BY created_at ASC
32             LIMIT ?2",
33        )?;
34        let results = stmt.query_map(params![session_id, lim], |row| {
35            Ok(crate::types::AccessLogEntry {
36                id: row.get(0)?,
37                session_id: row.get(1)?,
38                action: row.get(2)?,
39                query: row.get(3)?,
40                memory_ids: row.get(4)?,
41                tokens_injected: row.get(5)?,
42                created_at: row.get(6)?,
43            })
44        })?.collect::<std::result::Result<Vec<_>, _>>()?;
45        Ok(results)
46    }
47
48    /// Returns the N most recent access_log entries regardless of session (for tests).
49    pub fn get_session_access_log_all(&self, limit: i32) -> Result<Vec<crate::types::AccessLogEntry>> {
50        let mut stmt = self.conn().prepare(
51            "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
52             FROM access_log ORDER BY created_at DESC LIMIT ?1",
53        )?;
54        let results = stmt.query_map(params![limit], |row| {
55            Ok(crate::types::AccessLogEntry {
56                id: row.get(0)?,
57                session_id: row.get(1)?,
58                action: row.get(2)?,
59                query: row.get(3)?,
60                memory_ids: row.get(4)?,
61                tokens_injected: row.get(5)?,
62                created_at: row.get(6)?,
63            })
64        })?.collect::<std::result::Result<Vec<_>, _>>()?;
65        Ok(results)
66    }
67
68    pub fn delete_access_log_entry(&self, id: i64) -> Result<()> {
69        self.conn().execute("DELETE FROM access_log WHERE id = ?1", params![id])?;
70        Ok(())
71    }
72
73    /// Deletes all access_log entries for the session.
74    /// Removes entries attributed to this session. Legacy entries with session_id IS NULL are
75    /// ambiguous (shared across concurrent sessions) and are left untouched.
76    pub fn clear_session_access_log(&self, session_id: &str) -> Result<()> {
77        // Verify session exists
78        self.conn().query_row(
79            "SELECT id FROM sessions WHERE id = ?1",
80            params![session_id],
81            |r| r.get::<_, String>(0),
82        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
83
84        self.conn().execute(
85            "DELETE FROM access_log WHERE session_id = ?1",
86            params![session_id],
87        )?;
88        Ok(())
89    }
90
91    pub fn record_injection(&self, memory_ids: &[i64], tokens_per_memory: i32) -> Result<()> {
92        let tx = self.conn().unchecked_transaction()?;
93        for id in memory_ids {
94            tx.execute(
95                "INSERT INTO metrics (memory_id, injections, tokens_injected, last_injected_at)
96                 VALUES (?1, 1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
97                 ON CONFLICT(memory_id) DO UPDATE SET
98                    injections = injections + 1,
99                    tokens_injected = tokens_injected + ?2,
100                    last_injected_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
101                params![id, tokens_per_memory],
102            )?;
103        }
104        tx.commit()?;
105        Ok(())
106    }
107
108    pub fn record_hit(&self, memory_id: i64) -> Result<()> {
109        self.conn().execute(
110            "INSERT INTO metrics (memory_id, hits, last_hit_at)
111             VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
112             ON CONFLICT(memory_id) DO UPDATE SET
113                hits = hits + 1,
114                last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
115            params![memory_id],
116        )?;
117        Ok(())
118    }
119
120    pub fn record_hit_batch(&self, memory_ids: &[i64]) -> Result<()> {
121        let tx = self.conn().unchecked_transaction()?;
122        for id in memory_ids {
123            tx.execute(
124                "INSERT INTO metrics (memory_id, hits, last_hit_at)
125                 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
126                 ON CONFLICT(memory_id) DO UPDATE SET
127                    hits = hits + 1,
128                    last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
129                params![id],
130            )?;
131        }
132        tx.commit()?;
133        Ok(())
134    }
135
136    /// Cumulative token stats across all time.
137    pub fn cumulative_stats(&self) -> Result<crate::types::TokenStats> {
138        let (injections, hits) = self.conn().query_row(
139            "SELECT COALESCE(SUM(injections), 0), COALESCE(SUM(hits), 0) FROM metrics",
140            [],
141            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
142        )?;
143        let unique = self.conn().query_row(
144            "SELECT COUNT(*) FROM metrics WHERE injections > 0",
145            [],
146            |row| row.get::<_, i64>(0),
147        )?;
148        Ok(crate::types::TokenStats {
149            injections,
150            hits,
151            unique_memories_injected: unique,
152        })
153    }
154
155    /// Token stats for a specific session.
156    pub fn session_token_stats(&self, session_id: &str) -> Result<crate::types::TokenStats> {
157        self.conn().query_row(
158            "SELECT id FROM sessions WHERE id = ?1",
159            params![session_id],
160            |row| row.get::<_, String>(0),
161        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
162
163        // Count injection events: each access_log row with action 'context'
164        // contains a JSON array of memory_ids. Sum the array lengths.
165        let injection_rows: Vec<String> = {
166            let mut stmt = self.conn().prepare(
167                "SELECT memory_ids FROM access_log
168                 WHERE action = 'context' AND session_id = ?1"
169            )?;
170            stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?
171                .filter_map(|r| r.ok())
172                .collect()
173        };
174        let mut injections: i64 = 0;
175        let mut injected_set = std::collections::HashSet::new();
176        for ids_json in &injection_rows {
177            if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
178                injections += ids.len() as i64;
179                injected_set.extend(ids);
180            }
181        }
182
183        // Count hit events: search, detail, context
184        let hit_rows: Vec<String> = {
185            let mut stmt = self.conn().prepare(
186                "SELECT memory_ids FROM access_log
187                 WHERE action IN ('search', 'detail', 'context') AND session_id = ?1"
188            )?;
189            stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?
190                .filter_map(|r| r.ok())
191                .collect()
192        };
193        let mut hits: i64 = 0;
194        for ids_json in &hit_rows {
195            if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
196                hits += ids.len() as i64;
197            }
198        }
199
200        Ok(crate::types::TokenStats {
201            injections,
202            hits,
203            unique_memories_injected: injected_set.len() as i64,
204        })
205    }
206
207    pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
208        let mut stmt = self.conn().prepare(
209            "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
210        )?;
211        let results = stmt
212            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
213            .collect::<std::result::Result<Vec<_>, _>>()?;
214        Ok(results)
215    }
216
217    pub fn access_log_total(&self) -> Result<i64> {
218        Ok(self.conn().query_row(
219            "SELECT COUNT(*) FROM access_log",
220            [],
221            |row| row.get(0),
222        )?)
223    }
224
225    pub fn dedup_total(&self) -> Result<i64> {
226        Ok(self.conn().query_row(
227            "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
228            [],
229            |row| row.get(0),
230        )?)
231    }
232
233    pub fn revision_total(&self) -> Result<i64> {
234        Ok(self.conn().query_row(
235            "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
236            [],
237            |row| row.get(0),
238        )?)
239    }
240
241    pub fn low_roi_count(&self) -> Result<i64> {
242        Ok(self.conn().query_row(
243            "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
244            [],
245            |row| row.get(0),
246        )?)
247    }
248
249    pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
250        let mut stmt = self.conn().prepare(
251            "SELECT query, COUNT(*) as cnt FROM access_log
252             WHERE action = 'search' AND query IS NOT NULL
253             GROUP BY lower(query)
254             ORDER BY cnt DESC
255             LIMIT ?1",
256        )?;
257        let results = stmt
258            .query_map(rusqlite::params![limit], |row| {
259                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
260            })?
261            .collect::<std::result::Result<Vec<_>, _>>()?;
262        Ok(results)
263    }
264
265    pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
266        let mut stmt = self.conn().prepare(
267            "SELECT m.id, m.key, m.scope,
268                    COALESCE(mt.injections, 0),
269                    COALESCE(mt.hits, 0),
270                    COALESCE(mt.tokens_injected, 0),
271                    CASE WHEN COALESCE(mt.injections, 0) > 0
272                         THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
273                         ELSE 0.0 END
274             FROM memories m
275             LEFT JOIN metrics mt ON mt.memory_id = m.id
276             WHERE m.deleted_at IS NULL
277             ORDER BY COALESCE(mt.injections, 0) DESC
278             LIMIT 100",
279        )?;
280        let results = stmt
281            .query_map([], |row| {
282                Ok(MemoryMetric {
283                    id: row.get(0)?,
284                    key: row.get(1)?,
285                    scope: row.get(2)?,
286                    injections: row.get(3)?,
287                    hits: row.get(4)?,
288                    tokens_injected: row.get(5)?,
289                    hit_rate: row.get(6)?,
290                })
291            })?
292            .collect::<std::result::Result<Vec<_>, _>>()?;
293        Ok(results)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use crate::store::Store;
300    use crate::types::SaveParams;
301
302    fn make_memory(store: &Store, key: &str) -> i64 {
303        store
304            .save(SaveParams {
305                key: key.to_string(),
306                value: "test value".to_string(),
307                ..Default::default()
308            })
309            .unwrap()
310            .id()
311    }
312
313    #[test]
314    fn test_log_access_no_query() {
315        let store = Store::open_in_memory().unwrap();
316        store.log_access(None, "search", None, &[], 0).unwrap();
317    }
318
319    #[test]
320    fn test_log_access_with_query_and_ids() {
321        let store = Store::open_in_memory().unwrap();
322        let id = make_memory(&store, "test/key");
323        store.log_access(None, "search", Some("test query"), &[id], 0).unwrap();
324    }
325
326    #[test]
327    fn test_record_injection_increments() {
328        let store = Store::open_in_memory().unwrap();
329        let id = make_memory(&store, "test/key");
330
331        store.record_injection(&[id], 0).unwrap();
332        store.record_injection(&[id], 0).unwrap();
333
334        let metrics = store.get_metrics().unwrap();
335        let m = metrics.iter().find(|m| m.id == id).unwrap();
336        assert_eq!(m.injections, 2);
337    }
338
339    #[test]
340    fn test_record_hit_increments() {
341        let store = Store::open_in_memory().unwrap();
342        let id = make_memory(&store, "test/key");
343
344        store.record_hit(id).unwrap();
345        store.record_hit(id).unwrap();
346        store.record_hit(id).unwrap();
347
348        let metrics = store.get_metrics().unwrap();
349        let m = metrics.iter().find(|m| m.id == id).unwrap();
350        assert_eq!(m.hits, 3);
351    }
352
353    #[test]
354    fn test_hit_rate_calculation() {
355        let store = Store::open_in_memory().unwrap();
356        let id = make_memory(&store, "test/key");
357
358        store.record_injection(&[id], 0).unwrap();
359        store.record_injection(&[id], 0).unwrap();
360        store.record_hit(id).unwrap();
361
362        let metrics = store.get_metrics().unwrap();
363        let m = metrics.iter().find(|m| m.id == id).unwrap();
364        assert_eq!(m.injections, 2);
365        assert_eq!(m.hits, 1);
366        assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
367    }
368
369    #[test]
370    fn test_hit_rate_zero_when_no_injections() {
371        let store = Store::open_in_memory().unwrap();
372        let id = make_memory(&store, "test/key");
373
374        let metrics = store.get_metrics().unwrap();
375        let m = metrics.iter().find(|m| m.id == id).unwrap();
376        assert_eq!(m.hit_rate, 0.0);
377    }
378
379    #[test]
380    fn test_get_metrics_excludes_deleted() {
381        let store = Store::open_in_memory().unwrap();
382        let id = make_memory(&store, "test/key");
383        store.delete("test/key", None, false).unwrap();
384
385        let metrics = store.get_metrics().unwrap();
386        assert!(metrics.iter().find(|m| m.id == id).is_none());
387    }
388
389    #[test]
390    fn test_get_metrics_ordered_by_injections_desc() {
391        let store = Store::open_in_memory().unwrap();
392        let id1 = make_memory(&store, "key/one");
393        let id2 = make_memory(&store, "key/two");
394
395        store.record_injection(&[id1], 0).unwrap();
396        store.record_injection(&[id1], 0).unwrap();
397        store.record_injection(&[id1], 0).unwrap();
398        store.record_injection(&[id2], 0).unwrap();
399
400        let metrics = store.get_metrics().unwrap();
401        let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
402        let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
403        assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
404    }
405
406    #[test]
407    fn test_record_injection_accumulates_tokens() {
408        let store = Store::open_in_memory().unwrap();
409        let id = make_memory(&store, "key/one");
410
411        store.record_injection(&[id], 50).unwrap();
412        store.record_injection(&[id], 50).unwrap();
413
414        let metrics = store.get_metrics().unwrap();
415        let m = metrics.iter().find(|m| m.id == id).unwrap();
416        assert_eq!(m.injections, 2);
417        assert_eq!(m.tokens_injected, 100);
418    }
419
420    #[test]
421    fn test_record_injection_multiple_ids() {
422        let store = Store::open_in_memory().unwrap();
423        let id1 = make_memory(&store, "key/one");
424        let id2 = make_memory(&store, "key/two");
425
426        store.record_injection(&[id1, id2], 0).unwrap();
427
428        let metrics = store.get_metrics().unwrap();
429        let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
430        let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
431        assert_eq!(m1.injections, 1);
432        assert_eq!(m2.injections, 1);
433    }
434
435    #[test]
436    fn test_migration_006_columns_exist() {
437        let store = Store::open_in_memory().unwrap();
438        // tokens_injected column should exist on access_log
439        store.conn().execute(
440            "INSERT INTO access_log (action, tokens_injected) VALUES ('context', 42)",
441            [],
442        ).unwrap();
443        let tok: i32 = store.conn().query_row(
444            "SELECT tokens_injected FROM access_log WHERE action = 'context'",
445            [],
446            |r| r.get(0),
447        ).unwrap();
448        assert_eq!(tok, 42);
449
450        // tokens_used_input/output on sessions — just verify UPDATE doesn't fail
451        store.conn().execute(
452            "UPDATE sessions SET tokens_used_input = 100, tokens_used_output = 50 WHERE 1=0",
453            [],
454        ).unwrap();
455    }
456
457    #[test]
458    fn test_get_session_access_log_returns_entries() {
459        let store = Store::open_in_memory().unwrap();
460        let session = store.session_start("test-proj", None).unwrap();
461        store.log_access(Some(&session.id), "context", None, &[], 320).unwrap();
462        store.log_access(Some(&session.id), "search", Some("rust errors"), &[], 0).unwrap();
463        let entries = store.get_session_access_log(&session.id, None).unwrap();
464        assert_eq!(entries.len(), 2);
465        assert_eq!(entries[0].action, "context");
466        assert_eq!(entries[0].tokens_injected, 320);
467        assert_eq!(entries[1].action, "search");
468    }
469
470    #[test]
471    fn test_get_session_access_log_isolation() {
472        // Entries from a concurrent session must NOT appear in another session's log.
473        // Entries with NULL session_id must also NOT appear.
474        let store = Store::open_in_memory().unwrap();
475        let session_a = store.session_start("project-a", None).unwrap();
476        let session_b = store.session_start("project-b", None).unwrap();
477        store.log_access(Some(&session_a.id), "search", Some("aaa"), &[], 0).unwrap();
478        store.log_access(Some(&session_b.id), "search", Some("bbb"), &[], 0).unwrap();
479        // NULL session_id (CLI command) — must not leak into either session
480        store.log_access(None, "search", Some("orphan"), &[], 0).unwrap();
481        let entries_a = store.get_session_access_log(&session_a.id, None).unwrap();
482        let entries_b = store.get_session_access_log(&session_b.id, None).unwrap();
483        assert_eq!(entries_a.len(), 1);
484        assert_eq!(entries_a[0].query.as_deref(), Some("aaa"));
485        assert_eq!(entries_b.len(), 1);
486        assert_eq!(entries_b[0].query.as_deref(), Some("bbb"));
487    }
488
489    #[test]
490    fn test_get_session_access_log_null_session_isolation() {
491        // Entries with NULL session_id within the session's time window must NOT appear.
492        let store = Store::open_in_memory().unwrap();
493        let session = store.session_start("test-proj", None).unwrap();
494        store.log_access(Some(&session.id), "context", None, &[], 100).unwrap();
495        // CLI command writes NULL session_id — must not pollute the session view
496        store.log_access(None, "search", Some("orphan"), &[], 0).unwrap();
497        let entries = store.get_session_access_log(&session.id, None).unwrap();
498        assert_eq!(entries.len(), 1, "NULL session_id entries must not appear in session log");
499        assert_eq!(entries[0].action, "context");
500    }
501
502    #[test]
503    fn test_session_token_stats_null_session_isolation() {
504        // Stats must only count entries attributed to the session, not NULL session_id entries.
505        let store = Store::open_in_memory().unwrap();
506        let mem = make_memory(&store, "key/one");
507        let session = store.session_start("test-proj", None).unwrap();
508        store.log_access(Some(&session.id), "context", None, &[mem], 100).unwrap();
509        // NULL session_id entry — must not inflate stats
510        store.log_access(None, "context", None, &[mem], 200).unwrap();
511        let stats = store.session_token_stats(&session.id).unwrap();
512        assert_eq!(stats.injections, 1, "only 1 injection attributed to session");
513        assert_eq!(stats.unique_memories_injected, 1);
514    }
515
516    #[test]
517    fn test_delete_access_log_entry() {
518        let store = Store::open_in_memory().unwrap();
519        store.log_access(None, "context", None, &[], 100).unwrap();
520        let entries = store.get_session_access_log_all(10).unwrap();
521        assert_eq!(entries.len(), 1);
522        let id = entries[0].id;
523        store.delete_access_log_entry(id).unwrap();
524        let entries = store.get_session_access_log_all(10).unwrap();
525        assert_eq!(entries.len(), 0);
526    }
527
528    #[test]
529    fn test_clear_session_access_log() {
530        let store = Store::open_in_memory().unwrap();
531        let session = store.session_start("proj", None).unwrap();
532        store.log_access(Some(&session.id), "context", None, &[], 100).unwrap();
533        store.log_access(Some(&session.id), "search", Some("q"), &[], 0).unwrap();
534        store.clear_session_access_log(&session.id).unwrap();
535        let entries = store.get_session_access_log(&session.id, None).unwrap();
536        assert_eq!(entries.len(), 0);
537    }
538}