Skip to main content

zeph_memory/store/
admission_training.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! SQLite-backed store for RL admission control training data (#2416).
5//!
6//! Records ALL messages seen by A-MAC (admitted and rejected) to avoid survivorship
7//! bias in the logistic regression model (critic fix C3). `was_recalled` is set to 1
8//! when `SemanticMemory::recall()` returns the message, providing positive training signal.
9
10#[allow(unused_imports)]
11use zeph_db::sql;
12
13use crate::error::MemoryError;
14use crate::store::SqliteStore;
15use crate::types::{ConversationId, MessageId};
16
17/// Input for recording a single RL admission training sample.
18pub struct AdmissionTrainingInput<'a> {
19    pub message_id: Option<MessageId>,
20    pub conversation_id: ConversationId,
21    pub content: &'a str,
22    pub role: &'a str,
23    pub composite_score: f32,
24    pub was_admitted: bool,
25    pub features_json: &'a str,
26}
27
28/// A single training record for the RL admission model.
29#[derive(Debug, Clone)]
30pub struct AdmissionTrainingRecord {
31    pub id: i64,
32    pub message_id: Option<i64>,
33    pub conversation_id: ConversationId,
34    pub content_hash: String,
35    pub role: String,
36    pub composite_score: f32,
37    pub was_admitted: bool,
38    pub was_recalled: bool,
39    pub features_json: String,
40    pub created_at: String,
41}
42
43/// Compute a stable 16-char hex hash of `content` for deduplication.
44///
45/// Uses the first 8 bytes of SHA-256 truncated to a 16-char hex string.
46/// SHA-256 output is stable across Rust toolchain versions, unlike `DefaultHasher`.
47#[must_use]
48pub fn content_hash(content: &str) -> String {
49    use sha2::{Digest, Sha256};
50    let digest = Sha256::digest(content.as_bytes());
51    let mut bytes = [0u8; 8];
52    bytes.copy_from_slice(&digest[..8]);
53    format!("{:016x}", u64::from_be_bytes(bytes))
54}
55
56impl SqliteStore {
57    /// Record a message in the RL admission training data.
58    ///
59    /// Called for BOTH admitted and rejected messages so the model sees both classes.
60    /// `message_id` is `None` for rejected messages (never persisted to `messages` table).
61    /// `features_json` is the JSON-serialized feature vector used for training.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the database insert fails.
66    pub async fn record_admission_training(
67        &self,
68        input: AdmissionTrainingInput<'_>,
69    ) -> Result<i64, MemoryError> {
70        let hash = content_hash(input.content);
71        let admitted_i = i64::from(input.was_admitted);
72        let msg_id = input.message_id.map(|m| m.0);
73        let (conversation_id, role, composite_score, features_json) = (
74            input.conversation_id,
75            input.role,
76            input.composite_score,
77            input.features_json,
78        );
79        let id = zeph_db::query_scalar(sql!(
80            "INSERT INTO admission_training_data \
81             (message_id, conversation_id, content_hash, role, composite_score, \
82              was_admitted, was_recalled, features_json) \
83             VALUES (?, ?, ?, ?, ?, ?, 0, ?) \
84             RETURNING id"
85        ))
86        .bind(msg_id)
87        .bind(conversation_id.0)
88        .bind(hash)
89        .bind(role)
90        .bind(f64::from(composite_score))
91        .bind(admitted_i)
92        .bind(features_json)
93        .fetch_one(&self.pool)
94        .await?;
95        Ok(id)
96    }
97
98    /// Mark training records as recalled for the given message IDs.
99    ///
100    /// Called after `batch_increment_access_count()` in `SemanticMemory::recall()`.
101    /// Sets `was_recalled = 1` and updates `updated_at` for all matching records.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the database update fails.
106    pub async fn mark_training_recalled(
107        &self,
108        message_ids: &[MessageId],
109    ) -> Result<(), MemoryError> {
110        if message_ids.is_empty() {
111            return Ok(());
112        }
113        let placeholders = zeph_db::placeholder_list(1, message_ids.len());
114        let query = format!(
115            "UPDATE admission_training_data \
116             SET was_recalled = 1, updated_at = CURRENT_TIMESTAMP \
117             WHERE message_id IN ({placeholders})"
118        );
119        let mut q = zeph_db::query(sqlx::AssertSqlSafe(query));
120        for id in message_ids {
121            q = q.bind(id.0);
122        }
123        q.execute(&self.pool).await?;
124        Ok(())
125    }
126
127    /// Count total training records (admitted + rejected).
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the database query fails.
132    pub async fn count_training_records(&self) -> Result<i64, MemoryError> {
133        let count = zeph_db::query_scalar(sql!("SELECT COUNT(*) FROM admission_training_data"))
134            .fetch_one(&self.pool)
135            .await?;
136        Ok(count)
137    }
138
139    /// Get a batch of training records for model training.
140    ///
141    /// Returns up to `limit` records ordered by creation time (oldest first).
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the database query fails.
146    pub async fn get_training_batch(
147        &self,
148        limit: usize,
149    ) -> Result<Vec<AdmissionTrainingRecord>, MemoryError> {
150        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
151        // `created_at` is `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project through
152        // `Dialect::select_as_text` so it decodes into the `String` tuple field below.
153        // `ORDER BY` is table-qualified so it sorts on the native timestamp, not the cast text.
154        // `composite_score` is `REAL` (`FLOAT4`) on Postgres but the tuple decodes it as `f64`;
155        // `CAST(... AS DOUBLE PRECISION)` widens it (same un-gated idiom already used for
156        // `weight`/`confidence_fast`/`confidence_slow` in `graph/store/mod.rs`).
157        // `was_admitted`/`was_recalled` are `INTEGER` (`INT4`) on Postgres, so they decode as
158        // `i32`, not `i64` (INT8) — same rule as any other INTEGER/INT4 column in this crate.
159        let created_at_sel =
160            <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
161        let raw = format!(
162            "SELECT id, message_id, conversation_id, content_hash, role, \
163                    CAST(composite_score AS DOUBLE PRECISION) AS composite_score, \
164                    was_admitted, was_recalled, features_json, {created_at_sel} \
165             FROM admission_training_data \
166             ORDER BY admission_training_data.created_at ASC \
167             LIMIT ?"
168        );
169        let query_sql = zeph_db::rewrite_placeholders(&raw);
170        let rows = zeph_db::query_as::<
171            _,
172            (
173                i64,
174                Option<i64>,
175                i64,
176                String,
177                String,
178                f64,
179                i32,
180                i32,
181                String,
182                String,
183            ),
184        >(sqlx::AssertSqlSafe(query_sql))
185        .bind(limit)
186        .fetch_all(&self.pool)
187        .await?;
188
189        Ok(rows
190            .into_iter()
191            .map(
192                |(id, msg_id, cid, hash, role, score, admitted, recalled, features, created_at)| {
193                    AdmissionTrainingRecord {
194                        id,
195                        message_id: msg_id,
196                        conversation_id: ConversationId(cid),
197                        content_hash: hash,
198                        role,
199                        #[expect(clippy::cast_possible_truncation)]
200                        composite_score: score as f32,
201                        was_admitted: admitted != 0,
202                        was_recalled: recalled != 0,
203                        features_json: features,
204                        created_at,
205                    }
206                },
207            )
208            .collect())
209    }
210
211    /// Delete old training records, keeping the most recent `keep_recent`.
212    ///
213    /// Called after each retraining cycle to prevent unbounded table growth.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if the database delete fails.
218    // TODO(#2416): call cleanup_old_training_data() in the RL retrain loop scheduled in
219    // bootstrap/mod.rs once the retrain loop is wired.
220    pub async fn cleanup_old_training_data(&self, keep_recent: usize) -> Result<(), MemoryError> {
221        let keep = i64::try_from(keep_recent).unwrap_or(i64::MAX);
222        zeph_db::query(sql!(
223            "DELETE FROM admission_training_data \
224             WHERE id NOT IN ( \
225                 SELECT id FROM admission_training_data \
226                 ORDER BY created_at DESC \
227                 LIMIT ? \
228             )"
229        ))
230        .bind(keep)
231        .execute(&self.pool)
232        .await?;
233        Ok(())
234    }
235
236    /// Save trained RL model weights to `SQLite` for persistence across restarts.
237    ///
238    /// Uses a fixed `id = 1` row (INSERT OR REPLACE) so the table never grows beyond
239    /// one row — avoiding unbounded growth from repeated retrain cycles.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if the database upsert fails.
244    pub async fn save_rl_weights(
245        &self,
246        weights_json: &str,
247        sample_count: i64,
248    ) -> Result<(), MemoryError> {
249        zeph_db::query(sql!(
250            "INSERT INTO admission_rl_weights (id, weights_json, sample_count) \
251             VALUES (1, ?, ?) \
252             ON CONFLICT (id) DO UPDATE SET \
253               weights_json = EXCLUDED.weights_json, \
254               sample_count = EXCLUDED.sample_count"
255        ))
256        .bind(weights_json)
257        .bind(sample_count)
258        .execute(&self.pool)
259        .await?;
260        Ok(())
261    }
262
263    /// Load the latest RL model weights from `SQLite`.
264    ///
265    /// Returns `None` if no weights have been saved yet.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the database query fails.
270    pub async fn load_rl_weights(&self) -> Result<Option<(String, i64)>, MemoryError> {
271        let row: Option<(String, i64)> = zeph_db::query_as(sql!(
272            "SELECT weights_json, sample_count FROM admission_rl_weights \
273             ORDER BY id DESC LIMIT 1"
274        ))
275        .fetch_optional(&self.pool)
276        .await?;
277        Ok(row)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    async fn make_store() -> (SqliteStore, i64) {
286        let store = SqliteStore::new(":memory:")
287            .await
288            .expect("SqliteStore::new");
289        let cid = store
290            .create_conversation()
291            .await
292            .expect("create_conversation");
293        (store, cid.0)
294    }
295
296    #[tokio::test]
297    async fn record_and_count_training_data() {
298        let (store, cid) = make_store().await;
299        let cid = ConversationId(cid);
300        store
301            .record_admission_training(AdmissionTrainingInput {
302                message_id: None,
303                conversation_id: cid,
304                content: "content",
305                role: "user",
306                composite_score: 0.5,
307                was_admitted: false,
308                features_json: "[]",
309            })
310            .await
311            .expect("record rejected");
312        store
313            .record_admission_training(AdmissionTrainingInput {
314                message_id: Some(MessageId(1)),
315                conversation_id: cid,
316                content: "content2",
317                role: "assistant",
318                composite_score: 0.8,
319                was_admitted: true,
320                features_json: "[]",
321            })
322            .await
323            .expect("record admitted");
324        let count = store.count_training_records().await.expect("count");
325        assert_eq!(count, 2);
326    }
327
328    #[tokio::test]
329    async fn mark_recalled_sets_flag() {
330        let (store, cid) = make_store().await;
331        let cid = ConversationId(cid);
332        store
333            .record_admission_training(AdmissionTrainingInput {
334                message_id: Some(MessageId(42)),
335                conversation_id: cid,
336                content: "recalled content",
337                role: "user",
338                composite_score: 0.7,
339                was_admitted: true,
340                features_json: "[]",
341            })
342            .await
343            .expect("record");
344        store
345            .mark_training_recalled(&[MessageId(42)])
346            .await
347            .expect("mark recalled");
348        let batch = store.get_training_batch(10).await.expect("batch");
349        assert_eq!(batch.len(), 1);
350        assert!(
351            batch[0].was_recalled,
352            "was_recalled must be true after marking"
353        );
354    }
355
356    #[tokio::test]
357    async fn rejected_message_has_no_message_id() {
358        let (store, cid) = make_store().await;
359        let cid = ConversationId(cid);
360        store
361            .record_admission_training(AdmissionTrainingInput {
362                message_id: None,
363                conversation_id: cid,
364                content: "rejected",
365                role: "user",
366                composite_score: 0.2,
367                was_admitted: false,
368                features_json: "[]",
369            })
370            .await
371            .expect("record");
372        let batch = store.get_training_batch(10).await.expect("batch");
373        assert_eq!(batch.len(), 1);
374        assert!(!batch[0].was_admitted);
375        assert!(batch[0].message_id.is_none());
376    }
377
378    #[tokio::test]
379    async fn cleanup_trims_old_records() {
380        let (store, cid) = make_store().await;
381        let cid = ConversationId(cid);
382        for i in 0..5_i64 {
383            let content = format!("content {i}");
384            store
385                .record_admission_training(AdmissionTrainingInput {
386                    message_id: Some(MessageId(i)),
387                    conversation_id: cid,
388                    content: &content,
389                    role: "user",
390                    composite_score: 0.5,
391                    was_admitted: true,
392                    features_json: "[]",
393                })
394                .await
395                .expect("record");
396        }
397        // Keep only 2 most recent.
398        store.cleanup_old_training_data(2).await.expect("cleanup");
399        let count = store.count_training_records().await.expect("count");
400        assert_eq!(count, 2);
401    }
402
403    #[tokio::test]
404    async fn save_and_load_rl_weights() {
405        let (store, _) = make_store().await;
406        store
407            .save_rl_weights(r#"{"weights":[0.1,0.2],"bias":0.0}"#, 100)
408            .await
409            .expect("save");
410        let loaded = store.load_rl_weights().await.expect("load");
411        assert!(loaded.is_some());
412        let (json, count) = loaded.unwrap();
413        assert!(json.contains("weights"));
414        assert_eq!(count, 100);
415    }
416
417    #[tokio::test]
418    async fn load_rl_weights_returns_none_when_empty() {
419        let (store, _) = make_store().await;
420        let loaded = store.load_rl_weights().await.expect("load");
421        assert!(loaded.is_none());
422    }
423
424    #[test]
425    fn content_hash_is_deterministic() {
426        let h1 = content_hash("hello world");
427        let h2 = content_hash("hello world");
428        assert_eq!(h1, h2);
429    }
430
431    #[test]
432    fn content_hash_differs_for_different_content() {
433        let h1 = content_hash("hello");
434        let h2 = content_hash("world");
435        assert_ne!(h1, h2);
436    }
437}