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    pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
63        let mut stmt = self.conn().prepare(
64            "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
65        )?;
66        let results = stmt
67            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
68            .collect::<std::result::Result<Vec<_>, _>>()?;
69        Ok(results)
70    }
71
72    pub fn access_log_total(&self) -> Result<i64> {
73        Ok(self.conn().query_row(
74            "SELECT COUNT(*) FROM access_log",
75            [],
76            |row| row.get(0),
77        )?)
78    }
79
80    pub fn dedup_total(&self) -> Result<i64> {
81        Ok(self.conn().query_row(
82            "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
83            [],
84            |row| row.get(0),
85        )?)
86    }
87
88    pub fn revision_total(&self) -> Result<i64> {
89        Ok(self.conn().query_row(
90            "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
91            [],
92            |row| row.get(0),
93        )?)
94    }
95
96    pub fn low_roi_count(&self) -> Result<i64> {
97        Ok(self.conn().query_row(
98            "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
99            [],
100            |row| row.get(0),
101        )?)
102    }
103
104    pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
105        let mut stmt = self.conn().prepare(
106            "SELECT query, COUNT(*) as cnt FROM access_log
107             WHERE action = 'search' AND query IS NOT NULL
108             GROUP BY lower(query)
109             ORDER BY cnt DESC
110             LIMIT ?1",
111        )?;
112        let results = stmt
113            .query_map(rusqlite::params![limit], |row| {
114                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
115            })?
116            .collect::<std::result::Result<Vec<_>, _>>()?;
117        Ok(results)
118    }
119
120    pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
121        let mut stmt = self.conn().prepare(
122            "SELECT m.id, m.key, m.scope,
123                    COALESCE(mt.injections, 0),
124                    COALESCE(mt.hits, 0),
125                    COALESCE(mt.tokens_injected, 0),
126                    CASE WHEN COALESCE(mt.injections, 0) > 0
127                         THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
128                         ELSE 0.0 END
129             FROM memories m
130             LEFT JOIN metrics mt ON mt.memory_id = m.id
131             WHERE m.deleted_at IS NULL
132             ORDER BY COALESCE(mt.injections, 0) DESC
133             LIMIT 100",
134        )?;
135        let results = stmt
136            .query_map([], |row| {
137                Ok(MemoryMetric {
138                    id: row.get(0)?,
139                    key: row.get(1)?,
140                    scope: row.get(2)?,
141                    injections: row.get(3)?,
142                    hits: row.get(4)?,
143                    tokens_injected: row.get(5)?,
144                    hit_rate: row.get(6)?,
145                })
146            })?
147            .collect::<std::result::Result<Vec<_>, _>>()?;
148        Ok(results)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use crate::store::Store;
155    use crate::types::SaveParams;
156
157    fn make_memory(store: &Store, key: &str) -> i64 {
158        store
159            .save(SaveParams {
160                key: key.to_string(),
161                value: "test value".to_string(),
162                ..Default::default()
163            })
164            .unwrap()
165            .id()
166    }
167
168    #[test]
169    fn test_log_access_no_query() {
170        let store = Store::open_in_memory().unwrap();
171        store.log_access("search", None, &[]).unwrap();
172    }
173
174    #[test]
175    fn test_log_access_with_query_and_ids() {
176        let store = Store::open_in_memory().unwrap();
177        let id = make_memory(&store, "test/key");
178        store.log_access("search", Some("test query"), &[id]).unwrap();
179    }
180
181    #[test]
182    fn test_record_injection_increments() {
183        let store = Store::open_in_memory().unwrap();
184        let id = make_memory(&store, "test/key");
185
186        store.record_injection(&[id]).unwrap();
187        store.record_injection(&[id]).unwrap();
188
189        let metrics = store.get_metrics().unwrap();
190        let m = metrics.iter().find(|m| m.id == id).unwrap();
191        assert_eq!(m.injections, 2);
192    }
193
194    #[test]
195    fn test_record_hit_increments() {
196        let store = Store::open_in_memory().unwrap();
197        let id = make_memory(&store, "test/key");
198
199        store.record_hit(id).unwrap();
200        store.record_hit(id).unwrap();
201        store.record_hit(id).unwrap();
202
203        let metrics = store.get_metrics().unwrap();
204        let m = metrics.iter().find(|m| m.id == id).unwrap();
205        assert_eq!(m.hits, 3);
206    }
207
208    #[test]
209    fn test_hit_rate_calculation() {
210        let store = Store::open_in_memory().unwrap();
211        let id = make_memory(&store, "test/key");
212
213        store.record_injection(&[id]).unwrap();
214        store.record_injection(&[id]).unwrap();
215        store.record_hit(id).unwrap();
216
217        let metrics = store.get_metrics().unwrap();
218        let m = metrics.iter().find(|m| m.id == id).unwrap();
219        assert_eq!(m.injections, 2);
220        assert_eq!(m.hits, 1);
221        assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
222    }
223
224    #[test]
225    fn test_hit_rate_zero_when_no_injections() {
226        let store = Store::open_in_memory().unwrap();
227        let id = make_memory(&store, "test/key");
228
229        let metrics = store.get_metrics().unwrap();
230        let m = metrics.iter().find(|m| m.id == id).unwrap();
231        assert_eq!(m.hit_rate, 0.0);
232    }
233
234    #[test]
235    fn test_get_metrics_excludes_deleted() {
236        let store = Store::open_in_memory().unwrap();
237        let id = make_memory(&store, "test/key");
238        store.delete("test/key", None, false).unwrap();
239
240        let metrics = store.get_metrics().unwrap();
241        assert!(metrics.iter().find(|m| m.id == id).is_none());
242    }
243
244    #[test]
245    fn test_get_metrics_ordered_by_injections_desc() {
246        let store = Store::open_in_memory().unwrap();
247        let id1 = make_memory(&store, "key/one");
248        let id2 = make_memory(&store, "key/two");
249
250        store.record_injection(&[id1]).unwrap();
251        store.record_injection(&[id1]).unwrap();
252        store.record_injection(&[id1]).unwrap();
253        store.record_injection(&[id2]).unwrap();
254
255        let metrics = store.get_metrics().unwrap();
256        let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
257        let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
258        assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
259    }
260
261    #[test]
262    fn test_record_injection_multiple_ids() {
263        let store = Store::open_in_memory().unwrap();
264        let id1 = make_memory(&store, "key/one");
265        let id2 = make_memory(&store, "key/two");
266
267        store.record_injection(&[id1, id2]).unwrap();
268
269        let metrics = store.get_metrics().unwrap();
270        let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
271        let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
272        assert_eq!(m1.injections, 1);
273        assert_eq!(m2.injections, 1);
274    }
275}