1use super::SqliteStore;
5use crate::error::MemoryError;
6use zeph_common::SessionId;
7#[allow(unused_imports)]
8use zeph_db::sql;
9
10#[derive(Debug, Clone)]
11pub struct ExperimentResultRow {
12 pub id: i64,
13 pub session_id: SessionId,
14 pub parameter: String,
15 pub value_json: String,
16 pub baseline_score: f64,
17 pub candidate_score: f64,
18 pub delta: f64,
19 pub latency_ms: i64,
20 pub tokens_used: i64,
21 pub accepted: bool,
22 pub source: String,
23 pub created_at: String,
24}
25
26#[derive(Debug, Clone)]
27pub struct NewExperimentResult<'a> {
28 pub session_id: &'a str,
29 pub parameter: &'a str,
30 pub value_json: &'a str,
31 pub baseline_score: f64,
32 pub candidate_score: f64,
33 pub delta: f64,
34 pub latency_ms: i64,
35 pub tokens_used: i64,
36 pub accepted: bool,
37 pub source: &'a str,
38}
39
40#[derive(Debug, Clone)]
41pub struct SessionSummaryRow {
42 pub session_id: SessionId,
43 pub total: i64,
44 pub accepted_count: i64,
45 pub best_delta: f64,
46 pub total_tokens: i64,
47}
48
49fn validate_timestamp(s: &str) -> Result<(), MemoryError> {
51 let bytes = s.as_bytes();
52 if bytes.len() < 19 {
54 return Err(MemoryError::InvalidInput(format!(
55 "invalid timestamp format (too short): {s:?}"
56 )));
57 }
58 let sep = bytes[10];
59 if sep != b' ' && sep != b'T' {
60 return Err(MemoryError::InvalidInput(format!(
61 "invalid timestamp format (expected space or T at position 10): {s:?}"
62 )));
63 }
64 let digits_at = [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18];
66 let dashes_at = [4, 7];
67 let colons_at = [13, 16];
68 for i in digits_at {
69 if !bytes[i].is_ascii_digit() {
70 return Err(MemoryError::InvalidInput(format!(
71 "invalid timestamp format (expected digit at {i}): {s:?}"
72 )));
73 }
74 }
75 for i in dashes_at {
76 if bytes[i] != b'-' {
77 return Err(MemoryError::InvalidInput(format!(
78 "invalid timestamp format (expected '-' at {i}): {s:?}"
79 )));
80 }
81 }
82 for i in colons_at {
83 if bytes[i] != b':' {
84 return Err(MemoryError::InvalidInput(format!(
85 "invalid timestamp format (expected ':' at {i}): {s:?}"
86 )));
87 }
88 }
89 Ok(())
90}
91
92type ResultTuple = (
93 i64,
94 String,
95 String,
96 String,
97 f64,
98 f64,
99 f64,
100 i64,
101 i64,
102 bool,
103 String,
104 String,
105);
106
107fn row_from_tuple(t: ResultTuple) -> ExperimentResultRow {
108 ExperimentResultRow {
109 id: t.0,
110 session_id: SessionId::new(t.1),
111 parameter: t.2,
112 value_json: t.3,
113 baseline_score: t.4,
114 candidate_score: t.5,
115 delta: t.6,
116 latency_ms: t.7,
117 tokens_used: t.8,
118 accepted: t.9,
119 source: t.10,
120 created_at: t.11,
121 }
122}
123
124impl SqliteStore {
125 pub async fn insert_experiment_result(
131 &self,
132 result: &NewExperimentResult<'_>,
133 ) -> Result<i64, MemoryError> {
134 let row: (i64,) = zeph_db::query_as(sql!(
135 "INSERT INTO experiment_results \
136 (session_id, parameter, value_json, baseline_score, candidate_score, \
137 delta, latency_ms, tokens_used, accepted, source) \
138 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"
139 ))
140 .bind(result.session_id)
141 .bind(result.parameter)
142 .bind(result.value_json)
143 .bind(result.baseline_score)
144 .bind(result.candidate_score)
145 .bind(result.delta)
146 .bind(result.latency_ms)
147 .bind(result.tokens_used)
148 .bind(result.accepted)
149 .bind(result.source)
150 .fetch_one(&self.pool)
151 .await?;
152 Ok(row.0)
153 }
154
155 pub async fn list_experiment_results(
161 &self,
162 session_id: Option<&str>,
163 limit: u32,
164 ) -> Result<Vec<ExperimentResultRow>, MemoryError> {
165 let created_at_sel =
171 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
172 let rows: Vec<ResultTuple> = if let Some(sid) = session_id {
173 let raw = format!(
174 "SELECT id, session_id, parameter, value_json, baseline_score, candidate_score, \
175 delta, CAST(latency_ms AS BIGINT) AS latency_ms, \
176 CAST(tokens_used AS BIGINT) AS tokens_used, accepted, source, {created_at_sel} \
177 FROM experiment_results WHERE session_id = ? ORDER BY id DESC LIMIT ?"
178 );
179 let query_sql = zeph_db::rewrite_placeholders(&raw);
180 zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
181 .bind(sid)
182 .bind(i64::from(limit))
183 .fetch_all(&self.pool)
184 .await?
185 } else {
186 let raw = format!(
187 "SELECT id, session_id, parameter, value_json, baseline_score, candidate_score, \
188 delta, CAST(latency_ms AS BIGINT) AS latency_ms, \
189 CAST(tokens_used AS BIGINT) AS tokens_used, accepted, source, {created_at_sel} \
190 FROM experiment_results ORDER BY id DESC LIMIT ?"
191 );
192 let query_sql = zeph_db::rewrite_placeholders(&raw);
193 zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
194 .bind(i64::from(limit))
195 .fetch_all(&self.pool)
196 .await?
197 };
198 Ok(rows.into_iter().map(row_from_tuple).collect())
199 }
200
201 pub async fn best_experiment_result(
207 &self,
208 parameter: Option<&str>,
209 ) -> Result<Option<ExperimentResultRow>, MemoryError> {
210 let created_at_sel =
214 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
215 let row: Option<ResultTuple> = if let Some(param) = parameter {
216 let raw = format!(
217 "SELECT id, session_id, parameter, value_json, baseline_score, candidate_score, \
218 delta, CAST(latency_ms AS BIGINT) AS latency_ms, \
219 CAST(tokens_used AS BIGINT) AS tokens_used, accepted, source, {created_at_sel} \
220 FROM experiment_results \
221 WHERE accepted = TRUE AND parameter = ? ORDER BY delta DESC LIMIT 1"
222 );
223 let query_sql = zeph_db::rewrite_placeholders(&raw);
224 zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
225 .bind(param)
226 .fetch_optional(&self.pool)
227 .await?
228 } else {
229 let raw = format!(
230 "SELECT id, session_id, parameter, value_json, baseline_score, candidate_score, \
231 delta, CAST(latency_ms AS BIGINT) AS latency_ms, \
232 CAST(tokens_used AS BIGINT) AS tokens_used, accepted, source, {created_at_sel} \
233 FROM experiment_results \
234 WHERE accepted = TRUE ORDER BY delta DESC LIMIT 1"
235 );
236 let query_sql = zeph_db::rewrite_placeholders(&raw);
237 zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
238 .fetch_optional(&self.pool)
239 .await?
240 };
241 Ok(row.map(row_from_tuple))
242 }
243
244 pub async fn experiment_results_since(
251 &self,
252 since: &str,
253 ) -> Result<Vec<ExperimentResultRow>, MemoryError> {
254 validate_timestamp(since)?;
255 let created_at_sel =
263 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
264 let ts_cast = <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::TIMESTAMPTZ_CAST;
265 let raw = format!(
266 "SELECT id, session_id, parameter, value_json, baseline_score, candidate_score, \
267 delta, CAST(latency_ms AS BIGINT) AS latency_ms, \
268 CAST(tokens_used AS BIGINT) AS tokens_used, accepted, source, {created_at_sel} \
269 FROM experiment_results WHERE created_at >= ?{ts_cast} ORDER BY id DESC LIMIT 10000"
270 );
271 let query_sql = zeph_db::rewrite_placeholders(&raw);
272 let rows: Vec<ResultTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
273 .bind(since)
274 .fetch_all(&self.pool)
275 .await?;
276 Ok(rows.into_iter().map(row_from_tuple).collect())
277 }
278
279 pub async fn experiment_session_summary(
285 &self,
286 session_id: &str,
287 ) -> Result<Option<SessionSummaryRow>, MemoryError> {
288 let row: Option<(String, i64, i64, Option<f64>, i64)> = zeph_db::query_as(sql!(
289 "SELECT session_id, COUNT(*) as total, \
290 SUM(CASE WHEN accepted = TRUE THEN 1 ELSE 0 END) as accepted_count, \
291 MAX(CASE WHEN accepted = TRUE THEN delta ELSE NULL END) as best_delta, \
292 SUM(tokens_used) as total_tokens \
293 FROM experiment_results WHERE session_id = ? GROUP BY session_id"
294 ))
295 .bind(session_id)
296 .fetch_optional(&self.pool)
297 .await?;
298 Ok(row.map(
299 |(sid, total, accepted_count, best_delta, total_tokens)| SessionSummaryRow {
300 session_id: SessionId::new(sid),
301 total,
302 accepted_count,
303 best_delta: best_delta.unwrap_or(0.0),
304 total_tokens,
305 },
306 ))
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 async fn test_store() -> SqliteStore {
315 SqliteStore::new(":memory:").await.unwrap()
316 }
317
318 fn make_result<'a>(
319 session_id: &'a str,
320 parameter: &'a str,
321 accepted: bool,
322 delta: f64,
323 ) -> NewExperimentResult<'a> {
324 NewExperimentResult {
325 session_id,
326 parameter,
327 value_json: r#"{"type":"Float","value":0.7}"#,
328 baseline_score: 7.0,
329 candidate_score: 7.0 + delta,
330 delta,
331 latency_ms: 500,
332 tokens_used: 100,
333 accepted,
334 source: "manual",
335 }
336 }
337
338 #[tokio::test]
339 async fn insert_and_list_results() {
340 let store = test_store().await;
341 let r = make_result("session-1", "temperature", true, 1.0);
342 let id = store.insert_experiment_result(&r).await.unwrap();
343 assert!(id > 0);
344
345 let rows = store
346 .list_experiment_results(Some("session-1"), 10)
347 .await
348 .unwrap();
349 assert_eq!(rows.len(), 1);
350 assert_eq!(rows[0].session_id, "session-1");
351 assert_eq!(rows[0].parameter, "temperature");
352 assert!(rows[0].accepted);
353 assert!((rows[0].delta - 1.0).abs() < f64::EPSILON);
354 }
355
356 #[tokio::test]
357 async fn list_results_no_filter_returns_all() {
358 let store = test_store().await;
359 store
360 .insert_experiment_result(&make_result("s1", "temperature", true, 1.0))
361 .await
362 .unwrap();
363 store
364 .insert_experiment_result(&make_result("s2", "top_p", false, -0.2))
365 .await
366 .unwrap();
367
368 let rows = store.list_experiment_results(None, 10).await.unwrap();
369 assert_eq!(rows.len(), 2);
370 assert_eq!(rows[0].session_id, "s2");
372 }
373
374 #[tokio::test]
375 async fn best_result_returns_accepted_highest_delta() {
376 let store = test_store().await;
377 store
378 .insert_experiment_result(&make_result("s1", "temperature", false, 2.0))
379 .await
380 .unwrap();
381 store
382 .insert_experiment_result(&make_result("s1", "temperature", true, 0.5))
383 .await
384 .unwrap();
385 store
386 .insert_experiment_result(&make_result("s1", "temperature", true, 1.5))
387 .await
388 .unwrap();
389
390 let best = store.best_experiment_result(None).await.unwrap().unwrap();
391 assert!(best.accepted);
392 assert!((best.delta - 1.5).abs() < f64::EPSILON);
393 }
394
395 #[tokio::test]
396 async fn best_result_filtered_by_parameter() {
397 let store = test_store().await;
398 store
399 .insert_experiment_result(&make_result("s1", "temperature", true, 2.0))
400 .await
401 .unwrap();
402 store
403 .insert_experiment_result(&make_result("s1", "top_p", true, 1.0))
404 .await
405 .unwrap();
406
407 let best = store
408 .best_experiment_result(Some("top_p"))
409 .await
410 .unwrap()
411 .unwrap();
412 assert_eq!(best.parameter, "top_p");
413 }
414
415 #[tokio::test]
416 async fn best_result_no_accepted_returns_none() {
417 let store = test_store().await;
418 store
419 .insert_experiment_result(&make_result("s1", "temperature", false, 2.0))
420 .await
421 .unwrap();
422 let best = store.best_experiment_result(None).await.unwrap();
423 assert!(best.is_none());
424 }
425
426 #[tokio::test]
427 async fn session_summary_aggregation() {
428 let store = test_store().await;
429 store
430 .insert_experiment_result(&make_result("sess", "temperature", true, 1.0))
431 .await
432 .unwrap();
433 store
434 .insert_experiment_result(&make_result("sess", "top_p", false, -0.2))
435 .await
436 .unwrap();
437 store
438 .insert_experiment_result(&make_result("sess", "top_k", true, 0.8))
439 .await
440 .unwrap();
441
442 let summary = store
443 .experiment_session_summary("sess")
444 .await
445 .unwrap()
446 .unwrap();
447 assert_eq!(summary.session_id, "sess");
448 assert_eq!(summary.total, 3);
449 assert_eq!(summary.accepted_count, 2);
450 assert!((summary.best_delta - 1.0).abs() < f64::EPSILON);
451 assert_eq!(summary.total_tokens, 300);
452 }
453
454 #[tokio::test]
455 async fn session_summary_unknown_session_returns_none() {
456 let store = test_store().await;
457 let summary = store
458 .experiment_session_summary("nonexistent")
459 .await
460 .unwrap();
461 assert!(summary.is_none());
462 }
463
464 #[tokio::test]
465 async fn results_since_time_filtering() {
466 let store = test_store().await;
467 store
469 .insert_experiment_result(&make_result("s1", "temperature", true, 1.0))
470 .await
471 .unwrap();
472
473 let rows = store
474 .experiment_results_since("2099-01-01 00:00:00")
475 .await
476 .unwrap();
477 assert!(rows.is_empty());
478
479 let rows = store
481 .experiment_results_since("2000-01-01 00:00:00")
482 .await
483 .unwrap();
484 assert_eq!(rows.len(), 1);
485 }
486
487 #[tokio::test]
488 async fn results_since_rejects_invalid_timestamp() {
489 let store = test_store().await;
490 let bad = [
491 "",
492 "not-a-date",
493 "0000-00-00",
494 "2024-01-01",
495 "2024/01/01 00:00:00",
496 ];
497 for ts in bad {
498 let err = store.experiment_results_since(ts).await;
499 assert!(err.is_err(), "expected error for timestamp: {ts:?}");
500 }
501 let store2 = test_store().await;
503 let rows = store2
504 .experiment_results_since("2000-01-01T00:00:00")
505 .await
506 .unwrap();
507 assert!(rows.is_empty());
508 }
509
510 #[tokio::test]
511 async fn list_results_respects_limit() {
512 let store = test_store().await;
513 for i in 0..5 {
514 store
515 .insert_experiment_result(&make_result(
516 "s1",
517 "temperature",
518 i % 2 == 0,
519 f64::from(i),
520 ))
521 .await
522 .unwrap();
523 }
524 let rows = store.list_experiment_results(None, 3).await.unwrap();
525 assert_eq!(rows.len(), 3);
526 }
527}