1#[allow(unused_imports)]
11use zeph_db::sql;
12
13use crate::error::MemoryError;
14use crate::store::SqliteStore;
15use crate::types::{ConversationId, MessageId};
16
17pub 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#[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#[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 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 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 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 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 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 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 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 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 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}