Skip to main content

zeph_memory/five_signal/
access_frequency.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5
6use sqlx::Row as _;
7use zeph_db::{DbPool, sql};
8
9use crate::types::MessageId;
10
11/// Cap applied before log-normalization to prevent a single ultra-hot fact from
12/// dominating frequency scores across the candidate set.
13const MAX_ACCESS_COUNT: f64 = 10_000.0;
14
15/// Per-turn access frequency aggregator backed by `fact_access_log`.
16///
17/// Loads raw access counts for a candidate set in a single `GROUP BY` query per turn.
18/// Normalized values are `log(1 + count) / log(1 + MAX_ACCESS_COUNT)` ∈ `[0.0, 1.0]`.
19pub struct AccessFrequencyCache {
20    pool: DbPool,
21}
22
23impl AccessFrequencyCache {
24    /// Create a new cache backed by the given pool.
25    #[must_use]
26    pub fn new(pool: DbPool) -> Self {
27        Self { pool }
28    }
29
30    /// Load and normalize access counts for `fact_ids` within `session_id`.
31    ///
32    /// Issues a single SQL `GROUP BY` query indexed by `(session_id, accessed_at DESC)`.
33    /// Returns a map of `fact_id → normalized_score ∈ [0.0, 1.0]`.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if the database query fails.
38    #[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        // sqlx does not support binding Vec<i64> with IN directly for all backends;
57        // build the query with placeholders manually.
58        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    /// Record a fact access event in `fact_access_log`.
97    ///
98    /// Failures are logged as `WARN` and do not propagate — access logging is non-critical.
99    #[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        // Insert 1 access for fact 10 and 5 accesses for fact 20.
170        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}