zeph_memory/five_signal/
access_frequency.rs1use std::collections::HashMap;
5
6use sqlx::Row as _;
7use zeph_db::{DbPool, sql};
8
9use crate::types::MessageId;
10
11const MAX_ACCESS_COUNT: f64 = 10_000.0;
14
15pub struct AccessFrequencyCache {
20 pool: DbPool,
21}
22
23impl AccessFrequencyCache {
24 #[must_use]
26 pub fn new(pool: DbPool) -> Self {
27 Self { pool }
28 }
29
30 #[tracing::instrument(
39 name = "memory.five_signal.access_frequency.load",
40 skip(self, fact_ids),
41 fields(fact_count = fact_ids.len())
42 )]
43 pub async fn load_for_candidates(
44 &self,
45 session_id: &str,
46 fact_ids: &[MessageId],
47 ) -> Result<HashMap<MessageId, f64>, crate::error::MemoryError> {
48 tracing::debug!("five_signal: loading access frequencies");
49
50 if fact_ids.is_empty() {
51 return Ok(HashMap::new());
52 }
53
54 let ids: Vec<i64> = fact_ids.iter().map(|id| id.0).collect();
55
56 let session_placeholder = zeph_db::numbered_placeholder(1);
59 let placeholders = zeph_db::placeholder_list(2, ids.len());
60
61 let sql = format!(
62 "SELECT fact_id, COUNT(*) as cnt FROM fact_access_log \
63 WHERE session_id = {session_placeholder} AND fact_id IN ({placeholders}) \
64 GROUP BY fact_id"
65 );
66
67 let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(session_id);
68 for id in &ids {
69 q = q.bind(id);
70 }
71
72 let rows = q
73 .fetch_all(&self.pool)
74 .await
75 .map_err(|e| crate::error::MemoryError::Db(e.into()))?;
76
77 let counts: HashMap<i64, i64> = rows
78 .iter()
79 .map(|row| (row.get::<i64, _>("fact_id"), row.get::<i64, _>("cnt")))
80 .collect();
81
82 let normalized = fact_ids
83 .iter()
84 .map(|id| {
85 #[expect(clippy::cast_precision_loss)]
86 let raw = *counts.get(&id.0).unwrap_or(&0) as f64;
87 let score =
88 (1.0_f64 + raw.min(MAX_ACCESS_COUNT)).ln() / (1.0 + MAX_ACCESS_COUNT).ln();
89 (*id, score)
90 })
91 .collect();
92
93 Ok(normalized)
94 }
95
96 #[tracing::instrument(
100 name = "memory.five_signal.access_frequency.log",
101 skip(self, fact_type, session_id),
102 fields(fact_id = fact_id.0)
103 )]
104 pub async fn log_access(&self, fact_id: MessageId, fact_type: &str, session_id: &str) {
105 tracing::debug!("five_signal: logging access");
106
107 let accessed_at = std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
110
111 let res = zeph_db::query(sql!(
112 "INSERT INTO fact_access_log (fact_id, fact_type, session_id, accessed_at) \
113 VALUES (?, ?, ?, ?)"
114 ))
115 .bind(fact_id.0)
116 .bind(fact_type)
117 .bind(session_id)
118 .bind(accessed_at)
119 .execute(&self.pool)
120 .await;
121
122 if let Err(e) = res {
123 tracing::warn!(
124 fact_id = fact_id.0,
125 error = %e,
126 "five_signal: failed to log fact access (non-fatal)"
127 );
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 async fn test_pool() -> DbPool {
137 crate::store::SqliteStore::with_pool_size(":memory:", 1)
138 .await
139 .expect("in-memory SQLite failed")
140 .pool()
141 .clone()
142 }
143
144 #[tokio::test]
145 async fn load_for_candidates_empty_returns_empty() {
146 let pool = test_pool().await;
147 let cache = AccessFrequencyCache::new(pool);
148 let result = cache.load_for_candidates("s1", &[]).await.unwrap();
149 assert!(result.is_empty());
150 }
151
152 #[tokio::test]
153 async fn load_for_candidates_no_rows_gives_zero_score() {
154 let pool = test_pool().await;
155 let cache = AccessFrequencyCache::new(pool);
156 let ids = vec![MessageId(1), MessageId(2)];
157 let scores = cache.load_for_candidates("s1", &ids).await.unwrap();
158 assert_eq!(scores.len(), 2);
159 assert!(scores[&MessageId(1)] < f64::EPSILON);
160 assert!(scores[&MessageId(2)] < f64::EPSILON);
161 }
162
163 #[tokio::test]
164 async fn load_for_candidates_higher_count_gives_higher_score() {
165 let pool = test_pool().await;
166 let cache = AccessFrequencyCache::new(pool.clone());
167 let session = "test-session";
168
169 sqlx::query(
171 "INSERT INTO fact_access_log (fact_id, fact_type, session_id, accessed_at) \
172 VALUES (?1, 'episodic', ?2, 0)",
173 )
174 .bind(10_i64)
175 .bind(session)
176 .execute(&pool)
177 .await
178 .unwrap();
179
180 for _ in 0..5_u8 {
181 sqlx::query(
182 "INSERT INTO fact_access_log (fact_id, fact_type, session_id, accessed_at) \
183 VALUES (?1, 'episodic', ?2, 0)",
184 )
185 .bind(20_i64)
186 .bind(session)
187 .execute(&pool)
188 .await
189 .unwrap();
190 }
191
192 let ids = vec![MessageId(10), MessageId(20)];
193 let scores = cache.load_for_candidates(session, &ids).await.unwrap();
194
195 let s10 = scores[&MessageId(10)];
196 let s20 = scores[&MessageId(20)];
197 assert!(
198 s20 > s10,
199 "higher access count must yield higher score: {s20} vs {s10}"
200 );
201 assert!(s10 > 0.0, "score for fact with 1 access must be > 0");
202 assert!(s20 <= 1.0, "score must be capped at 1.0");
203 }
204
205 #[tokio::test]
206 async fn load_for_candidates_ignores_other_sessions() {
207 let pool = test_pool().await;
208 let cache = AccessFrequencyCache::new(pool.clone());
209
210 sqlx::query(
211 "INSERT INTO fact_access_log (fact_id, fact_type, session_id, accessed_at) \
212 VALUES (?1, 'episodic', ?2, 0)",
213 )
214 .bind(99_i64)
215 .bind("other-session")
216 .execute(&pool)
217 .await
218 .unwrap();
219
220 let ids = vec![MessageId(99)];
221 let scores = cache.load_for_candidates("my-session", &ids).await.unwrap();
222 assert!(
223 scores[&MessageId(99)] < f64::EPSILON,
224 "score must be 0 for different session"
225 );
226 }
227
228 #[test]
229 fn normalization_zero_count() {
230 let raw = 0.0_f64;
231 let score = (1.0 + raw.min(MAX_ACCESS_COUNT)).ln() / (1.0 + MAX_ACCESS_COUNT).ln();
232 assert!((score).abs() < 1e-9, "zero access → score 0.0");
233 }
234
235 #[test]
236 fn normalization_max_count() {
237 let raw = MAX_ACCESS_COUNT;
238 let score = (1.0 + raw.min(MAX_ACCESS_COUNT)).ln() / (1.0 + MAX_ACCESS_COUNT).ln();
239 assert!((score - 1.0).abs() < 1e-9, "max access → score 1.0");
240 }
241
242 #[test]
243 fn normalization_overflow_clamped() {
244 let raw = MAX_ACCESS_COUNT * 2.0;
245 let score = (1.0 + raw.min(MAX_ACCESS_COUNT)).ln() / (1.0 + MAX_ACCESS_COUNT).ln();
246 assert!((score - 1.0).abs() < 1e-9, "overflow is clamped to 1.0");
247 }
248
249 #[test]
250 fn normalization_monotone() {
251 let score_low = (1.0 + 10.0_f64.min(MAX_ACCESS_COUNT)).ln() / (1.0 + MAX_ACCESS_COUNT).ln();
252 let score_high =
253 (1.0 + 100.0_f64.min(MAX_ACCESS_COUNT)).ln() / (1.0 + MAX_ACCESS_COUNT).ln();
254 assert!(score_high > score_low);
255 }
256}