Skip to main content

microclaw_engine/internal/storage/db/learning/
tracks.rs

1use super::*;
2
3/// A durable, user-directed background learning programme.
4#[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/// An inert skill proposal produced by a learning epoch. It is never part of
38/// prompt discovery until an operator explicitly promotes it.
39#[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    /// Atomically moves due tracks into the future before returning them.
225    /// This provides at-most-once claiming per process tick; crashed epochs
226    /// remain visible and the next scheduled epoch can proceed normally.
227    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}