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#[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 #[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 #[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 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 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 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}