Skip to main content

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

1use super::*;
2
3#[derive(Debug, Clone, serde::Serialize)]
4pub struct GoalStateRecord {
5    pub goal_id: String,
6    pub chat_id: i64,
7    pub objective: String,
8    pub status: String,
9    pub constraints_json: Option<String>,
10    pub progress_json: Option<String>,
11    pub budget_json: Option<String>,
12    pub created_at: String,
13    pub updated_at: String,
14    pub completed_at: Option<String>,
15}
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct ExperienceRunRecord {
19    pub run_id: String,
20    pub goal_id: Option<String>,
21    pub chat_id: i64,
22    pub channel: String,
23    pub run_kind: String,
24    pub objective: String,
25    pub environment_fingerprint: Option<String>,
26    pub status: String,
27    pub result_summary: Option<String>,
28    pub started_at: String,
29    pub finished_at: Option<String>,
30    pub duration_ms: Option<i64>,
31    pub input_tokens: i64,
32    pub output_tokens: i64,
33    pub llm_requests: i64,
34    pub tool_calls: i64,
35    pub tool_errors: i64,
36    pub estimated_cost_usd: Option<f64>,
37    pub task_signature: TaskSignatureV1,
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
41pub struct TaskSignatureV1 {
42    pub version: i64,
43    pub task_type: String,
44    pub task_family: String,
45    pub capability_tags: Vec<String>,
46    pub signature_hash: String,
47}
48
49#[derive(Debug, Clone, serde::Serialize)]
50pub struct VerifierResultRecord {
51    pub id: i64,
52    pub run_id: String,
53    pub verifier_type: String,
54    pub verifier_name: String,
55    pub verdict: String,
56    pub confidence: f64,
57    pub evidence: Option<String>,
58    pub scope: Option<String>,
59    pub verified_at: String,
60    pub valid_until: Option<String>,
61}
62
63/// Stable ingestion contract for every piece of outcome evidence.
64///
65/// Keep this versioned and append-only: producers may evolve independently,
66/// while persisted envelopes remain replayable and auditable.
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68pub struct OutcomeEnvelopeV1 {
69    pub envelope_id: String,
70    pub run_id: String,
71    pub source_kind: String,
72    pub source_name: String,
73    pub verdict: String,
74    pub confidence: f64,
75    pub evidence: Option<String>,
76    pub scope: Option<String>,
77    pub valid_until: Option<String>,
78    #[serde(default)]
79    pub payload: serde_json::Value,
80    pub feedback: Option<ExperienceFeedbackInput>,
81}
82
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84pub struct ExperienceFeedbackInput {
85    pub feedback_id: String,
86    pub actor: String,
87}
88
89#[derive(Debug, Clone, serde::Serialize)]
90pub struct ExperienceFeedbackRecord {
91    pub feedback_id: String,
92    pub run_id: String,
93    pub actor: String,
94    pub verdict: String,
95    pub confidence: f64,
96    pub evidence: Option<String>,
97    pub scope: Option<String>,
98    pub created_at: String,
99    pub updated_at: String,
100    pub valid_until: Option<String>,
101}
102
103#[derive(Debug, Clone, serde::Serialize)]
104pub struct ExperienceRetrievalRecord {
105    pub querying_run_id: String,
106    pub source_run_id: String,
107    pub rank: i64,
108    pub selection_reason: String,
109    pub relevance_score: f64,
110    pub injected_at: String,
111    pub source_objective: String,
112    pub source_result_summary: Option<String>,
113}
114
115#[derive(Debug, Clone, serde::Serialize)]
116pub struct OutcomeEnvelopeRecord {
117    pub envelope_id: String,
118    pub schema_version: i64,
119    pub run_id: String,
120    pub source_kind: String,
121    pub source_name: String,
122    pub verdict: String,
123    pub confidence: f64,
124    pub evidence: Option<String>,
125    pub scope: Option<String>,
126    pub valid_until: Option<String>,
127    pub payload: serde_json::Value,
128    pub created_at: String,
129}
130
131#[derive(Debug, Clone, serde::Serialize)]
132pub struct ExperienceRunDetail {
133    pub run: ExperienceRunRecord,
134    pub outcomes: Vec<OutcomeEnvelopeRecord>,
135    pub feedback: Vec<ExperienceFeedbackRecord>,
136    pub retrieved_experiences: Vec<ExperienceRetrievalRecord>,
137    pub rejected_experiences: Vec<ExperienceRejectionRecord>,
138    pub activated_skills: Vec<SkillActivationRecord>,
139}
140
141#[derive(Debug, Clone, serde::Serialize)]
142pub struct ExperienceRejectionRecord {
143    pub source_run_id: String,
144    pub rejection_reason: String,
145    pub relevance_score: f64,
146    pub rejected_at: String,
147    pub source_objective: String,
148}
149
150#[derive(Debug, Clone, serde::Serialize)]
151pub struct ExperienceComparison {
152    pub comparison_id: String,
153    pub chat_id: i64,
154    pub skill_name: String,
155    pub skill_version: i64,
156    pub task_type: String,
157    pub task_family: String,
158    pub environment_fingerprint: Option<String>,
159    pub success_run_id: String,
160    pub failure_run_id: String,
161    pub minimal_difference: String,
162    pub counterexample: String,
163    pub created_at: String,
164}
165
166#[derive(Debug, Clone, serde::Serialize)]
167pub struct VerifiedExperienceMatch {
168    pub run_id: String,
169    pub objective: String,
170    pub environment_fingerprint: Option<String>,
171    pub verdict: String,
172    pub verifier_type: String,
173    pub confidence: f64,
174    pub result_summary: Option<String>,
175    pub started_at: String,
176    pub duration_ms: Option<i64>,
177    pub total_tokens: i64,
178    pub tool_calls: i64,
179    pub tool_errors: i64,
180    pub estimated_cost_usd: Option<f64>,
181    pub relevance_score: f64,
182    pub utility_lower_bound: f64,
183    pub task_signature: TaskSignatureV1,
184    pub rejection_reason: Option<String>,
185}
186
187#[derive(Debug, Clone, Default, serde::Serialize)]
188pub struct ExperienceSummary {
189    pub total_runs: i64,
190    pub completed_runs: i64,
191    pub failed_runs: i64,
192    pub verified_runs: i64,
193    pub input_tokens: i64,
194    pub output_tokens: i64,
195    pub tool_calls: i64,
196    pub tool_errors: i64,
197    pub estimated_cost_usd: f64,
198}
199
200pub fn derive_task_signature(objective: &str, run_kind: &str) -> TaskSignatureV1 {
201    let normalized = objective.to_lowercase();
202    let contains_any = |terms: &[&str]| terms.iter().any(|term| normalized.contains(term));
203    let (task_type, task_family) =
204        if contains_any(&["debug", "bug", "fix", "报错", "修复", "故障", "失败原因"]) {
205            ("software_development", "debugging")
206        } else if contains_any(&["test", "verify", "验证", "测试", "coverage", "clippy"]) {
207            ("software_development", "testing")
208        } else if contains_any(&[
209            "implement",
210            "code",
211            "refactor",
212            "代码",
213            "实现",
214            "开发",
215            "重构",
216        ]) {
217            ("software_development", "code_change")
218        } else if contains_any(&["deploy", "release", "ci", "部署", "发布", "上线"]) {
219            ("operations", "deployment")
220        } else if contains_any(&[
221            "search", "research", "look up", "调研", "搜索", "查找", "资料",
222        ]) {
223            ("information_retrieval", "research")
224        } else if contains_any(&["analy", "metric", "sql", "dataset", "分析", "指标", "数据"])
225        {
226            ("data_analysis", "analysis")
227        } else if contains_any(&["document", "report", "pdf", "slide", "文档", "报告", "演示"])
228        {
229            ("content_creation", "document_creation")
230        } else if contains_any(&["email", "message", "notify", "邮件", "消息", "通知"]) {
231            ("communication", "messaging")
232        } else if contains_any(&["schedule", "remind", "calendar", "定时", "提醒", "日历"])
233            || run_kind == "scheduled"
234        {
235            ("operations", "scheduling")
236        } else if contains_any(&["memory", "remember", "记忆", "记住", "忘记"]) {
237            ("agent_learning", "memory_management")
238        } else if contains_any(&["skill", "技能", "学习"]) {
239            ("agent_learning", "skill_management")
240        } else if contains_any(&["plan", "roadmap", "规划", "计划", "路线"]) {
241            ("reasoning", "planning")
242        } else {
243            ("general", "general_assistance")
244        };
245
246    let mut tags = Vec::new();
247    for (tag, terms) in [
248        ("browser", &["browser", "chrome", "网页", "浏览器"][..]),
249        ("filesystem", &["file", "directory", "文件", "目录"][..]),
250        (
251            "shell",
252            &["shell", "command", "terminal", "命令", "终端"][..],
253        ),
254        ("web", &["web", "http", "url", "网站", "网络"][..]),
255        ("coding", &["code", "rust", "python", "代码", "编程"][..]),
256        (
257            "verification",
258            &["test", "verify", "check", "测试", "验证"][..],
259        ),
260        ("data", &["data", "sql", "metric", "数据", "指标"][..]),
261        ("messaging", &["email", "message", "邮件", "消息"][..]),
262        ("scheduling", &["schedule", "calendar", "定时", "日历"][..]),
263    ] {
264        if contains_any(terms) {
265            tags.push(tag.to_string());
266        }
267    }
268    tags.push(task_type.to_string());
269    tags.sort();
270    tags.dedup();
271    let canonical = format!("v1|{task_type}|{task_family}|{}", tags.join(","));
272    use sha2::Digest;
273    let signature_hash = to_hex(&sha2::Sha256::digest(canonical.as_bytes()));
274    TaskSignatureV1 {
275        version: 1,
276        task_type: task_type.into(),
277        task_family: task_family.into(),
278        capability_tags: tags,
279        signature_hash,
280    }
281}
282
283pub(crate) fn verifier_priority(verifier_type: &str) -> i64 {
284    match verifier_type {
285        "deterministic" => 60,
286        "environmental" => 50,
287        "human" => 40,
288        "rule_based" => 30,
289        "model_based" => 20,
290        "runtime" => 10,
291        _ => 0,
292    }
293}
294
295pub(crate) type VerifierCandidate = (String, String, String, f64, Option<String>, Option<String>);
296
297pub(crate) fn verifier_can_govern_skill(verifier_type: &str) -> bool {
298    matches!(
299        verifier_type,
300        "deterministic" | "environmental" | "human" | "rule_based"
301    )
302}
303
304pub(crate) fn validate_outcome_envelope(
305    envelope: &OutcomeEnvelopeV1,
306) -> Result<(), MicroClawError> {
307    if envelope.envelope_id.trim().is_empty()
308        || envelope.envelope_id.len() > 256
309        || envelope.run_id.trim().is_empty()
310        || envelope.run_id.len() > 128
311        || !matches!(envelope.verdict.as_str(), "passed" | "failed")
312        || envelope.source_name.trim().is_empty()
313        || envelope.source_name.len() > 256
314        || !envelope.confidence.is_finite()
315        || !(0.0..=1.0).contains(&envelope.confidence)
316        || envelope
317            .evidence
318            .as_ref()
319            .is_some_and(|value| value.len() > 64 * 1024)
320        || envelope
321            .scope
322            .as_ref()
323            .is_some_and(|value| value.len() > 512)
324        || serde_json::to_vec(&envelope.payload)
325            .map(|value| value.len() > 64 * 1024)
326            .unwrap_or(true)
327    {
328        return Err(MicroClawError::ToolExecution(
329            "invalid outcome envelope fields".into(),
330        ));
331    }
332    if !matches!(
333        envelope.source_kind.as_str(),
334        "deterministic" | "environmental" | "human" | "rule_based" | "model_based" | "runtime"
335    ) {
336        return Err(MicroClawError::ToolExecution(format!(
337            "unsupported outcome source kind: {}",
338            envelope.source_kind
339        )));
340    }
341    if let Some(valid_until) = &envelope.valid_until {
342        chrono::DateTime::parse_from_rfc3339(valid_until).map_err(|_| {
343            MicroClawError::ToolExecution("outcome valid_until must be an RFC3339 timestamp".into())
344        })?;
345    }
346    if let Some(feedback) = &envelope.feedback {
347        if envelope.source_kind != "human"
348            || feedback.feedback_id.trim().is_empty()
349            || feedback.feedback_id.len() > 256
350            || feedback.actor.trim().is_empty()
351            || feedback.actor.len() > 256
352        {
353            return Err(MicroClawError::ToolExecution(
354                "invalid experience feedback identity".into(),
355            ));
356        }
357    }
358    Ok(())
359}
360
361pub(crate) type ComparativeCurrentRun = (
362    String,
363    i64,
364    i64,
365    String,
366    String,
367    String,
368    Option<String>,
369    Option<String>,
370    i64,
371    i64,
372    i64,
373    Option<f64>,
374);
375
376pub(crate) type ComparativePeerRun = (
377    String,
378    Option<String>,
379    i64,
380    i64,
381    i64,
382    Option<f64>,
383    Option<String>,
384);
385
386pub(crate) fn refresh_comparative_reflections_for_run(
387    tx: &Transaction<'_>,
388    run_id: &str,
389) -> Result<(), MicroClawError> {
390    let current: Option<ComparativeCurrentRun> = tx
391        .query_row(
392            "SELECT o.skill_name, o.skill_version, r.chat_id, o.verdict, r.task_type,
393                    r.task_family, r.environment_fingerprint, r.result_summary,
394                    r.duration_ms, r.tool_calls, r.tool_errors,
395                    r.estimated_cost_usd
396             FROM skill_outcomes o
397             JOIN experience_runs r ON r.run_id=o.run_id
398             WHERE o.run_id=?1 AND o.attribution_confidence>=0.999
399               AND o.verifier_type IN (
400                 'deterministic','environmental','human','rule_based'
401               )
402             LIMIT 1",
403            params![run_id],
404            |row| {
405                Ok((
406                    row.get(0)?,
407                    row.get(1)?,
408                    row.get(2)?,
409                    row.get(3)?,
410                    row.get(4)?,
411                    row.get(5)?,
412                    row.get(6)?,
413                    row.get(7)?,
414                    row.get::<_, Option<i64>>(8)?.unwrap_or_default(),
415                    row.get(9)?,
416                    row.get(10)?,
417                    row.get(11)?,
418                ))
419            },
420        )
421        .optional()?;
422    let Some((
423        skill_name,
424        skill_version,
425        chat_id,
426        verdict,
427        task_type,
428        task_family,
429        environment,
430        result_summary,
431        duration_ms,
432        tool_calls,
433        tool_errors,
434        cost,
435    )) = current
436    else {
437        return Ok(());
438    };
439    let opposite = if verdict == "passed" {
440        "failed"
441    } else if verdict == "failed" {
442        "passed"
443    } else {
444        return Ok(());
445    };
446    let peer: Option<ComparativePeerRun> = tx
447        .query_row(
448            "SELECT o.run_id, r.result_summary, COALESCE(r.duration_ms,0),
449                    r.tool_calls, r.tool_errors, r.estimated_cost_usd, o.evidence
450             FROM skill_outcomes o
451             JOIN experience_runs r ON r.run_id=o.run_id
452             WHERE o.skill_name=?1 AND o.skill_version=?2 AND o.verdict=?3
453               AND r.task_family=?4
454               AND (r.environment_fingerprint IS ?5 OR r.environment_fingerprint=?5)
455               AND o.attribution_confidence>=0.999
456               AND o.verifier_type IN (
457                 'deterministic','environmental','human','rule_based'
458               )
459               AND o.run_id<>?6
460               AND r.chat_id=?7
461             ORDER BY o.updated_at DESC LIMIT 1",
462            params![
463                skill_name,
464                skill_version,
465                opposite,
466                task_family,
467                environment,
468                run_id,
469                chat_id
470            ],
471            |row| {
472                Ok((
473                    row.get(0)?,
474                    row.get(1)?,
475                    row.get(2)?,
476                    row.get(3)?,
477                    row.get(4)?,
478                    row.get(5)?,
479                    row.get(6)?,
480                ))
481            },
482        )
483        .optional()?;
484    let Some((
485        peer_run_id,
486        peer_summary,
487        peer_duration,
488        peer_tool_calls,
489        peer_tool_errors,
490        peer_cost,
491        peer_evidence,
492    )) = peer
493    else {
494        return Ok(());
495    };
496    let (success_run_id, failure_run_id, success_profile, failure_profile, counterexample) =
497        if verdict == "passed" {
498            (
499                run_id.to_string(),
500                peer_run_id,
501                (
502                    duration_ms,
503                    tool_calls,
504                    tool_errors,
505                    cost.unwrap_or_default(),
506                ),
507                (
508                    peer_duration,
509                    peer_tool_calls,
510                    peer_tool_errors,
511                    peer_cost.unwrap_or_default(),
512                ),
513                peer_evidence.or(peer_summary).unwrap_or_default(),
514            )
515        } else {
516            (
517                peer_run_id,
518                run_id.to_string(),
519                (
520                    peer_duration,
521                    peer_tool_calls,
522                    peer_tool_errors,
523                    peer_cost.unwrap_or_default(),
524                ),
525                (
526                    duration_ms,
527                    tool_calls,
528                    tool_errors,
529                    cost.unwrap_or_default(),
530                ),
531                result_summary.unwrap_or_default(),
532            )
533        };
534    use sha2::{Digest, Sha256};
535    let canonical = format!(
536        "{chat_id}\n{skill_name}\n{skill_version}\n{task_family}\n{}\n{success_run_id}\n{failure_run_id}",
537        environment.as_deref().unwrap_or("")
538    );
539    let comparison_id = to_hex(&Sha256::digest(canonical.as_bytes()));
540    let minimal_difference = format!(
541        "success vs failure: tool_errors {}→{}, tool_calls {}→{}, duration_ms {}→{}, cost_usd {:.6}→{:.6}",
542        failure_profile.2,
543        success_profile.2,
544        failure_profile.1,
545        success_profile.1,
546        failure_profile.0,
547        success_profile.0,
548        failure_profile.3,
549        success_profile.3
550    );
551    let now = chrono::Utc::now().to_rfc3339();
552    let inserted = tx.execute(
553        "INSERT OR IGNORE INTO experience_comparisons(
554            comparison_id, chat_id, skill_name, skill_version, task_type, task_family,
555            environment_fingerprint, success_run_id, failure_run_id,
556            minimal_difference, counterexample, created_at
557         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
558        params![
559            comparison_id,
560            chat_id,
561            skill_name,
562            skill_version,
563            task_type,
564            task_family,
565            environment,
566            success_run_id,
567            failure_run_id,
568            minimal_difference,
569            counterexample,
570            now
571        ],
572    )?;
573    if inserted == 0 {
574        return Ok(());
575    }
576    let claim_version: i64 = tx.query_row(
577        "SELECT COALESCE(MAX(claim_version),0)+1 FROM learning_claims
578         WHERE skill_name=?1",
579        params![skill_name],
580        |row| row.get(0),
581    )?;
582    let supersedes: Option<String> = tx
583        .query_row(
584            "SELECT c.claim_id FROM learning_claims c
585             JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
586             WHERE c.skill_name=?1 AND x.chat_id=?2
587             ORDER BY c.claim_version DESC LIMIT 1",
588            params![skill_name, chat_id],
589            |row| row.get(0),
590        )
591        .optional()?;
592    let claim_id = uuid::Uuid::new_v4().to_string();
593    let statement = format!(
594        "For task_family={task_family}, prefer the successful execution profile ({minimal_difference}); treat the linked failing run as a counterexample."
595    );
596    let applicability = serde_json::json!({
597        "task_type": task_type,
598        "task_family": task_family,
599        "environment_fingerprint": environment,
600        "skill_version": skill_version
601    });
602    let evidence = serde_json::json!({
603        "success_run_id": success_run_id,
604        "failure_run_id": failure_run_id,
605        "minimal_difference": minimal_difference
606    });
607    tx.execute(
608        "INSERT INTO learning_claims(
609            claim_id, comparison_id, skill_name, base_version, claim_version,
610            statement, applicability_json, confidence, evidence_json,
611            counterexamples_json, status, supersedes_claim_id,
612            created_at, updated_at
613         ) VALUES (?1,?2,?3,?4,?5,?6,?7,0.6,?8,?9,'candidate',?10,?11,?11)",
614        params![
615            claim_id,
616            comparison_id,
617            skill_name,
618            skill_version,
619            claim_version,
620            statement,
621            applicability.to_string(),
622            evidence.to_string(),
623            serde_json::json!([counterexample]).to_string(),
624            supersedes,
625            now
626        ],
627    )?;
628    tx.execute(
629        "INSERT INTO learning_journal_events(
630            event_type, entity_type, entity_id, summary, evidence_json,
631            undo_action, created_at
632         ) VALUES ('claim_distilled','learning_claim',?1,?2,?3,
633                   'archive_claim',?4)",
634        params![claim_id, statement, evidence.to_string(), now],
635    )?;
636    Ok(())
637}
638
639impl Database {
640    // Kept as scalar fields so callers do not need to construct a persistence
641    // DTO merely to synchronize an active plan.
642    #[allow(clippy::too_many_arguments)]
643    pub fn upsert_goal_state(
644        &self,
645        goal_id: &str,
646        chat_id: i64,
647        objective: &str,
648        status: &str,
649        constraints_json: Option<&str>,
650        progress_json: Option<&str>,
651        budget_json: Option<&str>,
652    ) -> Result<(), MicroClawError> {
653        let conn = self.lock_conn();
654        let now = chrono::Utc::now().to_rfc3339();
655        let completed_at = matches!(status, "completed" | "failed" | "cancelled").then_some(&now);
656        conn.execute(
657            "INSERT INTO goal_states(
658                goal_id, chat_id, objective, status, constraints_json,
659                progress_json, budget_json, created_at, updated_at, completed_at
660             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9)
661             ON CONFLICT(goal_id) DO UPDATE SET
662                objective=excluded.objective,
663                status=excluded.status,
664                constraints_json=COALESCE(excluded.constraints_json, goal_states.constraints_json),
665                progress_json=COALESCE(excluded.progress_json, goal_states.progress_json),
666                budget_json=COALESCE(excluded.budget_json, goal_states.budget_json),
667                updated_at=excluded.updated_at,
668                completed_at=excluded.completed_at",
669            params![
670                goal_id,
671                chat_id,
672                objective,
673                status,
674                constraints_json,
675                progress_json,
676                budget_json,
677                now,
678                completed_at
679            ],
680        )?;
681        Ok(())
682    }
683
684    pub fn get_active_goal_state(
685        &self,
686        chat_id: i64,
687    ) -> Result<Option<GoalStateRecord>, MicroClawError> {
688        let conn = self.lock_conn();
689        conn.query_row(
690            "SELECT goal_id, chat_id, objective, status, constraints_json,
691                    progress_json, budget_json, created_at, updated_at, completed_at
692             FROM goal_states
693             WHERE chat_id=?1 AND status='active'
694             ORDER BY updated_at DESC LIMIT 1",
695            params![chat_id],
696            |row| {
697                Ok(GoalStateRecord {
698                    goal_id: row.get(0)?,
699                    chat_id: row.get(1)?,
700                    objective: row.get(2)?,
701                    status: row.get(3)?,
702                    constraints_json: row.get(4)?,
703                    progress_json: row.get(5)?,
704                    budget_json: row.get(6)?,
705                    created_at: row.get(7)?,
706                    updated_at: row.get(8)?,
707                    completed_at: row.get(9)?,
708                })
709            },
710        )
711        .optional()
712        .map_err(Into::into)
713    }
714
715    // Experience identity and execution context are deliberately explicit at
716    // the write boundary; grouping them would obscure required audit fields.
717    #[allow(clippy::too_many_arguments)]
718    pub fn start_experience_run(
719        &self,
720        run_id: &str,
721        goal_id: Option<&str>,
722        chat_id: i64,
723        channel: &str,
724        run_kind: &str,
725        objective: &str,
726        environment_fingerprint: Option<&str>,
727    ) -> Result<(), MicroClawError> {
728        if run_id.trim().is_empty()
729            || run_id.len() > 128
730            || channel.trim().is_empty()
731            || channel.len() > 128
732            || !matches!(run_kind, "interactive" | "scheduled" | "recovery")
733            || objective.len() > 16 * 1024
734            || environment_fingerprint.is_some_and(|value| value.len() > 2048)
735        {
736            return Err(MicroClawError::ToolExecution(
737                "invalid experience run identity or context".into(),
738            ));
739        }
740        let conn = self.lock_conn();
741        let signature = derive_task_signature(objective, run_kind);
742        let capability_tags_json = serde_json::to_string(&signature.capability_tags)
743            .map_err(|error| MicroClawError::ToolExecution(error.to_string()))?;
744        conn.execute(
745            "INSERT INTO experience_runs(
746                run_id, goal_id, chat_id, channel, run_kind, objective,
747                environment_fingerprint, status, started_at,
748                task_signature_version, task_type, task_family,
749                capability_tags_json, task_signature_hash
750             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'running', ?8,
751                       ?9, ?10, ?11, ?12, ?13)",
752            params![
753                run_id,
754                goal_id,
755                chat_id,
756                channel,
757                run_kind,
758                objective,
759                environment_fingerprint,
760                chrono::Utc::now().to_rfc3339(),
761                signature.version,
762                signature.task_type,
763                signature.task_family,
764                capability_tags_json,
765                signature.signature_hash
766            ],
767        )?;
768        Ok(())
769    }
770
771    pub fn finish_experience_run(
772        &self,
773        run_id: &str,
774        status: &str,
775        result_summary: Option<&str>,
776        duration_ms: i64,
777    ) -> Result<(), MicroClawError> {
778        if !matches!(status, "completed" | "failed" | "cancelled")
779            || duration_ms < 0
780            || result_summary.is_some_and(|summary| summary.len() > 16 * 1024)
781        {
782            return Err(MicroClawError::ToolExecution(
783                "invalid experience completion fields".into(),
784            ));
785        }
786        let conn = self.lock_conn();
787        let changed = conn.execute(
788            "UPDATE experience_runs
789             SET status=?2, result_summary=?3, finished_at=?4, duration_ms=?5
790             WHERE run_id=?1",
791            params![
792                run_id,
793                status,
794                result_summary,
795                chrono::Utc::now().to_rfc3339(),
796                duration_ms
797            ],
798        )?;
799        if changed == 0 {
800            return Err(MicroClawError::ToolExecution(format!(
801                "experience run not found: {run_id}"
802            )));
803        }
804        Ok(())
805    }
806
807    #[allow(clippy::too_many_arguments)]
808    pub fn update_experience_metrics(
809        &self,
810        run_id: &str,
811        input_tokens: i64,
812        output_tokens: i64,
813        llm_requests: i64,
814        tool_calls: i64,
815        tool_errors: i64,
816        estimated_cost_usd: Option<f64>,
817    ) -> Result<(), MicroClawError> {
818        let conn = self.lock_conn();
819        let changed = conn.execute(
820            "UPDATE experience_runs
821             SET input_tokens=?2, output_tokens=?3, llm_requests=?4,
822                 tool_calls=?5, tool_errors=?6, estimated_cost_usd=?7
823             WHERE run_id=?1",
824            params![
825                run_id,
826                input_tokens.max(0),
827                output_tokens.max(0),
828                llm_requests.max(0),
829                tool_calls.max(0),
830                tool_errors.max(0),
831                estimated_cost_usd.filter(|cost| cost.is_finite() && *cost >= 0.0)
832            ],
833        )?;
834        if changed == 0 {
835            return Err(MicroClawError::ToolExecution(format!(
836                "experience run not found: {run_id}"
837            )));
838        }
839        Ok(())
840    }
841
842    /// Retire experience rows left running by a previous process. The
843    /// restart signal is retained for observability but intentionally uses a
844    /// non-governing verifier type because a process crash is not evidence
845    /// that an activated skill was incorrect.
846    pub fn recover_running_experience_runs(&self) -> Result<usize, MicroClawError> {
847        let mut conn = self.lock_conn();
848        let tx = conn.transaction()?;
849        let mut stmt = tx.prepare("SELECT run_id FROM experience_runs WHERE status='running'")?;
850        let run_ids = stmt
851            .query_map([], |row| row.get::<_, String>(0))?
852            .collect::<Result<Vec<_>, _>>()?;
853        drop(stmt);
854        if run_ids.is_empty() {
855            tx.commit()?;
856            return Ok(0);
857        }
858        let now = chrono::Utc::now().to_rfc3339();
859        for run_id in &run_ids {
860            tx.execute(
861                "UPDATE experience_runs
862                 SET status='failed', result_summary='interrupted by process restart',
863                     finished_at=?2
864                 WHERE run_id=?1 AND status='running'",
865                params![run_id, now],
866            )?;
867        }
868        tx.commit()?;
869        drop(conn);
870        for run_id in &run_ids {
871            self.ingest_outcome_envelope(&OutcomeEnvelopeV1 {
872                envelope_id: format!("process-restart:{run_id}"),
873                run_id: run_id.clone(),
874                source_kind: "runtime".into(),
875                source_name: "process_restart".into(),
876                verdict: "failed".into(),
877                confidence: 1.0,
878                evidence: Some("run was still active when the process restarted".into()),
879                scope: Some("runtime".into()),
880                valid_until: None,
881                payload: serde_json::json!({"interrupted": true}),
882                feedback: None,
883            })?;
884        }
885        Ok(run_ids.len())
886    }
887
888    pub fn latest_experience_run_id(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
889        let conn = self.lock_conn();
890        conn.query_row(
891            "SELECT run_id FROM experience_runs
892             WHERE chat_id=?1 ORDER BY started_at DESC LIMIT 1",
893            params![chat_id],
894            |row| row.get(0),
895        )
896        .optional()
897        .map_err(Into::into)
898    }
899
900    pub fn experience_run_belongs_to_chat(
901        &self,
902        run_id: &str,
903        chat_id: i64,
904    ) -> Result<bool, MicroClawError> {
905        let conn = self.lock_conn();
906        let count: i64 = conn.query_row(
907            "SELECT COUNT(*) FROM experience_runs WHERE run_id=?1 AND chat_id=?2",
908            params![run_id, chat_id],
909            |row| row.get(0),
910        )?;
911        Ok(count > 0)
912    }
913
914    pub fn get_recent_experience_runs(
915        &self,
916        chat_id: Option<i64>,
917        limit: usize,
918    ) -> Result<Vec<ExperienceRunRecord>, MicroClawError> {
919        let conn = self.lock_conn();
920        let limit = limit.clamp(1, 200) as i64;
921        let sql = if chat_id.is_some() {
922            "SELECT run_id, goal_id, chat_id, channel, run_kind, objective,
923                    environment_fingerprint, status, result_summary, started_at,
924                    finished_at, duration_ms, input_tokens, output_tokens,
925                    llm_requests, tool_calls, tool_errors, estimated_cost_usd,
926                    task_signature_version, task_type, task_family,
927                    capability_tags_json, task_signature_hash
928             FROM experience_runs WHERE chat_id=?1
929             ORDER BY started_at DESC LIMIT ?2"
930        } else {
931            "SELECT run_id, goal_id, chat_id, channel, run_kind, objective,
932                    environment_fingerprint, status, result_summary, started_at,
933                    finished_at, duration_ms, input_tokens, output_tokens,
934                    llm_requests, tool_calls, tool_errors, estimated_cost_usd,
935                    task_signature_version, task_type, task_family,
936                    capability_tags_json, task_signature_hash
937             FROM experience_runs
938             ORDER BY started_at DESC LIMIT ?2"
939        };
940        let map_row = |row: &rusqlite::Row<'_>| {
941            Ok(ExperienceRunRecord {
942                run_id: row.get(0)?,
943                goal_id: row.get(1)?,
944                chat_id: row.get(2)?,
945                channel: row.get(3)?,
946                run_kind: row.get(4)?,
947                objective: row.get(5)?,
948                environment_fingerprint: row.get(6)?,
949                status: row.get(7)?,
950                result_summary: row.get(8)?,
951                started_at: row.get(9)?,
952                finished_at: row.get(10)?,
953                duration_ms: row.get(11)?,
954                input_tokens: row.get(12)?,
955                output_tokens: row.get(13)?,
956                llm_requests: row.get(14)?,
957                tool_calls: row.get(15)?,
958                tool_errors: row.get(16)?,
959                estimated_cost_usd: row.get(17)?,
960                task_signature: TaskSignatureV1 {
961                    version: row.get(18)?,
962                    task_type: row.get(19)?,
963                    task_family: row.get(20)?,
964                    capability_tags: serde_json::from_str::<Vec<String>>(
965                        &row.get::<_, String>(21)?,
966                    )
967                    .unwrap_or_default(),
968                    signature_hash: row.get(22)?,
969                },
970            })
971        };
972        let mut stmt = conn.prepare(sql)?;
973        let rows = if let Some(chat_id) = chat_id {
974            stmt.query_map(params![chat_id, limit], map_row)?
975        } else {
976            stmt.query_map(params![Option::<i64>::None, limit], map_row)?
977        };
978        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
979    }
980
981    /// Persist the exact verified experiences injected into one agent run.
982    /// Re-recording the same query run replaces its selection atomically.
983    pub fn record_experience_retrievals(
984        &self,
985        querying_run_id: &str,
986        selections: &[(String, String, f64)],
987    ) -> Result<(), MicroClawError> {
988        if querying_run_id.trim().is_empty()
989            || selections.len() > 20
990            || selections.iter().any(|(run_id, reason, score)| {
991                run_id.trim().is_empty()
992                    || reason.trim().is_empty()
993                    || reason.len() > 1024
994                    || !score.is_finite()
995            })
996        {
997            return Err(MicroClawError::ToolExecution(
998                "invalid experience retrieval log".into(),
999            ));
1000        }
1001        let mut conn = self.lock_conn();
1002        let tx = conn.transaction()?;
1003        let querying_chat: Option<i64> = tx
1004            .query_row(
1005                "SELECT chat_id FROM experience_runs WHERE run_id=?1",
1006                params![querying_run_id],
1007                |row| row.get(0),
1008            )
1009            .optional()?;
1010        let Some(querying_chat) = querying_chat else {
1011            return Err(MicroClawError::ToolExecution(format!(
1012                "experience run not found: {querying_run_id}"
1013            )));
1014        };
1015        tx.execute(
1016            "DELETE FROM experience_retrieval_logs WHERE querying_run_id=?1",
1017            params![querying_run_id],
1018        )?;
1019        let now = chrono::Utc::now().to_rfc3339();
1020        for (index, (source_run_id, reason, score)) in selections.iter().enumerate() {
1021            let source_chat: Option<i64> = tx
1022                .query_row(
1023                    "SELECT chat_id FROM experience_runs WHERE run_id=?1",
1024                    params![source_run_id],
1025                    |row| row.get(0),
1026                )
1027                .optional()?;
1028            if source_chat != Some(querying_chat) || source_run_id == querying_run_id {
1029                return Err(MicroClawError::ToolExecution(
1030                    "retrieved experience must be a different run in the same chat".into(),
1031                ));
1032            }
1033            tx.execute(
1034                "INSERT INTO experience_retrieval_logs(
1035                    querying_run_id, source_run_id, rank, selection_reason,
1036                    relevance_score, injected_at
1037                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1038                params![
1039                    querying_run_id,
1040                    source_run_id,
1041                    index as i64 + 1,
1042                    reason,
1043                    score,
1044                    now
1045                ],
1046            )?;
1047        }
1048        tx.commit()?;
1049        Ok(())
1050    }
1051
1052    pub fn record_experience_rejections(
1053        &self,
1054        querying_run_id: &str,
1055        rejections: &[(String, String, f64)],
1056    ) -> Result<(), MicroClawError> {
1057        if rejections.len() > 50
1058            || rejections.iter().any(|(run_id, reason, score)| {
1059                run_id.trim().is_empty()
1060                    || reason.trim().is_empty()
1061                    || reason.len() > 1024
1062                    || !score.is_finite()
1063            })
1064        {
1065            return Err(MicroClawError::ToolExecution(
1066                "invalid experience rejection log".into(),
1067            ));
1068        }
1069        let mut conn = self.lock_conn();
1070        let tx = conn.transaction()?;
1071        tx.execute(
1072            "DELETE FROM experience_retrieval_rejections WHERE querying_run_id=?1",
1073            params![querying_run_id],
1074        )?;
1075        let now = chrono::Utc::now().to_rfc3339();
1076        for (source_run_id, reason, score) in rejections {
1077            tx.execute(
1078                "INSERT INTO experience_retrieval_rejections(
1079                    querying_run_id, source_run_id, rejection_reason,
1080                    relevance_score, rejected_at
1081                 ) SELECT ?1,?2,?3,?4,?5
1082                   WHERE EXISTS(
1083                     SELECT 1 FROM experience_runs q JOIN experience_runs s
1084                     ON q.chat_id=s.chat_id
1085                     WHERE q.run_id=?1 AND s.run_id=?2 AND q.run_id<>s.run_id
1086                   )",
1087                params![querying_run_id, source_run_id, reason, score, now],
1088            )?;
1089        }
1090        tx.commit()?;
1091        Ok(())
1092    }
1093
1094    pub fn get_experience_run_detail(
1095        &self,
1096        run_id: &str,
1097    ) -> Result<Option<ExperienceRunDetail>, MicroClawError> {
1098        let conn = self.lock_conn();
1099        let run = conn
1100            .query_row(
1101                "SELECT run_id, goal_id, chat_id, channel, run_kind, objective,
1102                        environment_fingerprint, status, result_summary, started_at,
1103                        finished_at, duration_ms, input_tokens, output_tokens,
1104                        llm_requests, tool_calls, tool_errors, estimated_cost_usd
1105                        , task_signature_version, task_type, task_family,
1106                        capability_tags_json, task_signature_hash
1107                 FROM experience_runs WHERE run_id=?1",
1108                params![run_id],
1109                |row| {
1110                    Ok(ExperienceRunRecord {
1111                        run_id: row.get(0)?,
1112                        goal_id: row.get(1)?,
1113                        chat_id: row.get(2)?,
1114                        channel: row.get(3)?,
1115                        run_kind: row.get(4)?,
1116                        objective: row.get(5)?,
1117                        environment_fingerprint: row.get(6)?,
1118                        status: row.get(7)?,
1119                        result_summary: row.get(8)?,
1120                        started_at: row.get(9)?,
1121                        finished_at: row.get(10)?,
1122                        duration_ms: row.get(11)?,
1123                        input_tokens: row.get(12)?,
1124                        output_tokens: row.get(13)?,
1125                        llm_requests: row.get(14)?,
1126                        tool_calls: row.get(15)?,
1127                        tool_errors: row.get(16)?,
1128                        estimated_cost_usd: row.get(17)?,
1129                        task_signature: TaskSignatureV1 {
1130                            version: row.get(18)?,
1131                            task_type: row.get(19)?,
1132                            task_family: row.get(20)?,
1133                            capability_tags: serde_json::from_str::<Vec<String>>(
1134                                &row.get::<_, String>(21)?,
1135                            )
1136                            .unwrap_or_default(),
1137                            signature_hash: row.get(22)?,
1138                        },
1139                    })
1140                },
1141            )
1142            .optional()?;
1143        let Some(run) = run else {
1144            return Ok(None);
1145        };
1146
1147        let mut outcome_stmt = conn.prepare(
1148            "SELECT envelope_id, schema_version, run_id, source_kind, source_name,
1149                    verdict, confidence, evidence, scope, valid_until,
1150                    payload_json, created_at
1151             FROM outcome_envelopes WHERE run_id=?1 ORDER BY created_at ASC",
1152        )?;
1153        let outcomes = outcome_stmt
1154            .query_map(params![run_id], |row| {
1155                let payload_json: String = row.get(10)?;
1156                Ok(OutcomeEnvelopeRecord {
1157                    envelope_id: row.get(0)?,
1158                    schema_version: row.get(1)?,
1159                    run_id: row.get(2)?,
1160                    source_kind: row.get(3)?,
1161                    source_name: row.get(4)?,
1162                    verdict: row.get(5)?,
1163                    confidence: row.get(6)?,
1164                    evidence: row.get(7)?,
1165                    scope: row.get(8)?,
1166                    valid_until: row.get(9)?,
1167                    payload: serde_json::from_str(&payload_json).unwrap_or(serde_json::Value::Null),
1168                    created_at: row.get(11)?,
1169                })
1170            })?
1171            .collect::<Result<Vec<_>, _>>()?;
1172
1173        let mut feedback_stmt = conn.prepare(
1174            "SELECT feedback_id, run_id, actor, verdict, confidence, evidence,
1175                    scope, created_at, updated_at, valid_until
1176             FROM experience_feedback WHERE run_id=?1 ORDER BY updated_at ASC",
1177        )?;
1178        let feedback = feedback_stmt
1179            .query_map(params![run_id], |row| {
1180                Ok(ExperienceFeedbackRecord {
1181                    feedback_id: row.get(0)?,
1182                    run_id: row.get(1)?,
1183                    actor: row.get(2)?,
1184                    verdict: row.get(3)?,
1185                    confidence: row.get(4)?,
1186                    evidence: row.get(5)?,
1187                    scope: row.get(6)?,
1188                    created_at: row.get(7)?,
1189                    updated_at: row.get(8)?,
1190                    valid_until: row.get(9)?,
1191                })
1192            })?
1193            .collect::<Result<Vec<_>, _>>()?;
1194
1195        let mut retrieval_stmt = conn.prepare(
1196            "SELECT l.querying_run_id, l.source_run_id, l.rank,
1197                    l.selection_reason, l.relevance_score, l.injected_at,
1198                    r.objective, r.result_summary
1199             FROM experience_retrieval_logs l
1200             JOIN experience_runs r ON r.run_id=l.source_run_id
1201             WHERE l.querying_run_id=?1 ORDER BY l.rank ASC",
1202        )?;
1203        let retrieved_experiences = retrieval_stmt
1204            .query_map(params![run_id], |row| {
1205                Ok(ExperienceRetrievalRecord {
1206                    querying_run_id: row.get(0)?,
1207                    source_run_id: row.get(1)?,
1208                    rank: row.get(2)?,
1209                    selection_reason: row.get(3)?,
1210                    relevance_score: row.get(4)?,
1211                    injected_at: row.get(5)?,
1212                    source_objective: row.get(6)?,
1213                    source_result_summary: row.get(7)?,
1214                })
1215            })?
1216            .collect::<Result<Vec<_>, _>>()?;
1217
1218        let mut rejection_stmt = conn.prepare(
1219            "SELECT x.source_run_id, x.rejection_reason, x.relevance_score,
1220                    x.rejected_at, r.objective
1221             FROM experience_retrieval_rejections x
1222             JOIN experience_runs r ON r.run_id=x.source_run_id
1223             WHERE x.querying_run_id=?1 ORDER BY x.rejected_at ASC",
1224        )?;
1225        let rejected_experiences = rejection_stmt
1226            .query_map(params![run_id], |row| {
1227                Ok(ExperienceRejectionRecord {
1228                    source_run_id: row.get(0)?,
1229                    rejection_reason: row.get(1)?,
1230                    relevance_score: row.get(2)?,
1231                    rejected_at: row.get(3)?,
1232                    source_objective: row.get(4)?,
1233                })
1234            })?
1235            .collect::<Result<Vec<_>, _>>()?;
1236
1237        let mut skill_stmt = conn.prepare(
1238            "SELECT skill_name, skill_version, activated_at
1239             FROM skill_activation_logs
1240             WHERE experience_run_id=?1 ORDER BY activated_at ASC",
1241        )?;
1242        let activated_skills = skill_stmt
1243            .query_map(params![run_id], |row| {
1244                Ok(SkillActivationRecord {
1245                    skill_name: row.get(0)?,
1246                    skill_version: row.get(1)?,
1247                    activated_at: row.get(2)?,
1248                })
1249            })?
1250            .collect::<Result<Vec<_>, _>>()?;
1251
1252        Ok(Some(ExperienceRunDetail {
1253            run,
1254            outcomes,
1255            feedback,
1256            retrieved_experiences,
1257            rejected_experiences,
1258            activated_skills,
1259        }))
1260    }
1261
1262    pub fn get_experience_summary(
1263        &self,
1264        chat_id: Option<i64>,
1265    ) -> Result<ExperienceSummary, MicroClawError> {
1266        let conn = self.lock_conn();
1267        let sql = if chat_id.is_some() {
1268            "SELECT COUNT(*),
1269                    COALESCE(SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END), 0),
1270                    COALESCE(SUM(CASE WHEN status IN ('failed','cancelled') THEN 1 ELSE 0 END), 0),
1271                    COALESCE(SUM(CASE WHEN EXISTS(
1272                        SELECT 1 FROM verifier_results v WHERE v.run_id=experience_runs.run_id
1273                    ) THEN 1 ELSE 0 END), 0),
1274                    COALESCE(SUM(input_tokens), 0),
1275                    COALESCE(SUM(output_tokens), 0),
1276                    COALESCE(SUM(tool_calls), 0),
1277                    COALESCE(SUM(tool_errors), 0),
1278                    COALESCE(SUM(estimated_cost_usd), 0.0)
1279             FROM experience_runs WHERE chat_id=?1"
1280        } else {
1281            "SELECT COUNT(*),
1282                    COALESCE(SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END), 0),
1283                    COALESCE(SUM(CASE WHEN status IN ('failed','cancelled') THEN 1 ELSE 0 END), 0),
1284                    COALESCE(SUM(CASE WHEN EXISTS(
1285                        SELECT 1 FROM verifier_results v WHERE v.run_id=experience_runs.run_id
1286                    ) THEN 1 ELSE 0 END), 0),
1287                    COALESCE(SUM(input_tokens), 0),
1288                    COALESCE(SUM(output_tokens), 0),
1289                    COALESCE(SUM(tool_calls), 0),
1290                    COALESCE(SUM(tool_errors), 0),
1291                    COALESCE(SUM(estimated_cost_usd), 0.0)
1292             FROM experience_runs"
1293        };
1294        let map = |row: &rusqlite::Row<'_>| {
1295            Ok(ExperienceSummary {
1296                total_runs: row.get(0)?,
1297                completed_runs: row.get(1)?,
1298                failed_runs: row.get(2)?,
1299                verified_runs: row.get(3)?,
1300                input_tokens: row.get(4)?,
1301                output_tokens: row.get(5)?,
1302                tool_calls: row.get(6)?,
1303                tool_errors: row.get(7)?,
1304                estimated_cost_usd: row.get(8)?,
1305            })
1306        };
1307        if let Some(chat_id) = chat_id {
1308            conn.query_row(sql, params![chat_id], map)
1309        } else {
1310            conn.query_row(sql, [], map)
1311        }
1312        .map_err(Into::into)
1313    }
1314
1315    pub fn search_verified_experiences(
1316        &self,
1317        chat_id: i64,
1318        query: &str,
1319        environment_fingerprint: Option<&str>,
1320        limit: usize,
1321    ) -> Result<Vec<VerifiedExperienceMatch>, MicroClawError> {
1322        let conn = self.lock_conn();
1323        let mut stmt = conn.prepare(
1324            "SELECT run_id, objective, environment_fingerprint, result_summary,
1325                    started_at, duration_ms, input_tokens + output_tokens,
1326                    tool_calls, tool_errors, estimated_cost_usd,
1327                    task_signature_version, task_type, task_family,
1328                    capability_tags_json, task_signature_hash
1329             FROM experience_runs
1330             WHERE chat_id=?1 AND status IN ('completed','failed','cancelled')
1331             ORDER BY started_at DESC LIMIT 200",
1332        )?;
1333        let candidates = stmt
1334            .query_map(params![chat_id], |row| {
1335                Ok((
1336                    row.get::<_, String>(0)?,
1337                    row.get::<_, String>(1)?,
1338                    row.get::<_, Option<String>>(2)?,
1339                    row.get::<_, Option<String>>(3)?,
1340                    row.get::<_, String>(4)?,
1341                    row.get::<_, Option<i64>>(5)?,
1342                    row.get::<_, i64>(6)?,
1343                    row.get::<_, i64>(7)?,
1344                    row.get::<_, i64>(8)?,
1345                    row.get::<_, Option<f64>>(9)?,
1346                    row.get::<_, i64>(10)?,
1347                    row.get::<_, String>(11)?,
1348                    row.get::<_, String>(12)?,
1349                    row.get::<_, String>(13)?,
1350                    row.get::<_, String>(14)?,
1351                ))
1352            })?
1353            .collect::<Result<Vec<_>, _>>()?;
1354        drop(stmt);
1355
1356        let query_lower = query.trim().to_lowercase();
1357        let query_signature = derive_task_signature(query, "interactive");
1358        let query_terms = query_lower
1359            .split(|ch: char| !ch.is_alphanumeric())
1360            .filter(|term| term.chars().count() >= 2)
1361            .collect::<Vec<_>>();
1362        let now = chrono::Utc::now().to_rfc3339();
1363        let mut matches = Vec::new();
1364        for (
1365            run_id,
1366            objective,
1367            environment,
1368            result_summary,
1369            started_at,
1370            duration_ms,
1371            total_tokens,
1372            tool_calls,
1373            tool_errors,
1374            estimated_cost_usd,
1375            signature_version,
1376            task_type,
1377            task_family,
1378            capability_tags_json,
1379            signature_hash,
1380        ) in candidates
1381        {
1382            let searchable = format!(
1383                "{} {}",
1384                objective.to_lowercase(),
1385                result_summary.as_deref().unwrap_or("").to_lowercase()
1386            );
1387            let lexical = if query_lower.is_empty() {
1388                0.25
1389            } else if searchable.contains(&query_lower) {
1390                1.0
1391            } else if query_terms.is_empty() {
1392                0.0
1393            } else {
1394                query_terms
1395                    .iter()
1396                    .filter(|term| searchable.contains(**term))
1397                    .count() as f64
1398                    / query_terms.len() as f64
1399            };
1400            if lexical == 0.0 {
1401                continue;
1402            }
1403
1404            let mut verifier_stmt = conn.prepare(
1405                "SELECT verifier_type, verdict, confidence
1406                 FROM verifier_results
1407                 WHERE run_id=?1
1408                   AND verifier_type IN ('deterministic','environmental','human','rule_based')
1409                   AND (valid_until IS NULL OR valid_until > ?2)",
1410            )?;
1411            let verifier_rows = verifier_stmt
1412                .query_map(params![run_id, now], |row| {
1413                    Ok((
1414                        row.get::<_, String>(0)?,
1415                        row.get::<_, String>(1)?,
1416                        row.get::<_, f64>(2)?,
1417                    ))
1418                })?
1419                .collect::<Result<Vec<_>, _>>()?;
1420            if verifier_rows.is_empty() {
1421                continue;
1422            }
1423            let max_priority = verifier_rows
1424                .iter()
1425                .map(|result| verifier_priority(&result.0))
1426                .max()
1427                .unwrap_or(0);
1428            let strongest = verifier_rows
1429                .iter()
1430                .filter(|result| verifier_priority(&result.0) == max_priority)
1431                .collect::<Vec<_>>();
1432            let verifier_type = strongest[0].0.clone();
1433            let (verdict, confidence) = if verifier_type == "human" {
1434                let passed: f64 = strongest
1435                    .iter()
1436                    .filter(|result| result.1 == "passed")
1437                    .map(|result| result.2)
1438                    .sum();
1439                let failed: f64 = strongest
1440                    .iter()
1441                    .filter(|result| result.1 == "failed")
1442                    .map(|result| result.2)
1443                    .sum();
1444                let total = passed + failed;
1445                (
1446                    if passed > failed { "passed" } else { "failed" }.to_string(),
1447                    if total > 0.0 {
1448                        passed.max(failed) / total
1449                    } else {
1450                        0.0
1451                    },
1452                )
1453            } else {
1454                let selected = strongest
1455                    .iter()
1456                    .filter(|result| result.1 == "failed")
1457                    .max_by(|left, right| left.2.total_cmp(&right.2))
1458                    .or_else(|| {
1459                        strongest
1460                            .iter()
1461                            .max_by(|left, right| left.2.total_cmp(&right.2))
1462                    })
1463                    .expect("strongest verifier group is non-empty");
1464                (selected.1.clone(), selected.2)
1465            };
1466            let environment_bonus = match (environment_fingerprint, environment.as_deref()) {
1467                (Some(expected), Some(actual)) if expected == actual => 0.25,
1468                _ => 0.0,
1469            };
1470            let capability_tags =
1471                serde_json::from_str::<Vec<String>>(&capability_tags_json).unwrap_or_default();
1472            let task_bonus = if task_family == query_signature.task_family {
1473                0.3
1474            } else if task_type == query_signature.task_type {
1475                0.15
1476            } else {
1477                0.0
1478            };
1479            let shared_tags = capability_tags
1480                .iter()
1481                .filter(|tag| query_signature.capability_tags.contains(tag))
1482                .count();
1483            let tag_bonus = if capability_tags.is_empty()
1484                && query_signature.capability_tags.is_empty()
1485            {
1486                0.0
1487            } else {
1488                0.2 * shared_tags as f64
1489                    / capability_tags
1490                        .len()
1491                        .max(query_signature.capability_tags.len()) as f64
1492            };
1493            let rejection_reason = if verdict == "failed" {
1494                Some(format!(
1495                    "not injected: prior run has verified failure ({verifier_type})"
1496                ))
1497            } else {
1498                conn.query_row(
1499                    "SELECT 'not injected: skill ' || p.skill_name ||
1500                            ' is contraindicated for ' || p.task_family ||
1501                            ' (' || p.error_category || ', failures=' ||
1502                            p.failure_count || ')'
1503                     FROM skill_failure_patterns p
1504                     JOIN skill_activation_logs a
1505                       ON a.skill_name=p.skill_name
1506                      AND COALESCE(a.skill_version, p.skill_version)=p.skill_version
1507                     WHERE a.experience_run_id=?1
1508                       AND p.task_family=?2
1509                       AND (p.environment_fingerprint IS NULL
1510                            OR p.environment_fingerprint=?3)
1511                       AND p.state IN ('active','cooldown')
1512                     ORDER BY p.failure_count DESC LIMIT 1",
1513                    params![run_id, query_signature.task_family, environment_fingerprint],
1514                    |row| row.get::<_, String>(0),
1515                )
1516                .optional()?
1517            };
1518            matches.push(VerifiedExperienceMatch {
1519                run_id,
1520                objective,
1521                environment_fingerprint: environment,
1522                verdict,
1523                verifier_type,
1524                confidence,
1525                result_summary,
1526                started_at,
1527                duration_ms,
1528                total_tokens,
1529                tool_calls,
1530                tool_errors,
1531                estimated_cost_usd,
1532                relevance_score: lexical + environment_bonus + task_bonus + tag_bonus,
1533                utility_lower_bound: 0.0,
1534                task_signature: TaskSignatureV1 {
1535                    version: signature_version,
1536                    task_type,
1537                    task_family,
1538                    capability_tags,
1539                    signature_hash,
1540                },
1541                rejection_reason,
1542            });
1543        }
1544        let mut utility_samples = std::collections::HashMap::<String, (i64, i64)>::new();
1545        for item in &matches {
1546            let entry = utility_samples
1547                .entry(item.task_signature.task_family.clone())
1548                .or_default();
1549            entry.1 += 1;
1550            if item.verdict == "passed" {
1551                entry.0 += 1;
1552            }
1553        }
1554        for item in &mut matches {
1555            let (passed, total) = utility_samples
1556                .get(&item.task_signature.task_family)
1557                .copied()
1558                .unwrap_or_default();
1559            item.utility_lower_bound = wilson_lower_bound(passed, total, 1.96);
1560            item.relevance_score += 0.25 * item.utility_lower_bound;
1561        }
1562        matches.sort_by(|left, right| {
1563            right
1564                .relevance_score
1565                .total_cmp(&left.relevance_score)
1566                .then_with(|| right.started_at.cmp(&left.started_at))
1567        });
1568        matches.truncate(limit.clamp(1, 20));
1569        Ok(matches)
1570    }
1571
1572    #[expect(clippy::too_many_arguments)]
1573    pub fn record_verifier_result(
1574        &self,
1575        run_id: &str,
1576        verifier_type: &str,
1577        verifier_name: &str,
1578        verdict: &str,
1579        confidence: f64,
1580        evidence: Option<&str>,
1581        scope: Option<&str>,
1582        valid_until: Option<&str>,
1583    ) -> Result<(), MicroClawError> {
1584        let feedback = (verifier_type == "human").then(|| ExperienceFeedbackInput {
1585            feedback_id: verifier_name.to_string(),
1586            actor: "verifier".into(),
1587        });
1588        self.ingest_outcome_envelope(&OutcomeEnvelopeV1 {
1589            envelope_id: uuid::Uuid::new_v4().to_string(),
1590            run_id: run_id.to_string(),
1591            source_kind: verifier_type.to_string(),
1592            source_name: verifier_name.to_string(),
1593            verdict: verdict.to_string(),
1594            confidence,
1595            evidence: evidence.map(str::to_string),
1596            scope: scope.map(str::to_string),
1597            valid_until: valid_until.map(str::to_string),
1598            payload: serde_json::Value::Object(serde_json::Map::new()),
1599            feedback,
1600        })
1601    }
1602
1603    /// Ingest one versioned outcome envelope and atomically update its
1604    /// normalized projections. All outcome producers use this boundary.
1605    pub fn ingest_outcome_envelope(
1606        &self,
1607        envelope: &OutcomeEnvelopeV1,
1608    ) -> Result<(), MicroClawError> {
1609        validate_outcome_envelope(envelope)?;
1610        let mut conn = self.lock_conn();
1611        let tx = conn.transaction()?;
1612        let run_exists: i64 = tx.query_row(
1613            "SELECT COUNT(*) FROM experience_runs WHERE run_id=?1",
1614            params![envelope.run_id],
1615            |row| row.get(0),
1616        )?;
1617        if run_exists == 0 {
1618            return Err(MicroClawError::ToolExecution(format!(
1619                "experience run not found: {}",
1620                envelope.run_id
1621            )));
1622        }
1623        let now = chrono::Utc::now().to_rfc3339();
1624        let payload_json = serde_json::to_string(&envelope.payload).map_err(|error| {
1625            MicroClawError::ToolExecution(format!("invalid outcome payload: {error}"))
1626        })?;
1627        tx.execute(
1628            "INSERT INTO outcome_envelopes(
1629                envelope_id, schema_version, run_id, source_kind, source_name,
1630                verdict, confidence, evidence, scope, valid_until, payload_json,
1631                created_at
1632             ) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1633             ON CONFLICT(envelope_id) DO UPDATE SET
1634                verdict=excluded.verdict,
1635                confidence=excluded.confidence,
1636                evidence=excluded.evidence,
1637                scope=excluded.scope,
1638                valid_until=excluded.valid_until,
1639                payload_json=excluded.payload_json,
1640                created_at=excluded.created_at",
1641            params![
1642                envelope.envelope_id,
1643                envelope.run_id,
1644                envelope.source_kind,
1645                envelope.source_name,
1646                envelope.verdict,
1647                envelope.confidence.clamp(0.0, 1.0),
1648                envelope.evidence,
1649                envelope.scope,
1650                envelope.valid_until,
1651                payload_json,
1652                now
1653            ],
1654        )?;
1655        tx.execute(
1656            "INSERT INTO verifier_results(
1657                run_id, verifier_type, verifier_name, verdict, confidence,
1658                evidence, scope, verified_at, valid_until
1659             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1660             ON CONFLICT(run_id, verifier_type, verifier_name) DO UPDATE SET
1661                verdict=excluded.verdict,
1662                confidence=excluded.confidence,
1663                evidence=excluded.evidence,
1664                scope=excluded.scope,
1665                verified_at=excluded.verified_at,
1666                valid_until=excluded.valid_until",
1667            params![
1668                envelope.run_id,
1669                envelope.source_kind,
1670                envelope.source_name,
1671                envelope.verdict,
1672                envelope.confidence.clamp(0.0, 1.0),
1673                envelope.evidence,
1674                envelope.scope,
1675                now,
1676                envelope.valid_until
1677            ],
1678        )?;
1679        if let Some(feedback) = &envelope.feedback {
1680            tx.execute(
1681                "INSERT INTO experience_feedback(
1682                    feedback_id, run_id, actor, verdict, confidence, evidence,
1683                    scope, created_at, updated_at, valid_until
1684                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9)
1685                 ON CONFLICT(run_id, actor, feedback_id) DO UPDATE SET
1686                    verdict=excluded.verdict,
1687                    confidence=excluded.confidence,
1688                    evidence=excluded.evidence,
1689                    scope=excluded.scope,
1690                    updated_at=excluded.updated_at,
1691                    valid_until=excluded.valid_until",
1692                params![
1693                    feedback.feedback_id,
1694                    envelope.run_id,
1695                    feedback.actor,
1696                    envelope.verdict,
1697                    envelope.confidence.clamp(0.0, 1.0),
1698                    envelope.evidence,
1699                    envelope.scope,
1700                    now,
1701                    envelope.valid_until
1702                ],
1703            )?;
1704        }
1705        sync_skill_outcomes_for_run(&tx, &envelope.run_id)?;
1706        sync_skill_failure_patterns_for_run(&tx, &envelope.run_id)?;
1707        refresh_comparative_reflections_for_run(&tx, &envelope.run_id)?;
1708        tx.commit()?;
1709        Ok(())
1710    }
1711
1712    pub fn get_verifier_results(
1713        &self,
1714        run_id: &str,
1715    ) -> Result<Vec<VerifierResultRecord>, MicroClawError> {
1716        let conn = self.lock_conn();
1717        let mut stmt = conn.prepare(
1718            "SELECT id, run_id, verifier_type, verifier_name, verdict,
1719                    confidence, evidence, scope, verified_at, valid_until
1720             FROM verifier_results WHERE run_id=?1
1721             ORDER BY verified_at ASC",
1722        )?;
1723        let rows = stmt.query_map(params![run_id], |row| {
1724            Ok(VerifierResultRecord {
1725                id: row.get(0)?,
1726                run_id: row.get(1)?,
1727                verifier_type: row.get(2)?,
1728                verifier_name: row.get(3)?,
1729                verdict: row.get(4)?,
1730                confidence: row.get(5)?,
1731                evidence: row.get(6)?,
1732                scope: row.get(7)?,
1733                verified_at: row.get(8)?,
1734                valid_until: row.get(9)?,
1735            })
1736        })?;
1737        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1738    }
1739
1740    pub fn get_experience_comparisons(
1741        &self,
1742        chat_id: Option<i64>,
1743        limit: usize,
1744    ) -> Result<Vec<ExperienceComparison>, MicroClawError> {
1745        let conn = self.lock_conn();
1746        let mut stmt = conn.prepare(
1747            "SELECT comparison_id, chat_id, skill_name, skill_version, task_type,
1748                    task_family, environment_fingerprint, success_run_id,
1749                    failure_run_id, minimal_difference, counterexample, created_at
1750             FROM experience_comparisons
1751             WHERE (?1 IS NULL OR chat_id=?1)
1752             ORDER BY created_at DESC LIMIT ?2",
1753        )?;
1754        let rows = stmt
1755            .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
1756                Ok(ExperienceComparison {
1757                    comparison_id: row.get(0)?,
1758                    chat_id: row.get(1)?,
1759                    skill_name: row.get(2)?,
1760                    skill_version: row.get(3)?,
1761                    task_type: row.get(4)?,
1762                    task_family: row.get(5)?,
1763                    environment_fingerprint: row.get(6)?,
1764                    success_run_id: row.get(7)?,
1765                    failure_run_id: row.get(8)?,
1766                    minimal_difference: row.get(9)?,
1767                    counterexample: row.get(10)?,
1768                    created_at: row.get(11)?,
1769                })
1770            })?
1771            .collect::<Result<Vec<_>, _>>()?;
1772        Ok(rows)
1773    }
1774}
1775
1776#[cfg(test)]
1777mod tests {
1778    use super::*;
1779    use crate::internal::storage::db::test_support::*;
1780
1781    #[test]
1782    fn learning_substrate_promotes_verified_skill_and_preserves_evidence() {
1783        let (db, dir) = test_db();
1784        db.upsert_goal_state(
1785            "goal-1",
1786            42,
1787            "finish the release",
1788            "active",
1789            Some(r#"{"must_pass_tests":true}"#),
1790            None,
1791            Some(r#"{"tool_calls":20}"#),
1792        )
1793        .unwrap();
1794        assert_eq!(
1795            db.get_active_goal_state(42).unwrap().unwrap().objective,
1796            "finish the release"
1797        );
1798
1799        db.register_skill_version(
1800            "release-check",
1801            1,
1802            "---\nname: release-check\n---\nRun tests.",
1803            "agent-created",
1804        )
1805        .unwrap();
1806        assert_eq!(
1807            db.get_skill_lifecycle("release-check")
1808                .unwrap()
1809                .unwrap()
1810                .state,
1811            "candidate"
1812        );
1813        db.start_experience_run(
1814            "self-reported",
1815            Some("goal-1"),
1816            42,
1817            "web",
1818            "interactive",
1819            "ship it",
1820            Some("repo=test"),
1821        )
1822        .unwrap();
1823        db.log_skill_activation("release-check", 42).unwrap();
1824        db.record_verifier_result(
1825            "self-reported",
1826            "runtime",
1827            "agent_loop_completion",
1828            "passed",
1829            0.55,
1830            Some("model returned normally"),
1831            Some("turn"),
1832            None,
1833        )
1834        .unwrap();
1835        assert_eq!(
1836            db.get_skill_lifecycle("release-check")
1837                .unwrap()
1838                .unwrap()
1839                .state,
1840            "candidate",
1841            "runtime self-report must not advance governed behavior"
1842        );
1843        db.finish_experience_run("self-reported", "completed", None, 10)
1844            .unwrap();
1845        db.update_experience_metrics("self-reported", 120, 30, 2, 4, 1, Some(0.0125))
1846            .unwrap();
1847        let measured = db
1848            .get_recent_experience_runs(Some(42), 1)
1849            .unwrap()
1850            .pop()
1851            .unwrap();
1852        assert_eq!(measured.input_tokens, 120);
1853        assert_eq!(measured.output_tokens, 30);
1854        assert_eq!(measured.llm_requests, 2);
1855        assert_eq!(measured.tool_calls, 4);
1856        assert_eq!(measured.tool_errors, 1);
1857        assert_eq!(measured.estimated_cost_usd, Some(0.0125));
1858
1859        for index in 1..=3 {
1860            let run_id = format!("run-{index}");
1861            db.start_experience_run(
1862                &run_id,
1863                Some("goal-1"),
1864                42,
1865                "web",
1866                "interactive",
1867                "ship it",
1868                Some("repo=test"),
1869            )
1870            .unwrap();
1871            db.log_skill_activation("release-check", 42).unwrap();
1872            db.finish_experience_run(&run_id, "completed", Some("done"), 10)
1873                .unwrap();
1874            db.record_verifier_result(
1875                &run_id,
1876                "deterministic",
1877                "tests",
1878                "passed",
1879                1.0,
1880                Some("cargo test passed"),
1881                Some("repository"),
1882                None,
1883            )
1884            .unwrap();
1885        }
1886
1887        let lifecycle = db.get_skill_lifecycle("release-check").unwrap().unwrap();
1888        assert_eq!(lifecycle.state, "trusted");
1889        let summaries = db.get_skill_learning_summaries().unwrap();
1890        let summary = summaries
1891            .iter()
1892            .find(|summary| summary.skill_name == "release-check")
1893            .unwrap();
1894        assert_eq!(summary.total_outcomes, 3);
1895        assert_eq!(summary.passed_outcomes, 3);
1896        assert_eq!(summary.success_rate, 1.0);
1897        assert_eq!(
1898            db.get_verifier_results("run-1").unwrap()[0]
1899                .evidence
1900                .as_deref(),
1901            Some("cargo test passed")
1902        );
1903        assert!(db.clear_chat_memory(42).unwrap());
1904        assert!(db
1905            .get_recent_experience_runs(Some(42), 10)
1906            .unwrap()
1907            .is_empty());
1908        assert!(db.get_active_goal_state(42).unwrap().is_none());
1909        assert_eq!(
1910            db.get_skill_lifecycle("release-check")
1911                .unwrap()
1912                .unwrap()
1913                .state,
1914            "candidate",
1915            "erasing chat learning must recompute derived skill trust"
1916        );
1917        cleanup(&dir);
1918    }
1919
1920    #[test]
1921    fn learning_substrate_blocks_repeated_failures_in_matching_environment() {
1922        let (db, dir) = test_db();
1923        db.register_skill_version("deploy", 1, "deploy safely", "built-in")
1924            .unwrap();
1925        for index in 1..=2 {
1926            let run_id = format!("linux-failure-{index}");
1927            db.start_experience_run(
1928                &run_id,
1929                None,
1930                9,
1931                "web",
1932                "interactive",
1933                "deploy",
1934                Some("os=linux;provider=acme"),
1935            )
1936            .unwrap();
1937            db.log_skill_activation("deploy", 9).unwrap();
1938            db.record_verifier_result(
1939                &run_id,
1940                "deterministic",
1941                "deployment_health",
1942                "failed",
1943                1.0,
1944                Some("health check failed"),
1945                None,
1946                None,
1947            )
1948            .unwrap();
1949            db.finish_experience_run(&run_id, "completed", None, 10)
1950                .unwrap();
1951        }
1952        db.start_experience_run(
1953            "linux-current",
1954            None,
1955            9,
1956            "web",
1957            "interactive",
1958            "deploy",
1959            Some("os=linux;provider=acme"),
1960        )
1961        .unwrap();
1962        let applicability = db.evaluate_skill_applicability("deploy", 9).unwrap();
1963        assert!(!applicability.allowed);
1964        assert_eq!(applicability.matching_outcomes, 2);
1965        assert_eq!(applicability.failed_outcomes, 2);
1966        assert_eq!(applicability.task_family.as_deref(), Some("deployment"));
1967        db.finish_experience_run("linux-current", "completed", None, 1)
1968            .unwrap();
1969        db.start_experience_run(
1970            "linux-debug-current",
1971            None,
1972            9,
1973            "web",
1974            "interactive",
1975            "debug parser failure",
1976            Some("os=linux;provider=acme"),
1977        )
1978        .unwrap();
1979        let different_family = db.evaluate_skill_applicability("deploy", 9).unwrap();
1980        assert!(different_family.allowed);
1981        assert_eq!(different_family.matching_outcomes, 0);
1982        assert_eq!(different_family.task_family.as_deref(), Some("debugging"));
1983
1984        db.transition_skill_state("deploy", "archived", "operator archive")
1985            .unwrap();
1986        assert_eq!(
1987            db.get_skill_lifecycle("deploy").unwrap().unwrap().state,
1988            "archived"
1989        );
1990        cleanup(&dir);
1991    }
1992
1993    #[test]
1994    fn learning_failure_patterns_block_retrieval_and_recover_after_cooldown() {
1995        let (db, dir) = test_db();
1996        db.register_skill_version("deploy-safe", 1, "deploy", "built-in")
1997            .unwrap();
1998        for index in 1..=2 {
1999            let run_id = format!("deploy-timeout-{index}");
2000            db.start_experience_run(
2001                &run_id,
2002                None,
2003                91,
2004                "web",
2005                "interactive",
2006                "deploy service",
2007                Some("os=linux"),
2008            )
2009            .unwrap();
2010            db.log_skill_activation("deploy-safe", 91).unwrap();
2011            db.ingest_outcome_envelope(&OutcomeEnvelopeV1 {
2012                envelope_id: format!("tool:{index}"),
2013                run_id: run_id.clone(),
2014                source_kind: "runtime".into(),
2015                source_name: "tool_result:bash".into(),
2016                verdict: "failed".into(),
2017                confidence: 1.0,
2018                evidence: Some("command timed out".into()),
2019                scope: Some("tool_result".into()),
2020                valid_until: None,
2021                payload: serde_json::json!({"tool_name": "bash"}),
2022                feedback: None,
2023            })
2024            .unwrap();
2025            db.record_verifier_result(
2026                &run_id,
2027                "deterministic",
2028                "health",
2029                "failed",
2030                1.0,
2031                Some("deployment health failed"),
2032                None,
2033                None,
2034            )
2035            .unwrap();
2036            db.finish_experience_run(&run_id, "failed", Some("timeout"), 10)
2037                .unwrap();
2038        }
2039
2040        let patterns = db.get_skill_failure_patterns(Some("deploy-safe")).unwrap();
2041        assert_eq!(patterns.len(), 1);
2042        assert_eq!(patterns[0].task_family, "deployment");
2043        assert_eq!(patterns[0].tool_name.as_deref(), Some("bash"));
2044        assert_eq!(patterns[0].error_category, "timeout");
2045        assert_eq!(patterns[0].failure_count, 2);
2046        assert_eq!(patterns[0].state, "active");
2047        assert_eq!(
2048            db.get_skill_lifecycle("deploy-safe")
2049                .unwrap()
2050                .unwrap()
2051                .state,
2052            "degraded"
2053        );
2054
2055        let matches = db
2056            .search_verified_experiences(91, "deploy service", Some("os=linux"), 10)
2057            .unwrap();
2058        assert!(matches.iter().all(|item| item.rejection_reason.is_some()));
2059
2060        db.start_experience_run(
2061            "querying-run",
2062            None,
2063            91,
2064            "web",
2065            "interactive",
2066            "deploy service",
2067            Some("os=linux"),
2068        )
2069        .unwrap();
2070        let rejected = matches
2071            .iter()
2072            .map(|item| {
2073                (
2074                    item.run_id.clone(),
2075                    item.rejection_reason.clone().unwrap(),
2076                    item.relevance_score,
2077                )
2078            })
2079            .collect::<Vec<_>>();
2080        db.record_experience_rejections("querying-run", &rejected)
2081            .unwrap();
2082        let detail = db
2083            .get_experience_run_detail("querying-run")
2084            .unwrap()
2085            .unwrap();
2086        assert_eq!(detail.rejected_experiences.len(), 2);
2087        assert!(detail.retrieved_experiences.is_empty());
2088        db.finish_experience_run("querying-run", "completed", None, 1)
2089            .unwrap();
2090
2091        {
2092            let conn = db.lock_conn();
2093            conn.execute(
2094                "UPDATE skill_failure_patterns
2095                 SET cooldown_until='2000-01-01T00:00:00+00:00'
2096                 WHERE skill_name='deploy-safe'",
2097                [],
2098            )
2099            .unwrap();
2100        }
2101        assert!(db.begin_skill_recovery_trial("deploy-safe").unwrap());
2102        assert_eq!(
2103            db.get_skill_lifecycle("deploy-safe")
2104                .unwrap()
2105                .unwrap()
2106                .state,
2107            "trial"
2108        );
2109        for index in 1..=2 {
2110            let run_id = format!("deploy-recovery-{index}");
2111            db.start_experience_run(
2112                &run_id,
2113                None,
2114                91,
2115                "web",
2116                "interactive",
2117                "deploy service",
2118                Some("os=linux"),
2119            )
2120            .unwrap();
2121            db.log_skill_activation("deploy-safe", 91).unwrap();
2122            db.record_verifier_result(
2123                &run_id,
2124                "deterministic",
2125                "health",
2126                "passed",
2127                1.0,
2128                Some("healthy"),
2129                None,
2130                None,
2131            )
2132            .unwrap();
2133        }
2134        let pattern = db
2135            .get_skill_failure_patterns(Some("deploy-safe"))
2136            .unwrap()
2137            .pop()
2138            .unwrap();
2139        assert_eq!(pattern.state, "resolved");
2140        assert_eq!(pattern.recovery_successes, 2);
2141        assert_eq!(
2142            db.get_skill_lifecycle("deploy-safe")
2143                .unwrap()
2144                .unwrap()
2145                .state,
2146            "trial"
2147        );
2148        cleanup(&dir);
2149    }
2150
2151    #[test]
2152    fn comparative_reflection_never_pairs_or_exposes_across_chats() {
2153        let (db, dir) = test_db();
2154        db.register_skill_version("scoped-reflection", 1, "base", "built-in")
2155            .unwrap();
2156        for (run_id, chat_id, verdict) in [
2157            ("scoped-failure", 201, "failed"),
2158            ("scoped-success", 202, "passed"),
2159        ] {
2160            db.start_experience_run(
2161                run_id,
2162                None,
2163                chat_id,
2164                "web",
2165                "interactive",
2166                "deploy service",
2167                Some("os=linux"),
2168            )
2169            .unwrap();
2170            db.log_skill_activation("scoped-reflection", chat_id)
2171                .unwrap();
2172            db.record_verifier_result(
2173                run_id,
2174                "deterministic",
2175                "health",
2176                verdict,
2177                1.0,
2178                Some(verdict),
2179                None,
2180                None,
2181            )
2182            .unwrap();
2183        }
2184        assert!(db
2185            .get_experience_comparisons(Some(201), 10)
2186            .unwrap()
2187            .is_empty());
2188        assert!(db
2189            .get_experience_comparisons(Some(202), 10)
2190            .unwrap()
2191            .is_empty());
2192        assert!(db.get_learning_claims(Some(201), 10).unwrap().is_empty());
2193        assert!(db.get_learning_claims(Some(202), 10).unwrap().is_empty());
2194        cleanup(&dir);
2195    }
2196
2197    #[test]
2198    fn learning_substrate_recovers_interrupted_runs_without_penalizing_skill() {
2199        let (db, dir) = test_db();
2200        db.register_skill_version("restart-safe", 1, "instructions", "agent-created")
2201            .unwrap();
2202        db.start_experience_run(
2203            "interrupted-run",
2204            None,
2205            11,
2206            "web",
2207            "interactive",
2208            "long task",
2209            Some("channel=web"),
2210        )
2211        .unwrap();
2212        db.log_skill_activation("restart-safe", 11).unwrap();
2213
2214        assert_eq!(db.recover_running_experience_runs().unwrap(), 1);
2215        assert_eq!(db.recover_running_experience_runs().unwrap(), 0);
2216        let run = db
2217            .get_recent_experience_runs(Some(11), 1)
2218            .unwrap()
2219            .pop()
2220            .unwrap();
2221        assert_eq!(run.status, "failed");
2222        assert_eq!(
2223            db.get_skill_lifecycle("restart-safe")
2224                .unwrap()
2225                .unwrap()
2226                .state,
2227            "candidate",
2228            "process interruption must remain observable without governing the skill"
2229        );
2230        cleanup(&dir);
2231    }
2232
2233    #[test]
2234    fn learning_substrate_aggregates_human_feedback_and_ignores_expired_evidence() {
2235        let (db, dir) = test_db();
2236        db.register_skill_version("reviewed", 1, "instructions", "agent-created")
2237            .unwrap();
2238        db.start_experience_run(
2239            "human-run",
2240            None,
2241            12,
2242            "web",
2243            "interactive",
2244            "review",
2245            Some("channel=web"),
2246        )
2247        .unwrap();
2248        db.log_skill_activation("reviewed", 12).unwrap();
2249        db.record_verifier_result(
2250            "human-run",
2251            "human",
2252            "user_feedback:alice",
2253            "passed",
2254            0.9,
2255            Some("confirmed"),
2256            None,
2257            None,
2258        )
2259        .unwrap();
2260        db.record_verifier_result(
2261            "human-run",
2262            "human",
2263            "user_feedback:bob",
2264            "failed",
2265            0.2,
2266            Some("minor concern"),
2267            None,
2268            None,
2269        )
2270        .unwrap();
2271        db.finish_experience_run("human-run", "completed", Some("review completed"), 25)
2272            .unwrap();
2273        assert_eq!(
2274            db.get_skill_lifecycle("reviewed").unwrap().unwrap().state,
2275            "trial"
2276        );
2277        let summary = db
2278            .get_skill_learning_summaries()
2279            .unwrap()
2280            .into_iter()
2281            .find(|summary| summary.skill_name == "reviewed")
2282            .unwrap();
2283        assert_eq!(summary.total_outcomes, 1);
2284        assert_eq!(summary.passed_outcomes, 1);
2285        let recalled = db
2286            .search_verified_experiences(12, "review", Some("channel=web"), 5)
2287            .unwrap();
2288        assert_eq!(recalled.len(), 1);
2289        assert_eq!(recalled[0].run_id, "human-run");
2290        assert_eq!(recalled[0].verdict, "passed");
2291        assert!(recalled[0].relevance_score > 1.0);
2292        assert_eq!(recalled[0].task_signature.task_type, "general");
2293        assert!(recalled[0].utility_lower_bound > 0.0);
2294
2295        db.start_experience_run(
2296            "expired-run",
2297            None,
2298            12,
2299            "web",
2300            "interactive",
2301            "review",
2302            Some("channel=web"),
2303        )
2304        .unwrap();
2305        db.log_skill_activation("reviewed", 12).unwrap();
2306        db.record_verifier_result(
2307            "expired-run",
2308            "human",
2309            "user_feedback:old",
2310            "failed",
2311            1.0,
2312            Some("stale"),
2313            None,
2314            Some("2000-01-01T00:00:00+00:00"),
2315        )
2316        .unwrap();
2317        let summary = db
2318            .get_skill_learning_summaries()
2319            .unwrap()
2320            .into_iter()
2321            .find(|summary| summary.skill_name == "reviewed")
2322            .unwrap();
2323        assert_eq!(summary.total_outcomes, 1);
2324        assert_eq!(summary.failed_outcomes, 0);
2325        cleanup(&dir);
2326    }
2327
2328    #[test]
2329    fn learning_substrate_expired_strong_evidence_falls_back_to_active_evidence() {
2330        let (db, dir) = test_db();
2331        db.register_skill_version("expiry", 1, "instructions", "agent-created")
2332            .unwrap();
2333        db.start_experience_run(
2334            "expiry-run",
2335            None,
2336            14,
2337            "web",
2338            "interactive",
2339            "expiry check",
2340            Some("channel=web"),
2341        )
2342        .unwrap();
2343        db.log_skill_activation("expiry", 14).unwrap();
2344        db.record_verifier_result(
2345            "expiry-run",
2346            "human",
2347            "user_feedback:reviewer",
2348            "failed",
2349            0.8,
2350            None,
2351            None,
2352            None,
2353        )
2354        .unwrap();
2355        db.record_verifier_result(
2356            "expiry-run",
2357            "deterministic",
2358            "temporary-check",
2359            "passed",
2360            1.0,
2361            None,
2362            None,
2363            Some("2099-01-01T00:00:00+00:00"),
2364        )
2365        .unwrap();
2366        {
2367            let conn = db.lock_conn();
2368            conn.execute(
2369                "UPDATE verifier_results SET valid_until='2000-01-01T00:00:00+00:00'
2370                 WHERE run_id='expiry-run' AND verifier_name='temporary-check'",
2371                [],
2372            )
2373            .unwrap();
2374            conn.execute(
2375                "UPDATE skill_outcomes SET valid_until='2000-01-01T00:00:00+00:00'
2376                 WHERE run_id='expiry-run'",
2377                [],
2378            )
2379            .unwrap();
2380        }
2381        let summary = db
2382            .get_skill_learning_summaries()
2383            .unwrap()
2384            .into_iter()
2385            .find(|summary| summary.skill_name == "expiry")
2386            .unwrap();
2387        assert_eq!(summary.total_outcomes, 1);
2388        assert_eq!(summary.failed_outcomes, 1);
2389        cleanup(&dir);
2390    }
2391
2392    #[test]
2393    fn outcome_envelope_projects_feedback_and_retrieval_audit_detail() {
2394        let (db, dir) = test_db();
2395        db.start_experience_run(
2396            "source-run",
2397            None,
2398            21,
2399            "web",
2400            "interactive",
2401            "deploy service",
2402            Some("channel=web"),
2403        )
2404        .unwrap();
2405        db.finish_experience_run("source-run", "completed", Some("deployed"), 20)
2406            .unwrap();
2407        db.record_verifier_result(
2408            "source-run",
2409            "deterministic",
2410            "deployment_check",
2411            "passed",
2412            1.0,
2413            Some("health check passed"),
2414            Some("deployment"),
2415            None,
2416        )
2417        .unwrap();
2418        db.start_experience_run(
2419            "query-run",
2420            None,
2421            21,
2422            "web",
2423            "interactive",
2424            "deploy another service",
2425            Some("channel=web"),
2426        )
2427        .unwrap();
2428        db.record_experience_retrievals(
2429            "query-run",
2430            &[(
2431                "source-run".into(),
2432                "strong_verified; environment_match=true".into(),
2433                1.25,
2434            )],
2435        )
2436        .unwrap();
2437        db.ingest_outcome_envelope(&OutcomeEnvelopeV1 {
2438            envelope_id: "feedback-envelope".into(),
2439            run_id: "query-run".into(),
2440            source_kind: "human".into(),
2441            source_name: "user_feedback:tester:accept".into(),
2442            verdict: "passed".into(),
2443            confidence: 0.9,
2444            evidence: Some("confirmed by operator".into()),
2445            scope: Some("turn".into()),
2446            valid_until: None,
2447            payload: serde_json::json!({"origin": "test"}),
2448            feedback: Some(ExperienceFeedbackInput {
2449                feedback_id: "accept".into(),
2450                actor: "tester".into(),
2451            }),
2452        })
2453        .unwrap();
2454
2455        let detail = db.get_experience_run_detail("query-run").unwrap().unwrap();
2456        assert_eq!(detail.outcomes.len(), 1);
2457        assert_eq!(detail.outcomes[0].schema_version, 1);
2458        assert_eq!(detail.outcomes[0].payload["origin"], "test");
2459        assert_eq!(detail.feedback.len(), 1);
2460        assert_eq!(detail.feedback[0].feedback_id, "accept");
2461        assert_eq!(detail.retrieved_experiences.len(), 1);
2462        assert_eq!(detail.retrieved_experiences[0].source_run_id, "source-run");
2463
2464        assert!(db
2465            .record_experience_retrievals(
2466                "query-run",
2467                &[("missing-run".into(), "invalid".into(), 1.0)]
2468            )
2469            .is_err());
2470        cleanup(&dir);
2471    }
2472
2473    #[test]
2474    fn migration_37_backfills_existing_verifiers_and_human_feedback() {
2475        let (db, dir) = test_db();
2476        db.start_experience_run(
2477            "pre-v37-run",
2478            None,
2479            22,
2480            "web",
2481            "interactive",
2482            "legacy outcome",
2483            None,
2484        )
2485        .unwrap();
2486        db.record_verifier_result(
2487            "pre-v37-run",
2488            "human",
2489            "user_feedback:legacy-review",
2490            "passed",
2491            0.8,
2492            Some("approved before migration"),
2493            Some("turn"),
2494            None,
2495        )
2496        .unwrap();
2497        {
2498            let conn = db.lock_conn();
2499            conn.execute_batch(
2500                "DROP TABLE experience_retrieval_logs;
2501                 DROP TABLE experience_feedback;
2502                 DROP TABLE outcome_envelopes;",
2503            )
2504            .unwrap();
2505            set_schema_version(&conn, 36).unwrap();
2506            apply_schema_migrations(&conn).unwrap();
2507        }
2508
2509        let detail = db
2510            .get_experience_run_detail("pre-v37-run")
2511            .unwrap()
2512            .unwrap();
2513        assert_eq!(detail.outcomes.len(), 1);
2514        assert_eq!(detail.outcomes[0].source_kind, "human");
2515        assert_eq!(detail.feedback.len(), 1);
2516        assert_eq!(detail.feedback[0].actor, "legacy");
2517        let version = {
2518            let conn = db.lock_conn();
2519            get_schema_version(&conn).unwrap()
2520        };
2521        assert_eq!(version, SCHEMA_VERSION_CURRENT);
2522        cleanup(&dir);
2523    }
2524
2525    #[test]
2526    fn migration_40_creates_comparative_reflection_and_shadow_schema() {
2527        let (db, dir) = test_db();
2528        db.register_skill_version("legacy-reflection", 1, "deploy", "built-in")
2529            .unwrap();
2530        for (run_id, verdict) in [("legacy-p2-fail", "failed"), ("legacy-p2-pass", "passed")] {
2531            db.start_experience_run(
2532                run_id,
2533                None,
2534                82,
2535                "web",
2536                "interactive",
2537                "deploy service",
2538                Some("os=linux"),
2539            )
2540            .unwrap();
2541            db.log_skill_activation("legacy-reflection", 82).unwrap();
2542            db.record_verifier_result(
2543                run_id,
2544                "deterministic",
2545                "health",
2546                verdict,
2547                1.0,
2548                Some(verdict),
2549                None,
2550                None,
2551            )
2552            .unwrap();
2553        }
2554        {
2555            let conn = db.lock_conn();
2556            conn.execute_batch(
2557                "DROP TABLE shadow_observations;
2558                 DROP TABLE shadow_evaluations;
2559                 DROP TABLE skill_candidates;
2560                 DROP TABLE learning_journal_events;
2561                 DROP TABLE learning_claims;
2562                 DROP TABLE experience_comparisons;",
2563            )
2564            .unwrap();
2565            set_schema_version(&conn, 39).unwrap();
2566            apply_schema_migrations(&conn).unwrap();
2567        }
2568        let conn = db.lock_conn();
2569        for table in [
2570            "experience_comparisons",
2571            "learning_claims",
2572            "skill_candidates",
2573            "shadow_observations",
2574            "shadow_evaluations",
2575            "learning_journal_events",
2576        ] {
2577            let exists: i64 = conn
2578                .query_row(
2579                    "SELECT COUNT(*) FROM sqlite_master
2580                     WHERE type='table' AND name=?1",
2581                    params![table],
2582                    |row| row.get(0),
2583                )
2584                .unwrap();
2585            assert_eq!(exists, 1, "missing migration table {table}");
2586        }
2587        assert!(table_has_column(&conn, "skill_governance_policy", "shadow_min_samples").unwrap());
2588        assert_eq!(get_schema_version(&conn).unwrap(), SCHEMA_VERSION_CURRENT);
2589        drop(conn);
2590        assert_eq!(
2591            db.get_experience_comparisons(Some(82), 10).unwrap().len(),
2592            1
2593        );
2594        assert_eq!(db.get_learning_claims(Some(82), 10).unwrap().len(), 1);
2595        cleanup(&dir);
2596    }
2597
2598    #[test]
2599    fn task_signature_v1_is_deterministic_and_multilingual() {
2600        let first = derive_task_signature("Fix Rust parser bug and run tests", "interactive");
2601        let repeated = derive_task_signature("Fix Rust parser bug and run tests", "interactive");
2602        assert_eq!(first, repeated);
2603        assert_eq!(first.task_type, "software_development");
2604        assert_eq!(first.task_family, "debugging");
2605        assert!(first.capability_tags.contains(&"coding".to_string()));
2606        assert!(first.capability_tags.contains(&"verification".to_string()));
2607
2608        let chinese = derive_task_signature("调研长周期智能体的最新资料", "interactive");
2609        assert_eq!(chinese.task_type, "information_retrieval");
2610        assert_eq!(chinese.task_family, "research");
2611        assert_ne!(first.signature_hash, chinese.signature_hash);
2612    }
2613
2614    #[test]
2615    fn migration_38_backfills_task_signatures() {
2616        let (db, dir) = test_db();
2617        db.start_experience_run(
2618            "pre-v38-run",
2619            None,
2620            62,
2621            "web",
2622            "interactive",
2623            "debug Rust build failure",
2624            None,
2625        )
2626        .unwrap();
2627        {
2628            let conn = db.lock_conn();
2629            conn.execute(
2630                "UPDATE experience_runs SET task_type='general',
2631                    task_family='general_assistance', capability_tags_json='[]',
2632                    task_signature_hash='' WHERE run_id='pre-v38-run'",
2633                [],
2634            )
2635            .unwrap();
2636            set_schema_version(&conn, 37).unwrap();
2637            apply_schema_migrations(&conn).unwrap();
2638        }
2639        let run = db
2640            .get_experience_run_detail("pre-v38-run")
2641            .unwrap()
2642            .unwrap()
2643            .run;
2644        assert_eq!(run.task_signature.task_family, "debugging");
2645        assert!(!run.task_signature.signature_hash.is_empty());
2646        cleanup(&dir);
2647    }
2648}