1use super::*;
2
3#[derive(Debug, Clone, serde::Serialize)]
5pub struct LearningTrack {
6 pub track_id: String,
7 pub chat_id: i64,
8 pub name: String,
9 pub objective: String,
10 pub directions: serde_json::Value,
11 pub allowed_sources: serde_json::Value,
12 pub schedule: String,
13 pub timezone: String,
14 pub token_budget: i64,
15 pub max_sources: i64,
16 pub promotion_mode: String,
17 pub status: String,
18 pub next_run: String,
19 pub last_run: Option<String>,
20 pub created_at: String,
21 pub updated_at: String,
22}
23
24#[derive(Debug, Clone, serde::Serialize)]
25pub struct LearningEpoch {
26 pub epoch_id: String,
27 pub track_id: String,
28 pub experience_run_id: Option<String>,
29 pub status: String,
30 pub report: Option<String>,
31 pub candidate_id: Option<String>,
32 pub error: Option<String>,
33 pub started_at: String,
34 pub finished_at: Option<String>,
35}
36
37#[derive(Debug, Clone, serde::Serialize)]
40pub struct LearningTrackCandidate {
41 pub candidate_id: String,
42 pub epoch_id: String,
43 pub skill_name: String,
44 pub description: String,
45 pub instructions: String,
46 pub sources: serde_json::Value,
47 pub tests: serde_json::Value,
48 pub risk: String,
49 pub content_hash: String,
50 pub status: String,
51 pub created_at: String,
52 pub updated_at: String,
53}
54
55#[derive(Debug, Clone, serde::Serialize)]
56pub struct LearningCandidateEvaluation {
57 pub evaluation_id: String,
58 pub candidate_id: String,
59 pub status: String,
60 pub sample_count: i64,
61 pub baseline_passed: i64,
62 pub candidate_passed: i64,
63 pub regression_count: i64,
64 pub baseline_tokens: i64,
65 pub candidate_tokens: i64,
66 pub baseline_duration_ms: i64,
67 pub candidate_duration_ms: i64,
68 pub reason: Option<String>,
69 pub started_at: String,
70 pub finished_at: Option<String>,
71}
72
73#[derive(Debug, Clone, serde::Serialize)]
74pub struct LearningCandidateTrial {
75 pub trial_id: String,
76 pub evaluation_id: String,
77 pub test_name: String,
78 pub baseline_passed: bool,
79 pub candidate_passed: bool,
80 pub baseline_tokens: i64,
81 pub candidate_tokens: i64,
82 pub baseline_duration_ms: i64,
83 pub candidate_duration_ms: i64,
84 pub evidence: serde_json::Value,
85 pub created_at: String,
86}
87
88pub(crate) fn map_learning_track(row: &rusqlite::Row<'_>) -> rusqlite::Result<LearningTrack> {
89 let directions: String = row.get(4)?;
90 let allowed_sources: String = row.get(5)?;
91 Ok(LearningTrack {
92 track_id: row.get(0)?,
93 chat_id: row.get(1)?,
94 name: row.get(2)?,
95 objective: row.get(3)?,
96 directions: serde_json::from_str(&directions).unwrap_or_default(),
97 allowed_sources: serde_json::from_str(&allowed_sources).unwrap_or_default(),
98 schedule: row.get(6)?,
99 timezone: row.get(7)?,
100 token_budget: row.get(8)?,
101 max_sources: row.get(9)?,
102 promotion_mode: row.get(10)?,
103 status: row.get(11)?,
104 next_run: row.get(12)?,
105 last_run: row.get(13)?,
106 created_at: row.get(14)?,
107 updated_at: row.get(15)?,
108 })
109}
110
111impl Database {
112 #[expect(clippy::too_many_arguments)]
113 pub fn create_learning_track(
114 &self,
115 chat_id: i64,
116 name: &str,
117 objective: &str,
118 directions: &serde_json::Value,
119 allowed_sources: &serde_json::Value,
120 schedule: &str,
121 timezone: &str,
122 token_budget: i64,
123 max_sources: i64,
124 promotion_mode: &str,
125 next_run: &str,
126 ) -> Result<String, MicroClawError> {
127 if name.trim().is_empty()
128 || name.len() > 96
129 || objective.trim().is_empty()
130 || objective.len() > 8_000
131 || schedule.trim().is_empty()
132 || !matches!(promotion_mode, "propose" | "manual")
133 || !(1_000..=2_000_000).contains(&token_budget)
134 || !(1..=100).contains(&max_sources)
135 {
136 return Err(MicroClawError::ToolExecution(
137 "invalid learning track configuration".into(),
138 ));
139 }
140 let track_id = uuid::Uuid::new_v4().to_string();
141 let now = chrono::Utc::now().to_rfc3339();
142 let conn = self.lock_conn();
143 conn.execute(
144 "INSERT INTO learning_tracks(
145 track_id, chat_id, name, objective, directions_json,
146 allowed_sources_json, schedule, timezone, token_budget,
147 max_sources, promotion_mode, status, next_run, created_at,
148 updated_at
149 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,'active',?12,?13,?13)",
150 params![
151 track_id,
152 chat_id,
153 name.trim(),
154 objective.trim(),
155 directions.to_string(),
156 allowed_sources.to_string(),
157 schedule,
158 timezone,
159 token_budget,
160 max_sources,
161 promotion_mode,
162 next_run,
163 now
164 ],
165 )?;
166 Ok(track_id)
167 }
168
169 pub fn list_learning_tracks(
170 &self,
171 chat_id: Option<i64>,
172 ) -> Result<Vec<LearningTrack>, MicroClawError> {
173 let conn = self.lock_conn();
174 let mut stmt = conn.prepare(
175 "SELECT track_id, chat_id, name, objective, directions_json,
176 allowed_sources_json, schedule, timezone, token_budget,
177 max_sources, promotion_mode, status, next_run, last_run,
178 created_at, updated_at
179 FROM learning_tracks
180 WHERE (?1 IS NULL OR chat_id=?1)
181 ORDER BY updated_at DESC",
182 )?;
183 let rows = stmt
184 .query_map(params![chat_id], map_learning_track)?
185 .collect::<Result<Vec<_>, _>>()?;
186 Ok(rows)
187 }
188
189 pub fn learning_track_belongs_to_chat(
190 &self,
191 track_id: &str,
192 chat_id: i64,
193 ) -> Result<bool, MicroClawError> {
194 let conn = self.lock_conn();
195 let count: i64 = conn.query_row(
196 "SELECT COUNT(*) FROM learning_tracks WHERE track_id=?1 AND chat_id=?2",
197 params![track_id, chat_id],
198 |row| row.get(0),
199 )?;
200 Ok(count == 1)
201 }
202
203 pub fn update_learning_track_status(
204 &self,
205 track_id: &str,
206 status: &str,
207 next_run: Option<&str>,
208 ) -> Result<bool, MicroClawError> {
209 if !matches!(status, "active" | "paused" | "archived") {
210 return Err(MicroClawError::ToolExecution(
211 "invalid learning track status".into(),
212 ));
213 }
214 let conn = self.lock_conn();
215 let changed = conn.execute(
216 "UPDATE learning_tracks SET status=?2,
217 next_run=COALESCE(?3,next_run), updated_at=?4
218 WHERE track_id=?1",
219 params![track_id, status, next_run, chrono::Utc::now().to_rfc3339()],
220 )?;
221 Ok(changed == 1)
222 }
223
224 pub fn claim_due_learning_tracks(
228 &self,
229 now: &str,
230 next_runs: &[(String, String)],
231 ) -> Result<Vec<LearningTrack>, MicroClawError> {
232 let mut conn = self.lock_conn();
233 let tx = conn.transaction()?;
234 let mut claimed = Vec::new();
235 for (track_id, next_run) in next_runs {
236 let changed = tx.execute(
237 "UPDATE learning_tracks SET next_run=?3, last_run=?2,
238 updated_at=?2
239 WHERE track_id=?1 AND status='active' AND next_run<=?2",
240 params![track_id, now, next_run],
241 )?;
242 if changed == 1 {
243 claimed.push(tx.query_row(
244 "SELECT track_id, chat_id, name, objective, directions_json,
245 allowed_sources_json, schedule, timezone, token_budget,
246 max_sources, promotion_mode, status, next_run, last_run,
247 created_at, updated_at
248 FROM learning_tracks WHERE track_id=?1",
249 params![track_id],
250 map_learning_track,
251 )?);
252 }
253 }
254 tx.commit()?;
255 Ok(claimed)
256 }
257
258 pub fn start_learning_epoch(
259 &self,
260 track_id: &str,
261 experience_run_id: &str,
262 ) -> Result<String, MicroClawError> {
263 let epoch_id = uuid::Uuid::new_v4().to_string();
264 let conn = self.lock_conn();
265 conn.execute(
266 "INSERT INTO learning_epochs(
267 epoch_id, track_id, experience_run_id, status, started_at
268 ) VALUES (?1,?2,?3,'running',?4)",
269 params![
270 epoch_id,
271 track_id,
272 experience_run_id,
273 chrono::Utc::now().to_rfc3339()
274 ],
275 )?;
276 Ok(epoch_id)
277 }
278
279 pub fn finish_learning_epoch(
280 &self,
281 epoch_id: &str,
282 status: &str,
283 report: Option<&str>,
284 candidate_id: Option<&str>,
285 error: Option<&str>,
286 ) -> Result<(), MicroClawError> {
287 if !matches!(status, "completed" | "failed" | "no_candidate") {
288 return Err(MicroClawError::ToolExecution(
289 "invalid learning epoch status".into(),
290 ));
291 }
292 let conn = self.lock_conn();
293 conn.execute(
294 "UPDATE learning_epochs SET status=?2, report=?3,
295 candidate_id=?4, error=?5, finished_at=?6
296 WHERE epoch_id=?1",
297 params![
298 epoch_id,
299 status,
300 report,
301 candidate_id,
302 error,
303 chrono::Utc::now().to_rfc3339()
304 ],
305 )?;
306 Ok(())
307 }
308
309 #[expect(clippy::too_many_arguments)]
310 pub fn create_learning_track_candidate(
311 &self,
312 epoch_id: &str,
313 skill_name: &str,
314 description: &str,
315 instructions: &str,
316 sources: &serde_json::Value,
317 tests: &serde_json::Value,
318 risk: &str,
319 ) -> Result<String, MicroClawError> {
320 if skill_name.trim().is_empty()
321 || description.trim().is_empty()
322 || instructions.trim().is_empty()
323 || !matches!(risk, "low" | "medium" | "high")
324 {
325 return Err(MicroClawError::ToolExecution(
326 "invalid learning candidate".into(),
327 ));
328 }
329 use sha2::{Digest, Sha256};
330 let content_hash = to_hex(&Sha256::digest(instructions.as_bytes()));
331 let candidate_id = uuid::Uuid::new_v4().to_string();
332 let now = chrono::Utc::now().to_rfc3339();
333 let conn = self.lock_conn();
334 conn.execute(
335 "INSERT INTO learning_track_candidates(
336 candidate_id, epoch_id, skill_name, description, instructions,
337 sources_json, tests_json, risk, content_hash, status,
338 created_at, updated_at
339 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,'pending',?10,?10)",
340 params![
341 candidate_id,
342 epoch_id,
343 skill_name,
344 description,
345 instructions,
346 sources.to_string(),
347 tests.to_string(),
348 risk,
349 content_hash,
350 now
351 ],
352 )?;
353 Ok(candidate_id)
354 }
355
356 pub fn list_learning_epochs(
357 &self,
358 chat_id: i64,
359 limit: usize,
360 ) -> Result<Vec<LearningEpoch>, MicroClawError> {
361 let conn = self.lock_conn();
362 let mut stmt = conn.prepare(
363 "SELECT e.epoch_id, e.track_id, e.experience_run_id, e.status,
364 e.report, e.candidate_id, e.error, e.started_at, e.finished_at
365 FROM learning_epochs e
366 JOIN learning_tracks t ON t.track_id=e.track_id
367 WHERE t.chat_id=?1 ORDER BY e.started_at DESC LIMIT ?2",
368 )?;
369 let rows = stmt
370 .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
371 Ok(LearningEpoch {
372 epoch_id: row.get(0)?,
373 track_id: row.get(1)?,
374 experience_run_id: row.get(2)?,
375 status: row.get(3)?,
376 report: row.get(4)?,
377 candidate_id: row.get(5)?,
378 error: row.get(6)?,
379 started_at: row.get(7)?,
380 finished_at: row.get(8)?,
381 })
382 })?
383 .collect::<Result<Vec<_>, _>>()?;
384 Ok(rows)
385 }
386
387 pub fn list_learning_track_candidates(
388 &self,
389 chat_id: i64,
390 limit: usize,
391 ) -> Result<Vec<LearningTrackCandidate>, MicroClawError> {
392 let conn = self.lock_conn();
393 let mut stmt = conn.prepare(
394 "SELECT c.candidate_id, c.epoch_id, c.skill_name, c.description,
395 c.instructions, c.sources_json, c.tests_json, c.risk,
396 c.content_hash, c.status, c.created_at, c.updated_at
397 FROM learning_track_candidates c
398 JOIN learning_epochs e ON e.epoch_id=c.epoch_id
399 JOIN learning_tracks t ON t.track_id=e.track_id
400 WHERE t.chat_id=?1 ORDER BY c.updated_at DESC LIMIT ?2",
401 )?;
402 let rows = stmt
403 .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
404 let sources: String = row.get(5)?;
405 let tests: String = row.get(6)?;
406 Ok(LearningTrackCandidate {
407 candidate_id: row.get(0)?,
408 epoch_id: row.get(1)?,
409 skill_name: row.get(2)?,
410 description: row.get(3)?,
411 instructions: row.get(4)?,
412 sources: serde_json::from_str(&sources).unwrap_or_default(),
413 tests: serde_json::from_str(&tests).unwrap_or_default(),
414 risk: row.get(7)?,
415 content_hash: row.get(8)?,
416 status: row.get(9)?,
417 created_at: row.get(10)?,
418 updated_at: row.get(11)?,
419 })
420 })?
421 .collect::<Result<Vec<_>, _>>()?;
422 Ok(rows)
423 }
424
425 pub fn get_learning_track_candidate(
426 &self,
427 candidate_id: &str,
428 chat_id: i64,
429 ) -> Result<Option<LearningTrackCandidate>, MicroClawError> {
430 Ok(self
431 .list_learning_track_candidates(chat_id, 200)?
432 .into_iter()
433 .find(|candidate| candidate.candidate_id == candidate_id))
434 }
435
436 pub fn update_learning_track_candidate_status(
437 &self,
438 candidate_id: &str,
439 status: &str,
440 ) -> Result<bool, MicroClawError> {
441 if !matches!(
442 status,
443 "pending"
444 | "evaluating"
445 | "evaluation_passed"
446 | "evaluation_failed"
447 | "promoted"
448 | "rejected"
449 ) {
450 return Err(MicroClawError::ToolExecution(
451 "invalid learning candidate status".into(),
452 ));
453 }
454 let conn = self.lock_conn();
455 let changed = conn.execute(
456 "UPDATE learning_track_candidates SET status=?2, updated_at=?3
457 WHERE candidate_id=?1",
458 params![candidate_id, status, chrono::Utc::now().to_rfc3339()],
459 )?;
460 Ok(changed == 1)
461 }
462
463 pub fn start_learning_candidate_evaluation(
464 &self,
465 candidate_id: &str,
466 ) -> Result<String, MicroClawError> {
467 let evaluation_id = uuid::Uuid::new_v4().to_string();
468 let now = chrono::Utc::now().to_rfc3339();
469 let mut conn = self.lock_conn();
470 let tx = conn.transaction()?;
471 let changed = tx.execute(
472 "UPDATE learning_track_candidates SET status='evaluating', updated_at=?2
473 WHERE candidate_id=?1 AND status IN ('pending','evaluation_failed')",
474 params![candidate_id, now],
475 )?;
476 if changed != 1 {
477 return Err(MicroClawError::ToolExecution(
478 "learning candidate is not eligible for evaluation".into(),
479 ));
480 }
481 tx.execute(
482 "DELETE FROM learning_candidate_trials WHERE evaluation_id IN
483 (SELECT evaluation_id FROM learning_candidate_evaluations WHERE candidate_id=?1)",
484 params![candidate_id],
485 )?;
486 tx.execute(
487 "DELETE FROM learning_candidate_evaluations WHERE candidate_id=?1",
488 params![candidate_id],
489 )?;
490 tx.execute(
491 "INSERT INTO learning_candidate_evaluations(
492 evaluation_id,candidate_id,status,started_at
493 ) VALUES (?1,?2,'running',?3)",
494 params![evaluation_id, candidate_id, now],
495 )?;
496 tx.commit()?;
497 Ok(evaluation_id)
498 }
499
500 #[expect(clippy::too_many_arguments)]
501 pub fn record_learning_candidate_trial(
502 &self,
503 evaluation_id: &str,
504 test_name: &str,
505 baseline_passed: bool,
506 candidate_passed: bool,
507 baseline_tokens: i64,
508 candidate_tokens: i64,
509 baseline_duration_ms: i64,
510 candidate_duration_ms: i64,
511 evidence: &serde_json::Value,
512 ) -> Result<(), MicroClawError> {
513 let conn = self.lock_conn();
514 conn.execute(
515 "INSERT INTO learning_candidate_trials(
516 trial_id,evaluation_id,test_name,baseline_passed,candidate_passed,
517 baseline_tokens,candidate_tokens,baseline_duration_ms,
518 candidate_duration_ms,evidence_json,created_at
519 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
520 params![
521 uuid::Uuid::new_v4().to_string(),
522 evaluation_id,
523 test_name,
524 baseline_passed as i64,
525 candidate_passed as i64,
526 baseline_tokens.max(0),
527 candidate_tokens.max(0),
528 baseline_duration_ms.max(0),
529 candidate_duration_ms.max(0),
530 evidence.to_string(),
531 chrono::Utc::now().to_rfc3339()
532 ],
533 )?;
534 Ok(())
535 }
536
537 pub fn finish_learning_candidate_evaluation(
538 &self,
539 evaluation_id: &str,
540 passed: bool,
541 reason: &str,
542 ) -> Result<LearningCandidateEvaluation, MicroClawError> {
543 let mut conn = self.lock_conn();
544 let tx = conn.transaction()?;
545 let totals: (i64, i64, i64, i64, i64, i64) = tx.query_row(
546 "SELECT COUNT(*), COALESCE(SUM(baseline_passed),0),
547 COALESCE(SUM(candidate_passed),0),
548 COALESCE(SUM(CASE WHEN baseline_passed=1 AND candidate_passed=0 THEN 1 ELSE 0 END),0),
549 COALESCE(SUM(baseline_tokens),0), COALESCE(SUM(candidate_tokens),0)
550 FROM learning_candidate_trials WHERE evaluation_id=?1",
551 params![evaluation_id],
552 |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?)),
553 )?;
554 let durations: (i64, i64) = tx.query_row(
555 "SELECT COALESCE(SUM(baseline_duration_ms),0),COALESCE(SUM(candidate_duration_ms),0)
556 FROM learning_candidate_trials WHERE evaluation_id=?1",
557 params![evaluation_id],
558 |row| Ok((row.get(0)?, row.get(1)?)),
559 )?;
560 let status = if passed { "passed" } else { "failed" };
561 let now = chrono::Utc::now().to_rfc3339();
562 tx.execute(
563 "UPDATE learning_candidate_evaluations SET status=?2,sample_count=?3,
564 baseline_passed=?4,candidate_passed=?5,regression_count=?6,
565 baseline_tokens=?7,candidate_tokens=?8,baseline_duration_ms=?9,
566 candidate_duration_ms=?10,reason=?11,finished_at=?12
567 WHERE evaluation_id=?1 AND status='running'",
568 params![
569 evaluation_id,
570 status,
571 totals.0,
572 totals.1,
573 totals.2,
574 totals.3,
575 totals.4,
576 totals.5,
577 durations.0,
578 durations.1,
579 reason,
580 now
581 ],
582 )?;
583 tx.execute(
584 "UPDATE learning_track_candidates SET status=?2,updated_at=?3
585 WHERE candidate_id=(SELECT candidate_id FROM learning_candidate_evaluations WHERE evaluation_id=?1)",
586 params![evaluation_id, if passed { "evaluation_passed" } else { "evaluation_failed" }, now],
587 )?;
588 tx.commit()?;
589 drop(conn);
590 self.get_learning_candidate_evaluation_by_id(evaluation_id)?
591 .ok_or_else(|| MicroClawError::ToolExecution("evaluation disappeared".into()))
592 }
593
594 fn get_learning_candidate_evaluation_by_id(
595 &self,
596 evaluation_id: &str,
597 ) -> Result<Option<LearningCandidateEvaluation>, MicroClawError> {
598 let conn = self.lock_conn();
599 conn.query_row(
600 "SELECT evaluation_id,candidate_id,status,sample_count,baseline_passed,
601 candidate_passed,regression_count,baseline_tokens,candidate_tokens,
602 baseline_duration_ms,candidate_duration_ms,reason,started_at,finished_at
603 FROM learning_candidate_evaluations WHERE evaluation_id=?1",
604 params![evaluation_id],
605 |row| {
606 Ok(LearningCandidateEvaluation {
607 evaluation_id: row.get(0)?,
608 candidate_id: row.get(1)?,
609 status: row.get(2)?,
610 sample_count: row.get(3)?,
611 baseline_passed: row.get(4)?,
612 candidate_passed: row.get(5)?,
613 regression_count: row.get(6)?,
614 baseline_tokens: row.get(7)?,
615 candidate_tokens: row.get(8)?,
616 baseline_duration_ms: row.get(9)?,
617 candidate_duration_ms: row.get(10)?,
618 reason: row.get(11)?,
619 started_at: row.get(12)?,
620 finished_at: row.get(13)?,
621 })
622 },
623 )
624 .optional()
625 .map_err(Into::into)
626 }
627
628 pub fn list_learning_candidate_evaluations(
629 &self,
630 chat_id: i64,
631 limit: usize,
632 ) -> Result<Vec<LearningCandidateEvaluation>, MicroClawError> {
633 let conn = self.lock_conn();
634 let mut stmt = conn.prepare(
635 "SELECT v.evaluation_id,v.candidate_id,v.status,v.sample_count,
636 v.baseline_passed,v.candidate_passed,v.regression_count,
637 v.baseline_tokens,v.candidate_tokens,v.baseline_duration_ms,
638 v.candidate_duration_ms,v.reason,v.started_at,v.finished_at
639 FROM learning_candidate_evaluations v
640 JOIN learning_track_candidates c ON c.candidate_id=v.candidate_id
641 JOIN learning_epochs e ON e.epoch_id=c.epoch_id
642 JOIN learning_tracks t ON t.track_id=e.track_id
643 WHERE t.chat_id=?1 ORDER BY v.started_at DESC LIMIT ?2",
644 )?;
645 let rows = stmt
646 .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
647 Ok(LearningCandidateEvaluation {
648 evaluation_id: row.get(0)?,
649 candidate_id: row.get(1)?,
650 status: row.get(2)?,
651 sample_count: row.get(3)?,
652 baseline_passed: row.get(4)?,
653 candidate_passed: row.get(5)?,
654 regression_count: row.get(6)?,
655 baseline_tokens: row.get(7)?,
656 candidate_tokens: row.get(8)?,
657 baseline_duration_ms: row.get(9)?,
658 candidate_duration_ms: row.get(10)?,
659 reason: row.get(11)?,
660 started_at: row.get(12)?,
661 finished_at: row.get(13)?,
662 })
663 })?
664 .collect::<Result<Vec<_>, _>>()?;
665 Ok(rows)
666 }
667
668 pub fn list_learning_candidate_trials(
669 &self,
670 evaluation_id: &str,
671 ) -> Result<Vec<LearningCandidateTrial>, MicroClawError> {
672 let conn = self.lock_conn();
673 let mut stmt = conn.prepare(
674 "SELECT trial_id,evaluation_id,test_name,baseline_passed,candidate_passed,
675 baseline_tokens,candidate_tokens,baseline_duration_ms,
676 candidate_duration_ms,evidence_json,created_at
677 FROM learning_candidate_trials WHERE evaluation_id=?1 ORDER BY created_at",
678 )?;
679 let rows = stmt
680 .query_map(params![evaluation_id], |row| {
681 let evidence: String = row.get(9)?;
682 Ok(LearningCandidateTrial {
683 trial_id: row.get(0)?,
684 evaluation_id: row.get(1)?,
685 test_name: row.get(2)?,
686 baseline_passed: row.get::<_, i64>(3)? != 0,
687 candidate_passed: row.get::<_, i64>(4)? != 0,
688 baseline_tokens: row.get(5)?,
689 candidate_tokens: row.get(6)?,
690 baseline_duration_ms: row.get(7)?,
691 candidate_duration_ms: row.get(8)?,
692 evidence: serde_json::from_str(&evidence).unwrap_or_default(),
693 created_at: row.get(10)?,
694 })
695 })?
696 .collect::<Result<Vec<_>, _>>()?;
697 Ok(rows)
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use crate::internal::storage::db::test_support::*;
704
705 #[test]
706 fn learning_tracks_are_scoped_claimed_and_candidate_gated() {
707 let (db, dir) = test_db();
708 let track_id = db
709 .create_learning_track(
710 77,
711 "rust-reliability",
712 "Learn repeatable Rust runtime reliability procedures",
713 &serde_json::json!(["recovery", "verification"]),
714 &serde_json::json!(["doc.rust-lang.org"]),
715 "0 0 3 * * *",
716 "UTC",
717 80_000,
718 10,
719 "propose",
720 "2026-01-01T00:00:00Z",
721 )
722 .unwrap();
723 assert!(db.learning_track_belongs_to_chat(&track_id, 77).unwrap());
724 assert!(!db.learning_track_belongs_to_chat(&track_id, 78).unwrap());
725 let claimed = db
726 .claim_due_learning_tracks(
727 "2026-01-02T00:00:00Z",
728 &[(track_id.clone(), "2026-01-03T00:00:00Z".into())],
729 )
730 .unwrap();
731 assert_eq!(claimed.len(), 1);
732 assert!(db
733 .claim_due_learning_tracks(
734 "2026-01-02T00:00:00Z",
735 &[(track_id.clone(), "2026-01-03T00:00:00Z".into())],
736 )
737 .unwrap()
738 .is_empty());
739
740 let epoch_id = db.start_learning_epoch(&track_id, "experience-77").unwrap();
741 let candidate_id = db
742 .create_learning_track_candidate(
743 &epoch_id,
744 "rust-recovery",
745 "Use when diagnosing recoverable Rust runtime failures",
746 "# Procedure\n\n1. Reproduce.\n2. Verify.",
747 &serde_json::json!([{"url": "https://doc.rust-lang.org"}]),
748 &serde_json::json!([{"name": "replay", "expected": "passes"}]),
749 "low",
750 )
751 .unwrap();
752 db.finish_learning_epoch(
753 &epoch_id,
754 "completed",
755 Some("source-backed report"),
756 Some(&candidate_id),
757 None,
758 )
759 .unwrap();
760 let candidates = db.list_learning_track_candidates(77, 10).unwrap();
761 assert_eq!(candidates.len(), 1);
762 assert_eq!(candidates[0].status, "pending");
763
764 let evaluation_id = db
765 .start_learning_candidate_evaluation(&candidate_id)
766 .unwrap();
767 for index in 0..3 {
768 db.record_learning_candidate_trial(
769 &evaluation_id,
770 &format!("scenario-{index}"),
771 index == 0,
772 true,
773 100,
774 90,
775 10,
776 9,
777 &serde_json::json!({"judge_reason": "candidate meets criterion"}),
778 )
779 .unwrap();
780 }
781 let evaluation = db
782 .finish_learning_candidate_evaluation(
783 &evaluation_id,
784 true,
785 "candidate improves baseline without regressions",
786 )
787 .unwrap();
788 assert_eq!(evaluation.status, "passed");
789 assert_eq!(evaluation.sample_count, 3);
790 assert_eq!(evaluation.baseline_passed, 1);
791 assert_eq!(evaluation.candidate_passed, 3);
792 assert_eq!(evaluation.regression_count, 0);
793 assert_eq!(
794 db.list_learning_candidate_trials(&evaluation_id)
795 .unwrap()
796 .len(),
797 3
798 );
799 assert_eq!(
800 db.list_learning_track_candidates(77, 10).unwrap()[0].status,
801 "evaluation_passed"
802 );
803 assert!(db
804 .update_learning_track_candidate_status(&candidate_id, "promoted")
805 .unwrap());
806 cleanup(&dir);
807 }
808}