1use super::*;
2
3#[derive(Debug, Clone, serde::Serialize)]
4pub struct SkillActivationRecord {
5 pub skill_name: String,
6 pub skill_version: Option<i64>,
7 pub activated_at: String,
8}
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct SkillLifecycleRecord {
12 pub skill_name: String,
13 pub state: String,
14 pub active_version: i64,
15 pub previous_trusted_version: Option<i64>,
16 pub source: String,
17 pub created_at: String,
18 pub updated_at: String,
19 pub state_reason: Option<String>,
20}
21
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct SkillLearningSummary {
24 pub skill_name: String,
25 pub state: String,
26 pub active_version: i64,
27 pub total_outcomes: i64,
28 pub passed_outcomes: i64,
29 pub failed_outcomes: i64,
30 pub success_rate: f64,
31 pub utility_lower_bound: f64,
32 pub last_outcome_at: Option<String>,
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct SkillTaskUtilitySummary {
37 pub skill_name: String,
38 pub skill_version: i64,
39 pub task_type: String,
40 pub task_family: String,
41 pub total_outcomes: i64,
42 pub passed_outcomes: i64,
43 pub failed_outcomes: i64,
44 pub success_rate: f64,
45 pub utility_lower_bound: f64,
46}
47
48#[derive(Debug, Clone, serde::Serialize)]
49pub struct SkillFailurePattern {
50 pub pattern_id: String,
51 pub skill_name: String,
52 pub skill_version: i64,
53 pub task_type: String,
54 pub task_family: String,
55 pub environment_fingerprint: Option<String>,
56 pub tool_name: Option<String>,
57 pub error_category: String,
58 pub failure_count: i64,
59 pub recovery_successes: i64,
60 pub state: String,
61 pub cooldown_until: Option<String>,
62 pub last_evidence: Option<String>,
63 pub updated_at: String,
64}
65
66#[derive(Debug, Clone, serde::Serialize)]
67pub struct LearningClaim {
68 pub claim_id: String,
69 pub comparison_id: String,
70 pub skill_name: String,
71 pub base_version: i64,
72 pub claim_version: i64,
73 pub statement: String,
74 pub applicability: serde_json::Value,
75 pub confidence: f64,
76 pub evidence: serde_json::Value,
77 pub counterexamples: serde_json::Value,
78 pub status: String,
79 pub supersedes_claim_id: Option<String>,
80 pub created_at: String,
81 pub updated_at: String,
82}
83
84#[derive(Debug, Clone, serde::Serialize)]
85pub struct SkillCandidate {
86 pub candidate_id: String,
87 pub claim_id: String,
88 pub skill_name: String,
89 pub base_version: i64,
90 pub candidate_version: i64,
91 pub content: String,
92 pub content_hash: String,
93 pub status: String,
94 pub created_at: String,
95 pub updated_at: String,
96}
97
98#[derive(Debug, Clone, serde::Serialize)]
99pub struct ShadowEvaluation {
100 pub evaluation_id: String,
101 pub candidate_id: String,
102 pub sample_count: i64,
103 pub baseline_passed: i64,
104 pub candidate_passed: i64,
105 pub baseline_utility_lower_bound: f64,
106 pub candidate_utility_lower_bound: f64,
107 pub baseline_cost_usd: f64,
108 pub candidate_cost_usd: f64,
109 pub baseline_duration_ms: i64,
110 pub candidate_duration_ms: i64,
111 pub regression_count: i64,
112 pub verdict: String,
113 pub reason: String,
114 pub updated_at: String,
115}
116
117#[derive(Debug, Clone, serde::Serialize)]
118pub struct LearningJournalEvent {
119 pub id: i64,
120 pub event_type: String,
121 pub entity_type: String,
122 pub entity_id: String,
123 pub summary: String,
124 pub evidence: serde_json::Value,
125 pub undo_action: Option<String>,
126 pub created_at: String,
127}
128
129#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
130#[serde(default)]
131pub struct SkillGovernancePolicy {
132 pub candidate_failures_to_degrade: i64,
133 pub trial_min_outcomes: i64,
134 pub trial_promote_rate: f64,
135 pub trial_degrade_rate: f64,
136 pub trusted_min_outcomes: i64,
137 pub trusted_degrade_rate: f64,
138 pub utility_confidence_z: f64,
139 pub trial_promote_utility_lower_bound: f64,
140 pub failure_pattern_min_failures: i64,
141 pub failure_pattern_cooldown_hours: i64,
142 pub failure_pattern_recovery_successes: i64,
143 pub shadow_min_samples: i64,
144 pub shadow_promote_utility_margin: f64,
145 pub shadow_max_cost_ratio: f64,
146 pub shadow_max_regressions: i64,
147}
148
149impl Default for SkillGovernancePolicy {
150 fn default() -> Self {
151 Self {
152 candidate_failures_to_degrade: 2,
153 trial_min_outcomes: 3,
154 trial_promote_rate: 0.8,
155 trial_degrade_rate: 0.5,
156 trusted_min_outcomes: 5,
157 trusted_degrade_rate: 0.6,
158 utility_confidence_z: 1.96,
159 trial_promote_utility_lower_bound: 0.4,
160 failure_pattern_min_failures: 2,
161 failure_pattern_cooldown_hours: 24,
162 failure_pattern_recovery_successes: 2,
163 shadow_min_samples: 3,
164 shadow_promote_utility_margin: 0.05,
165 shadow_max_cost_ratio: 1.2,
166 shadow_max_regressions: 0,
167 }
168 }
169}
170
171#[derive(Debug, Clone, serde::Serialize)]
172pub struct SkillApplicability {
173 pub environment_fingerprint: Option<String>,
174 pub task_type: Option<String>,
175 pub task_family: Option<String>,
176 pub matching_outcomes: i64,
177 pub passed_outcomes: i64,
178 pub failed_outcomes: i64,
179 pub success_rate: Option<f64>,
180 pub utility_lower_bound: Option<f64>,
181 pub allowed: bool,
182 pub reason: String,
183}
184
185pub(crate) fn validate_skill_governance_policy(
186 policy: &SkillGovernancePolicy,
187) -> Result<(), MicroClawError> {
188 if policy.candidate_failures_to_degrade < 1
189 || policy.trial_min_outcomes < 2
190 || policy.trusted_min_outcomes < policy.trial_min_outcomes
191 || !(0.0..=1.0).contains(&policy.trial_promote_rate)
192 || !(0.0..=1.0).contains(&policy.trial_degrade_rate)
193 || !(0.0..=1.0).contains(&policy.trusted_degrade_rate)
194 || !(0.0..=1.0).contains(&policy.trial_promote_utility_lower_bound)
195 || !policy.utility_confidence_z.is_finite()
196 || !(0.0..=5.0).contains(&policy.utility_confidence_z)
197 || policy.failure_pattern_min_failures < 1
198 || policy.failure_pattern_cooldown_hours < 1
199 || policy.failure_pattern_recovery_successes < 1
200 || policy.shadow_min_samples < 1
201 || !policy.shadow_promote_utility_margin.is_finite()
202 || !(0.0..=1.0).contains(&policy.shadow_promote_utility_margin)
203 || !policy.shadow_max_cost_ratio.is_finite()
204 || policy.shadow_max_cost_ratio < 1.0
205 || policy.shadow_max_regressions < 0
206 || policy.trial_degrade_rate >= policy.trial_promote_rate
207 {
208 return Err(MicroClawError::ToolExecution(
209 "invalid skill governance policy: counts, rates, confidence, or utility lower bound"
210 .into(),
211 ));
212 }
213 Ok(())
214}
215
216pub(crate) fn sync_skill_outcomes_for_run(
217 tx: &Transaction<'_>,
218 run_id: &str,
219) -> Result<(), MicroClawError> {
220 let now = chrono::Utc::now().to_rfc3339();
221 let mut verifier_stmt = tx.prepare(
222 "SELECT verifier_type, verifier_name, verdict, confidence, evidence, valid_until
223 FROM verifier_results
224 WHERE run_id=?1 AND (valid_until IS NULL OR valid_until > ?2)",
225 )?;
226 let verifier_rows = verifier_stmt.query_map(params![run_id, now], |row| {
227 Ok((
228 row.get::<_, String>(0)?,
229 row.get::<_, String>(1)?,
230 row.get::<_, String>(2)?,
231 row.get::<_, f64>(3)?,
232 row.get::<_, Option<String>>(4)?,
233 row.get::<_, Option<String>>(5)?,
234 ))
235 })?;
236 let mut strongest: Vec<VerifierCandidate> = Vec::new();
237 let mut strongest_priority = -1;
238 for row in verifier_rows {
239 let candidate = row?;
240 let candidate_priority = verifier_priority(&candidate.0);
241 if candidate_priority > strongest_priority {
242 strongest.clear();
243 strongest.push(candidate);
244 strongest_priority = candidate_priority;
245 } else if candidate_priority == strongest_priority {
246 strongest.push(candidate);
247 }
248 }
249 drop(verifier_stmt);
250 if strongest.is_empty() {
251 tx.execute(
252 "DELETE FROM skill_outcomes WHERE run_id=?1",
253 params![run_id],
254 )?;
255 return Ok(());
256 }
257 let verifier_type = strongest[0].0.clone();
258 let (verifier_name, verdict, confidence, evidence) = if verifier_type == "human"
259 && strongest.len() > 1
260 {
261 let passed_weight: f64 = strongest
262 .iter()
263 .filter(|result| result.2 == "passed")
264 .map(|result| result.3)
265 .sum();
266 let failed_weight: f64 = strongest
267 .iter()
268 .filter(|result| result.2 == "failed")
269 .map(|result| result.3)
270 .sum();
271 let total_weight = passed_weight + failed_weight;
272 let verdict = if passed_weight > failed_weight {
273 "passed"
274 } else {
275 "failed"
276 };
277 let confidence = if total_weight > 0.0 {
278 passed_weight.max(failed_weight) / total_weight
279 } else {
280 0.0
281 };
282 (
283 "human_consensus".to_string(),
284 verdict.to_string(),
285 confidence,
286 Some(format!(
287 "{} active human feedback item(s): passed_weight={passed_weight:.3}, failed_weight={failed_weight:.3}",
288 strongest.len()
289 )),
290 )
291 } else {
292 let selected = strongest
295 .iter()
296 .filter(|result| result.2 == "failed")
297 .max_by(|left, right| left.3.total_cmp(&right.3))
298 .or_else(|| {
299 strongest
300 .iter()
301 .max_by(|left, right| left.3.total_cmp(&right.3))
302 })
303 .expect("strongest verifier group is non-empty");
304 (
305 selected.1.clone(),
306 selected.2.clone(),
307 selected.3,
308 selected.4.clone(),
309 )
310 };
311 let valid_until = strongest.iter().filter_map(|result| result.5.clone()).min();
312
313 let mut activation_stmt = tx.prepare(
314 "SELECT DISTINCT skill_name, COALESCE(skill_version, 0)
315 FROM skill_activation_logs
316 WHERE experience_run_id=?1",
317 )?;
318 let activation_rows = activation_stmt.query_map(params![run_id], |row| {
319 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
320 })?;
321 let activations = activation_rows.collect::<Result<Vec<_>, _>>()?;
322 drop(activation_stmt);
323 let attribution_confidence = if activations.is_empty() {
324 0.0
325 } else {
326 1.0 / activations.len() as f64
327 };
328 for (skill_name, mut skill_version) in activations {
329 if skill_version == 0 {
330 skill_version = tx
331 .query_row(
332 "SELECT active_version FROM skill_lifecycle WHERE skill_name=?1",
333 params![skill_name],
334 |row| row.get(0),
335 )
336 .optional()?
337 .unwrap_or(0);
338 }
339 if skill_version == 0 {
340 continue;
341 }
342 tx.execute(
343 "INSERT INTO skill_outcomes(
344 skill_name, skill_version, run_id, verdict, verifier_type,
345 verifier_name, confidence, evidence, valid_until,
346 attribution_confidence, created_at, updated_at
347 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
348 ON CONFLICT(skill_name, run_id) DO UPDATE SET
349 skill_version=excluded.skill_version,
350 verdict=excluded.verdict,
351 verifier_type=excluded.verifier_type,
352 verifier_name=excluded.verifier_name,
353 confidence=excluded.confidence,
354 evidence=excluded.evidence,
355 valid_until=excluded.valid_until,
356 attribution_confidence=excluded.attribution_confidence,
357 updated_at=excluded.updated_at",
358 params![
359 skill_name,
360 skill_version,
361 run_id,
362 verdict,
363 verifier_type,
364 verifier_name,
365 confidence,
366 evidence,
367 valid_until,
368 attribution_confidence,
369 now
370 ],
371 )?;
372 if verifier_can_govern_skill(&verifier_type) && attribution_confidence >= 0.999 {
375 evaluate_skill_lifecycle(tx, &skill_name, skill_version)?;
376 }
377 }
378 Ok(())
379}
380
381pub(crate) fn classify_failure_category(evidence: Option<&str>) -> &'static str {
382 let value = evidence.unwrap_or("").to_ascii_lowercase();
383 if value.contains("timeout") || value.contains("timed out") {
384 "timeout"
385 } else if value.contains("permission")
386 || value.contains("denied")
387 || value.contains("forbidden")
388 {
389 "permission"
390 } else if value.contains("not found") || value.contains("missing") {
391 "dependency"
392 } else if value.contains("network") || value.contains("connect") || value.contains("dns") {
393 "network"
394 } else if value.contains("invalid") || value.contains("parse") || value.contains("syntax") {
395 "invalid_input"
396 } else if value.contains("cancel") {
397 "cancelled"
398 } else {
399 "execution"
400 }
401}
402
403pub(crate) fn sync_skill_failure_patterns_for_run(
404 tx: &Transaction<'_>,
405 run_id: &str,
406) -> Result<(), MicroClawError> {
407 let prior_patterns = {
410 let mut stmt =
411 tx.prepare("SELECT pattern_id FROM skill_failure_pattern_evidence WHERE run_id=?1")?;
412 let rows = stmt
413 .query_map(params![run_id], |row| row.get::<_, String>(0))?
414 .collect::<Result<Vec<_>, _>>()?;
415 rows
416 };
417 tx.execute(
418 "DELETE FROM skill_failure_pattern_evidence WHERE run_id=?1",
419 params![run_id],
420 )?;
421 for pattern_id in prior_patterns {
422 let (failures, recoveries): (i64, i64) = tx.query_row(
423 "SELECT
424 COALESCE(SUM(CASE WHEN verdict='failed' THEN 1 ELSE 0 END),0),
425 COALESCE(SUM(CASE WHEN verdict='passed' THEN 1 ELSE 0 END),0)
426 FROM skill_failure_pattern_evidence WHERE pattern_id=?1",
427 params![pattern_id],
428 |row| Ok((row.get(0)?, row.get(1)?)),
429 )?;
430 if failures == 0 {
431 tx.execute(
432 "DELETE FROM skill_failure_patterns WHERE pattern_id=?1",
433 params![pattern_id],
434 )?;
435 } else {
436 tx.execute(
437 "UPDATE skill_failure_patterns
438 SET failure_count=?2, recovery_successes=?3,
439 state=CASE WHEN ?2 < (
440 SELECT failure_pattern_min_failures
441 FROM skill_governance_policy WHERE singleton_id=1
442 ) THEN 'observed' ELSE state END,
443 cooldown_until=CASE WHEN ?2 < (
444 SELECT failure_pattern_min_failures
445 FROM skill_governance_policy WHERE singleton_id=1
446 ) THEN NULL ELSE cooldown_until END,
447 updated_at=?4
448 WHERE pattern_id=?1",
449 params![
450 pattern_id,
451 failures,
452 recoveries,
453 chrono::Utc::now().to_rfc3339()
454 ],
455 )?;
456 }
457 }
458 let context: Option<(String, String, Option<String>)> = tx
459 .query_row(
460 "SELECT task_type, task_family, environment_fingerprint
461 FROM experience_runs WHERE run_id=?1",
462 params![run_id],
463 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
464 )
465 .optional()?;
466 let Some((task_type, task_family, environment)) = context else {
467 return Ok(());
468 };
469 let policy: (i64, i64, i64) = tx.query_row(
470 "SELECT failure_pattern_min_failures, failure_pattern_cooldown_hours,
471 failure_pattern_recovery_successes
472 FROM skill_governance_policy WHERE singleton_id=1",
473 [],
474 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
475 )?;
476 let skills = {
477 let mut stmt = tx.prepare(
478 "SELECT skill_name, skill_version, verdict, evidence
479 FROM skill_outcomes
480 WHERE run_id=?1 AND attribution_confidence >= 0.999
481 AND verifier_type IN ('deterministic','environmental','human','rule_based')",
482 )?;
483 let rows = stmt
484 .query_map(params![run_id], |row| {
485 Ok((
486 row.get::<_, String>(0)?,
487 row.get::<_, i64>(1)?,
488 row.get::<_, String>(2)?,
489 row.get::<_, Option<String>>(3)?,
490 ))
491 })?
492 .collect::<Result<Vec<_>, _>>()?;
493 rows
494 };
495 let failed_tools = {
496 let mut stmt = tx.prepare(
497 "SELECT json_extract(payload_json, '$.tool_name'), evidence
498 FROM outcome_envelopes
499 WHERE run_id=?1 AND verdict='failed' AND scope='tool_result'",
500 )?;
501 let rows = stmt
502 .query_map(params![run_id], |row| {
503 Ok((
504 row.get::<_, Option<String>>(0)?,
505 row.get::<_, Option<String>>(1)?,
506 ))
507 })?
508 .collect::<Result<Vec<_>, _>>()?;
509 rows
510 };
511 let now = chrono::Utc::now();
512 for (skill_name, skill_version, verdict, skill_evidence) in skills {
513 if verdict == "failed" {
514 let evidence_items = if failed_tools.is_empty() {
515 vec![(None, skill_evidence.clone())]
516 } else {
517 failed_tools.clone()
518 };
519 for (tool_name, evidence) in evidence_items {
520 let category = classify_failure_category(evidence.as_deref());
521 let canonical = format!(
522 "{skill_name}\n{skill_version}\n{task_family}\n{}\n{}\n{category}",
523 environment.as_deref().unwrap_or(""),
524 tool_name.as_deref().unwrap_or("")
525 );
526 use sha2::{Digest, Sha256};
527 let pattern_id = to_hex(&Sha256::digest(canonical.as_bytes()));
528 let cooldown_until = (now + chrono::Duration::hours(policy.1)).to_rfc3339();
529 tx.execute(
530 "INSERT INTO skill_failure_patterns(
531 pattern_id, skill_name, skill_version, task_type, task_family,
532 environment_fingerprint, tool_name, error_category,
533 failure_count, recovery_successes, state, cooldown_until,
534 last_evidence, first_seen_at, updated_at
535 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,0,0,'observed',NULL,?9,?10,?10)
536 ON CONFLICT(pattern_id) DO UPDATE SET
537 last_evidence=excluded.last_evidence,
538 updated_at=excluded.updated_at",
539 params![
540 pattern_id,
541 skill_name,
542 skill_version,
543 task_type,
544 task_family,
545 environment,
546 tool_name,
547 category,
548 evidence,
549 now.to_rfc3339()
550 ],
551 )?;
552 let inserted = tx.execute(
553 "INSERT OR IGNORE INTO skill_failure_pattern_evidence(
554 pattern_id, run_id, verdict, created_at
555 ) VALUES (?1,?2,'failed',?3)",
556 params![pattern_id, run_id, now.to_rfc3339()],
557 )?;
558 if inserted > 0 {
559 tx.execute(
560 "UPDATE skill_failure_patterns
561 SET failure_count=failure_count+1,
562 recovery_successes=0,
563 state=CASE WHEN failure_count+1 >= ?2
564 THEN 'active' ELSE 'observed' END,
565 cooldown_until=CASE WHEN failure_count+1 >= ?2
566 THEN ?3 ELSE cooldown_until END,
567 updated_at=?4 WHERE pattern_id=?1",
568 params![pattern_id, policy.0, cooldown_until, now.to_rfc3339()],
569 )?;
570 if policy.0 <= {
571 tx.query_row(
572 "SELECT failure_count FROM skill_failure_patterns
573 WHERE pattern_id=?1",
574 params![pattern_id],
575 |row| row.get::<_, i64>(0),
576 )?
577 } {
578 let current: Option<(String, Option<i64>)> = tx
579 .query_row(
580 "SELECT state, previous_trusted_version FROM skill_lifecycle
581 WHERE skill_name=?1 AND active_version=?2",
582 params![skill_name, skill_version],
583 |row| Ok((row.get(0)?, row.get(1)?)),
584 )
585 .optional()?;
586 if current
587 .as_ref()
588 .is_some_and(|(state, _)| state != "degraded" && state != "archived")
589 {
590 let (from_state, previous_trusted) =
591 current.unwrap_or_else(|| (String::new(), None));
592 let reason = format!(
593 "active failure pattern: task_family={task_family}, error_category={category}"
594 );
595 if from_state == "trusted" && previous_trusted.is_some() {
596 let restored = previous_trusted.unwrap_or_default();
597 let rollback_reason = format!(
598 "automatic rollback from v{skill_version} to v{restored}: {reason}"
599 );
600 tx.execute(
601 "UPDATE skill_lifecycle SET state='trusted',
602 active_version=?2, previous_trusted_version=NULL,
603 state_reason=?3, updated_at=?4 WHERE skill_name=?1",
604 params![
605 skill_name,
606 restored,
607 rollback_reason,
608 now.to_rfc3339()
609 ],
610 )?;
611 tx.execute(
612 "UPDATE skill_candidates
613 SET status='rolled_back', updated_at=?3
614 WHERE skill_name=?1 AND candidate_version=?2
615 AND status='promoted'",
616 params![skill_name, skill_version, now.to_rfc3339()],
617 )?;
618 tx.execute(
619 "INSERT INTO skill_lifecycle_events(
620 skill_name, from_state, to_state, version,
621 reason, created_at
622 ) VALUES (?1,'trusted','trusted',?2,?3,?4)",
623 params![
624 skill_name,
625 restored,
626 rollback_reason,
627 now.to_rfc3339()
628 ],
629 )?;
630 tx.execute(
631 "INSERT INTO learning_journal_events(
632 event_type, entity_type, entity_id, summary,
633 evidence_json, created_at
634 ) VALUES ('automatic_rollback','skill',?1,?2,?3,?4)",
635 params![
636 skill_name,
637 rollback_reason,
638 serde_json::json!({
639 "failed_version": skill_version,
640 "restored_version": restored,
641 "trigger": reason
642 })
643 .to_string(),
644 now.to_rfc3339()
645 ],
646 )?;
647 } else {
648 tx.execute(
649 "UPDATE skill_lifecycle SET state='degraded',
650 previous_trusted_version=CASE WHEN state='trusted'
651 THEN active_version ELSE previous_trusted_version END,
652 state_reason=?3, updated_at=?4
653 WHERE skill_name=?1 AND active_version=?2",
654 params![skill_name, skill_version, reason, now.to_rfc3339()],
655 )?;
656 tx.execute(
657 "INSERT INTO skill_lifecycle_events(
658 skill_name, from_state, to_state, version,
659 reason, created_at
660 ) VALUES (?1,?2,'degraded',?3,?4,?5)",
661 params![
662 skill_name,
663 from_state,
664 skill_version,
665 reason,
666 now.to_rfc3339()
667 ],
668 )?;
669 }
670 }
671 }
672 }
673 }
674 } else if verdict == "passed" {
675 let patterns = {
676 let mut stmt = tx.prepare(
677 "SELECT pattern_id FROM skill_failure_patterns
678 WHERE skill_name=?1 AND skill_version=?2 AND task_family=?3
679 AND (environment_fingerprint IS NULL OR environment_fingerprint=?4)
680 AND state IN ('active','cooldown','trial')",
681 )?;
682 let rows = stmt
683 .query_map(
684 params![skill_name, skill_version, task_family, environment],
685 |row| row.get::<_, String>(0),
686 )?
687 .collect::<Result<Vec<_>, _>>()?;
688 rows
689 };
690 for pattern_id in patterns {
691 let inserted = tx.execute(
692 "INSERT OR IGNORE INTO skill_failure_pattern_evidence(
693 pattern_id, run_id, verdict, created_at
694 ) VALUES (?1,?2,'passed',?3)",
695 params![pattern_id, run_id, now.to_rfc3339()],
696 )?;
697 if inserted > 0 {
698 tx.execute(
699 "UPDATE skill_failure_patterns
700 SET recovery_successes=recovery_successes+1,
701 state=CASE
702 WHEN recovery_successes+1 >= ?2 THEN 'resolved'
703 WHEN cooldown_until <= ?3 THEN 'trial'
704 ELSE 'cooldown' END,
705 updated_at=?3 WHERE pattern_id=?1",
706 params![pattern_id, policy.2, now.to_rfc3339()],
707 )?;
708 }
709 }
710 let unresolved: i64 = tx.query_row(
711 "SELECT COUNT(*) FROM skill_failure_patterns
712 WHERE skill_name=?1 AND skill_version=?2
713 AND state IN ('active','cooldown','trial')",
714 params![skill_name, skill_version],
715 |row| row.get(0),
716 )?;
717 if unresolved == 0 {
718 let changed = tx.execute(
719 "UPDATE skill_lifecycle SET state='trial',
720 state_reason='failure patterns resolved; recovery trial started',
721 updated_at=?3
722 WHERE skill_name=?1 AND active_version=?2 AND state='degraded'",
723 params![skill_name, skill_version, now.to_rfc3339()],
724 )?;
725 if changed > 0 {
726 tx.execute(
727 "INSERT INTO skill_lifecycle_events(
728 skill_name, from_state, to_state, version, reason, created_at
729 ) VALUES (?1,'degraded','trial',?2,
730 'failure patterns resolved; recovery trial started',?3)",
731 params![skill_name, skill_version, now.to_rfc3339()],
732 )?;
733 }
734 }
735 }
736 }
737 Ok(())
738}
739
740pub(crate) fn map_shadow_evaluation(row: &rusqlite::Row<'_>) -> rusqlite::Result<ShadowEvaluation> {
741 Ok(ShadowEvaluation {
742 evaluation_id: row.get(0)?,
743 candidate_id: row.get(1)?,
744 sample_count: row.get(2)?,
745 baseline_passed: row.get(3)?,
746 candidate_passed: row.get(4)?,
747 baseline_utility_lower_bound: row.get(5)?,
748 candidate_utility_lower_bound: row.get(6)?,
749 baseline_cost_usd: row.get(7)?,
750 candidate_cost_usd: row.get(8)?,
751 baseline_duration_ms: row.get(9)?,
752 candidate_duration_ms: row.get(10)?,
753 regression_count: row.get(11)?,
754 verdict: row.get(12)?,
755 reason: row.get(13)?,
756 updated_at: row.get(14)?,
757 })
758}
759
760pub(crate) fn recompute_shadow_evaluation(
761 tx: &Transaction<'_>,
762 candidate_id: &str,
763) -> Result<ShadowEvaluation, MicroClawError> {
764 let (
765 samples,
766 baseline_passed,
767 candidate_passed,
768 baseline_cost,
769 candidate_cost,
770 baseline_duration,
771 candidate_duration,
772 regressions,
773 ): (i64, i64, i64, f64, f64, i64, i64, i64) = tx.query_row(
774 "SELECT COUNT(*),
775 COALESCE(SUM(CASE WHEN b.verdict='passed' THEN 1 ELSE 0 END),0),
776 COALESCE(SUM(CASE WHEN c.verdict='passed' THEN 1 ELSE 0 END),0),
777 COALESCE(SUM(b.cost_usd),0), COALESCE(SUM(c.cost_usd),0),
778 COALESCE(SUM(b.duration_ms),0), COALESCE(SUM(c.duration_ms),0),
779 COALESCE(SUM(CASE WHEN b.verdict='passed'
780 AND c.verdict='failed' THEN 1 ELSE 0 END),0)
781 FROM shadow_observations b
782 JOIN shadow_observations c
783 ON c.candidate_id=b.candidate_id AND c.pair_key=b.pair_key
784 AND c.arm='candidate'
785 WHERE b.candidate_id=?1 AND b.arm='baseline'",
786 params![candidate_id],
787 |row| {
788 Ok((
789 row.get(0)?,
790 row.get(1)?,
791 row.get(2)?,
792 row.get(3)?,
793 row.get(4)?,
794 row.get(5)?,
795 row.get(6)?,
796 row.get(7)?,
797 ))
798 },
799 )?;
800 let policy = tx.query_row(
801 "SELECT shadow_min_samples, shadow_promote_utility_margin,
802 shadow_max_cost_ratio, shadow_max_regressions,
803 utility_confidence_z
804 FROM skill_governance_policy WHERE singleton_id=1",
805 [],
806 |row| {
807 Ok((
808 row.get::<_, i64>(0)?,
809 row.get::<_, f64>(1)?,
810 row.get::<_, f64>(2)?,
811 row.get::<_, i64>(3)?,
812 row.get::<_, f64>(4)?,
813 ))
814 },
815 )?;
816 let baseline_utility = wilson_lower_bound(baseline_passed, samples, policy.4);
817 let candidate_utility = wilson_lower_bound(candidate_passed, samples, policy.4);
818 let cost_ratio = if baseline_cost <= f64::EPSILON {
819 if candidate_cost <= f64::EPSILON {
820 1.0
821 } else {
822 f64::INFINITY
823 }
824 } else {
825 candidate_cost / baseline_cost
826 };
827 let (verdict, reason) = if samples < policy.0 {
828 (
829 "pending",
830 format!("need {} paired samples; have {samples}", policy.0),
831 )
832 } else if regressions > policy.3 {
833 (
834 "failed",
835 format!("regressions {regressions} exceed {}", policy.3),
836 )
837 } else if candidate_utility + f64::EPSILON < baseline_utility + policy.1 {
838 (
839 "failed",
840 format!(
841 "candidate utility floor {candidate_utility:.3} does not exceed baseline {baseline_utility:.3} by {:.3}",
842 policy.1
843 ),
844 )
845 } else if cost_ratio > policy.2 {
846 (
847 "failed",
848 format!(
849 "candidate cost ratio {cost_ratio:.3} exceeds {:.3}",
850 policy.2
851 ),
852 )
853 } else {
854 (
855 "passed",
856 format!(
857 "paired shadow gate passed: utility {:.3}→{:.3}, cost ratio {:.3}, regressions {regressions}",
858 baseline_utility, candidate_utility, cost_ratio
859 ),
860 )
861 };
862 use sha2::{Digest, Sha256};
863 let evaluation_id = to_hex(&Sha256::digest(candidate_id.as_bytes()));
864 let previous: Option<String> = tx
865 .query_row(
866 "SELECT verdict FROM shadow_evaluations WHERE candidate_id=?1",
867 params![candidate_id],
868 |row| row.get(0),
869 )
870 .optional()?;
871 let now = chrono::Utc::now().to_rfc3339();
872 tx.execute(
873 "INSERT INTO shadow_evaluations(
874 evaluation_id, candidate_id, sample_count, baseline_passed,
875 candidate_passed, baseline_utility_lower_bound,
876 candidate_utility_lower_bound, baseline_cost_usd,
877 candidate_cost_usd, baseline_duration_ms, candidate_duration_ms,
878 regression_count, verdict, reason, updated_at
879 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)
880 ON CONFLICT(candidate_id) DO UPDATE SET
881 sample_count=excluded.sample_count,
882 baseline_passed=excluded.baseline_passed,
883 candidate_passed=excluded.candidate_passed,
884 baseline_utility_lower_bound=excluded.baseline_utility_lower_bound,
885 candidate_utility_lower_bound=excluded.candidate_utility_lower_bound,
886 baseline_cost_usd=excluded.baseline_cost_usd,
887 candidate_cost_usd=excluded.candidate_cost_usd,
888 baseline_duration_ms=excluded.baseline_duration_ms,
889 candidate_duration_ms=excluded.candidate_duration_ms,
890 regression_count=excluded.regression_count,
891 verdict=excluded.verdict, reason=excluded.reason,
892 updated_at=excluded.updated_at",
893 params![
894 evaluation_id,
895 candidate_id,
896 samples,
897 baseline_passed,
898 candidate_passed,
899 baseline_utility,
900 candidate_utility,
901 baseline_cost,
902 candidate_cost,
903 baseline_duration,
904 candidate_duration,
905 regressions,
906 verdict,
907 reason,
908 now
909 ],
910 )?;
911 tx.execute(
912 "UPDATE skill_candidates SET status=?2, updated_at=?3
913 WHERE candidate_id=?1 AND status<>'promoted'",
914 params![
915 candidate_id,
916 match verdict {
917 "passed" => "shadow_passed",
918 "failed" => "shadow_failed",
919 _ => "shadow",
920 },
921 now
922 ],
923 )?;
924 if previous.as_deref() != Some(verdict) {
925 tx.execute(
926 "INSERT INTO learning_journal_events(
927 event_type, entity_type, entity_id, summary, evidence_json,
928 undo_action, created_at
929 ) VALUES ('shadow_evaluated','skill_candidate',?1,?2,?3,
930 CASE WHEN ?4='passed' THEN 'archive_candidate'
931 ELSE NULL END,?5)",
932 params![
933 candidate_id,
934 reason,
935 serde_json::json!({
936 "sample_count": samples,
937 "baseline_utility_lower_bound": baseline_utility,
938 "candidate_utility_lower_bound": candidate_utility,
939 "cost_ratio": cost_ratio,
940 "regression_count": regressions
941 })
942 .to_string(),
943 verdict,
944 now
945 ],
946 )?;
947 }
948 tx.query_row(
949 "SELECT evaluation_id, candidate_id, sample_count,
950 baseline_passed, candidate_passed,
951 baseline_utility_lower_bound, candidate_utility_lower_bound,
952 baseline_cost_usd, candidate_cost_usd,
953 baseline_duration_ms, candidate_duration_ms,
954 regression_count, verdict, reason, updated_at
955 FROM shadow_evaluations WHERE candidate_id=?1",
956 params![candidate_id],
957 map_shadow_evaluation,
958 )
959 .map_err(Into::into)
960}
961
962pub(crate) fn refresh_expired_skill_outcomes(tx: &Transaction<'_>) -> Result<(), MicroClawError> {
963 let now = chrono::Utc::now().to_rfc3339();
964 let mut stmt = tx.prepare(
965 "SELECT DISTINCT run_id, skill_name, skill_version FROM skill_outcomes
966 WHERE valid_until IS NOT NULL AND valid_until <= ?1",
967 )?;
968 let expired = stmt
969 .query_map(params![now], |row| {
970 Ok((
971 row.get::<_, String>(0)?,
972 row.get::<_, String>(1)?,
973 row.get::<_, i64>(2)?,
974 ))
975 })?
976 .collect::<Result<Vec<_>, _>>()?;
977 drop(stmt);
978 for (run_id, _, _) in &expired {
979 sync_skill_outcomes_for_run(tx, run_id)?;
980 sync_skill_failure_patterns_for_run(tx, run_id)?;
981 }
982 let mut affected = expired
983 .into_iter()
984 .map(|(_, skill_name, version)| (skill_name, version))
985 .collect::<Vec<_>>();
986 affected.sort();
987 affected.dedup();
988 for (skill_name, version) in affected {
989 recompute_skill_lifecycle(tx, &skill_name, version, "verifier evidence expired")?;
990 }
991 Ok(())
992}
993
994pub(crate) fn evaluate_skill_lifecycle(
995 tx: &Transaction<'_>,
996 skill_name: &str,
997 version: i64,
998) -> Result<bool, MicroClawError> {
999 let current: Option<String> = tx
1000 .query_row(
1001 "SELECT state FROM skill_lifecycle
1002 WHERE skill_name=?1 AND active_version=?2",
1003 params![skill_name, version],
1004 |row| row.get(0),
1005 )
1006 .optional()?;
1007 let Some(current) = current else {
1008 return Ok(false);
1009 };
1010 if current == "archived" || current == "degraded" {
1011 return Ok(false);
1012 }
1013 let (total, passed, failed): (i64, i64, i64) = tx.query_row(
1014 "SELECT COUNT(*),
1015 COALESCE(SUM(CASE WHEN verdict='passed' THEN 1 ELSE 0 END), 0),
1016 COALESCE(SUM(CASE WHEN verdict='failed' THEN 1 ELSE 0 END), 0)
1017 FROM skill_outcomes
1018 WHERE skill_name=?1 AND skill_version=?2
1019 AND verifier_type IN ('deterministic','environmental','human','rule_based')
1020 AND attribution_confidence >= 0.999
1021 AND (valid_until IS NULL OR valid_until > ?3)",
1022 params![skill_name, version, chrono::Utc::now().to_rfc3339()],
1023 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1024 )?;
1025 let success_rate = if total == 0 {
1026 0.0
1027 } else {
1028 passed as f64 / total as f64
1029 };
1030 let policy = tx.query_row(
1031 "SELECT candidate_failures_to_degrade, trial_min_outcomes,
1032 trial_promote_rate, trial_degrade_rate, trusted_min_outcomes,
1033 trusted_degrade_rate, utility_confidence_z,
1034 trial_promote_utility_lower_bound,
1035 failure_pattern_min_failures, failure_pattern_cooldown_hours,
1036 failure_pattern_recovery_successes, shadow_min_samples,
1037 shadow_promote_utility_margin, shadow_max_cost_ratio,
1038 shadow_max_regressions
1039 FROM skill_governance_policy WHERE singleton_id=1",
1040 [],
1041 |row| {
1042 Ok(SkillGovernancePolicy {
1043 candidate_failures_to_degrade: row.get(0)?,
1044 trial_min_outcomes: row.get(1)?,
1045 trial_promote_rate: row.get(2)?,
1046 trial_degrade_rate: row.get(3)?,
1047 trusted_min_outcomes: row.get(4)?,
1048 trusted_degrade_rate: row.get(5)?,
1049 utility_confidence_z: row.get(6)?,
1050 trial_promote_utility_lower_bound: row.get(7)?,
1051 failure_pattern_min_failures: row.get(8)?,
1052 failure_pattern_cooldown_hours: row.get(9)?,
1053 failure_pattern_recovery_successes: row.get(10)?,
1054 shadow_min_samples: row.get(11)?,
1055 shadow_promote_utility_margin: row.get(12)?,
1056 shadow_max_cost_ratio: row.get(13)?,
1057 shadow_max_regressions: row.get(14)?,
1058 })
1059 },
1060 )?;
1061 let utility_lower_bound = wilson_lower_bound(passed, total, policy.utility_confidence_z);
1062 let transition = match current.as_str() {
1063 "candidate" if failed >= policy.candidate_failures_to_degrade => {
1064 Some(("degraded", "candidate exceeded verified failure threshold"))
1065 }
1066 "candidate" if passed >= 1 => Some(("trial", "first verified success")),
1067 "trial"
1068 if total >= policy.trial_min_outcomes
1069 && success_rate >= policy.trial_promote_rate
1070 && utility_lower_bound >= policy.trial_promote_utility_lower_bound =>
1071 {
1072 Some((
1073 "trusted",
1074 "trial passed raw-rate and risk-adjusted utility thresholds",
1075 ))
1076 }
1077 "trial"
1078 if total >= policy.trial_min_outcomes && success_rate < policy.trial_degrade_rate =>
1079 {
1080 Some(("degraded", "trial failed quality threshold"))
1081 }
1082 "trusted"
1083 if total >= policy.trusted_min_outcomes
1084 && success_rate < policy.trusted_degrade_rate =>
1085 {
1086 Some((
1087 "degraded",
1088 "trusted skill regressed below quality threshold",
1089 ))
1090 }
1091 _ => None,
1092 };
1093 let Some((next, reason)) = transition else {
1094 return Ok(false);
1095 };
1096 let now = chrono::Utc::now().to_rfc3339();
1097 if current == "trusted" && next == "degraded" {
1098 let rollback_version: Option<i64> = tx
1099 .query_row(
1100 "SELECT previous_trusted_version FROM skill_lifecycle
1101 WHERE skill_name=?1 AND active_version=?2",
1102 params![skill_name, version],
1103 |row| row.get(0),
1104 )
1105 .optional()?
1106 .flatten();
1107 if let Some(rollback_version) = rollback_version {
1108 let exists: i64 = tx.query_row(
1109 "SELECT COUNT(*) FROM skill_versions
1110 WHERE skill_name=?1 AND version=?2",
1111 params![skill_name, rollback_version],
1112 |row| row.get(0),
1113 )?;
1114 if exists > 0 {
1115 let rollback_reason =
1116 format!("automatic rollback from v{version} to v{rollback_version}: {reason}");
1117 tx.execute(
1118 "UPDATE skill_lifecycle SET state='trusted', active_version=?2,
1119 previous_trusted_version=NULL, updated_at=?3, state_reason=?4
1120 WHERE skill_name=?1",
1121 params![skill_name, rollback_version, now, rollback_reason],
1122 )?;
1123 tx.execute(
1124 "INSERT INTO skill_lifecycle_events(
1125 skill_name, from_state, to_state, version, reason, created_at
1126 ) VALUES (?1,'trusted','trusted',?2,?3,?4)",
1127 params![skill_name, rollback_version, rollback_reason, now],
1128 )?;
1129 tx.execute(
1130 "UPDATE skill_candidates SET status='rolled_back', updated_at=?3
1131 WHERE skill_name=?1 AND candidate_version=?2
1132 AND status='promoted'",
1133 params![skill_name, version, now],
1134 )?;
1135 tx.execute(
1136 "INSERT INTO learning_journal_events(
1137 event_type, entity_type, entity_id, summary,
1138 evidence_json, undo_action, created_at
1139 ) VALUES ('automatic_rollback','skill',?1,?2,?3,NULL,?4)",
1140 params![
1141 skill_name,
1142 rollback_reason,
1143 serde_json::json!({
1144 "failed_version": version,
1145 "restored_version": rollback_version,
1146 "trigger": reason
1147 })
1148 .to_string(),
1149 now
1150 ],
1151 )?;
1152 return Ok(true);
1153 }
1154 }
1155 }
1156 let previous_trusted = (current == "trusted").then_some(version);
1157 tx.execute(
1158 "UPDATE skill_lifecycle
1159 SET state=?2,
1160 previous_trusted_version=COALESCE(?3, previous_trusted_version),
1161 updated_at=?4, state_reason=?5
1162 WHERE skill_name=?1",
1163 params![skill_name, next, previous_trusted, now, reason],
1164 )?;
1165 tx.execute(
1166 "INSERT INTO skill_lifecycle_events(
1167 skill_name, from_state, to_state, version, reason, created_at
1168 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1169 params![skill_name, current, next, version, reason, now],
1170 )?;
1171 Ok(true)
1172}
1173
1174pub(crate) fn recompute_skill_lifecycle(
1175 tx: &Transaction<'_>,
1176 skill_name: &str,
1177 version: i64,
1178 reason: &str,
1179) -> Result<(), MicroClawError> {
1180 let current: Option<(String, String)> = tx
1181 .query_row(
1182 "SELECT state, source FROM skill_lifecycle
1183 WHERE skill_name=?1 AND active_version=?2",
1184 params![skill_name, version],
1185 |row| Ok((row.get(0)?, row.get(1)?)),
1186 )
1187 .optional()?;
1188 let Some((from_state, source)) = current else {
1189 return Ok(());
1190 };
1191 if from_state == "archived" {
1192 return Ok(());
1193 }
1194 let baseline = if source == "agent-created" {
1195 "candidate"
1196 } else {
1197 "trusted"
1198 };
1199 let now = chrono::Utc::now().to_rfc3339();
1200 tx.execute(
1201 "UPDATE skill_lifecycle
1202 SET state=?2, updated_at=?3, state_reason=?4
1203 WHERE skill_name=?1",
1204 params![skill_name, baseline, now, reason],
1205 )?;
1206 if from_state != baseline {
1207 tx.execute(
1208 "INSERT INTO skill_lifecycle_events(
1209 skill_name, from_state, to_state, version, reason, created_at
1210 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1211 params![skill_name, from_state, baseline, version, reason, now],
1212 )?;
1213 }
1214 for _ in 0..3 {
1217 if !evaluate_skill_lifecycle(tx, skill_name, version)? {
1218 break;
1219 }
1220 }
1221 Ok(())
1222}
1223
1224impl Database {
1225 pub fn register_skill_version(
1226 &self,
1227 skill_name: &str,
1228 version: i64,
1229 content: &str,
1230 source: &str,
1231 ) -> Result<(), MicroClawError> {
1232 if skill_name.trim().is_empty() || version <= 0 {
1233 return Err(MicroClawError::ToolExecution(
1234 "skill name must be non-empty and version must be positive".into(),
1235 ));
1236 }
1237 use sha2::{Digest, Sha256};
1238 let mut hasher = Sha256::new();
1239 hasher.update(content.as_bytes());
1240 let hash = to_hex(&hasher.finalize());
1241 let mut conn = self.lock_conn();
1242 let tx = conn.transaction()?;
1243 let now = chrono::Utc::now().to_rfc3339();
1244 let existing_hash: Option<String> = tx
1245 .query_row(
1246 "SELECT content_hash FROM skill_versions
1247 WHERE skill_name=?1 AND version=?2",
1248 params![skill_name, version],
1249 |row| row.get(0),
1250 )
1251 .optional()?;
1252 if existing_hash
1253 .as_deref()
1254 .is_some_and(|existing| existing != hash)
1255 {
1256 return Err(MicroClawError::ToolExecution(format!(
1257 "skill version {skill_name}@{version} is immutable and already has different content"
1258 )));
1259 }
1260 tx.execute(
1261 "INSERT INTO skill_versions(
1262 skill_name, version, content, content_hash, source, created_at
1263 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1264 ON CONFLICT(skill_name, version) DO NOTHING",
1265 params![skill_name, version, content, hash, source, now],
1266 )?;
1267 let existing: Option<(String, i64)> = tx
1268 .query_row(
1269 "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
1270 params![skill_name],
1271 |row| Ok((row.get(0)?, row.get(1)?)),
1272 )
1273 .optional()?;
1274 match existing {
1275 None => {
1276 let initial = if source == "agent-created" {
1277 "candidate"
1278 } else {
1279 "trusted"
1280 };
1281 tx.execute(
1282 "INSERT INTO skill_lifecycle(
1283 skill_name, state, active_version, source,
1284 created_at, updated_at, state_reason
1285 ) VALUES (?1, ?2, ?3, ?4, ?5, ?5, 'version registered')",
1286 params![skill_name, initial, version, source, now],
1287 )?;
1288 tx.execute(
1289 "INSERT INTO skill_lifecycle_events(
1290 skill_name, from_state, to_state, version, reason, created_at
1291 ) VALUES (?1, NULL, ?2, ?3, 'version registered', ?4)",
1292 params![skill_name, initial, version, now],
1293 )?;
1294 }
1295 Some((state, active_version)) => {
1296 if version <= active_version {
1300 tx.commit()?;
1301 return Ok(());
1302 }
1303 let next_state = if source == "agent-created" && version > active_version {
1304 "candidate"
1305 } else {
1306 state.as_str()
1307 };
1308 let previous_trusted =
1309 (state == "trusted" && version > active_version).then_some(active_version);
1310 tx.execute(
1311 "UPDATE skill_lifecycle
1312 SET state=?2, active_version=?3,
1313 previous_trusted_version=COALESCE(?4, previous_trusted_version),
1314 source=?5, updated_at=?6, state_reason='new version registered'
1315 WHERE skill_name=?1",
1316 params![
1317 skill_name,
1318 next_state,
1319 version,
1320 previous_trusted,
1321 source,
1322 now
1323 ],
1324 )?;
1325 if next_state != state {
1326 tx.execute(
1327 "INSERT INTO skill_lifecycle_events(
1328 skill_name, from_state, to_state, version, reason, created_at
1329 ) VALUES (?1, ?2, ?3, ?4, 'new version registered', ?5)",
1330 params![skill_name, state, next_state, version, now],
1331 )?;
1332 }
1333 }
1334 }
1335 tx.commit()?;
1336 Ok(())
1337 }
1338
1339 pub fn get_skill_lifecycle(
1340 &self,
1341 skill_name: &str,
1342 ) -> Result<Option<SkillLifecycleRecord>, MicroClawError> {
1343 let conn = self.lock_conn();
1344 conn.query_row(
1345 "SELECT skill_name, state, active_version, previous_trusted_version,
1346 source, created_at, updated_at, state_reason
1347 FROM skill_lifecycle WHERE skill_name=?1",
1348 params![skill_name],
1349 |row| {
1350 Ok(SkillLifecycleRecord {
1351 skill_name: row.get(0)?,
1352 state: row.get(1)?,
1353 active_version: row.get(2)?,
1354 previous_trusted_version: row.get(3)?,
1355 source: row.get(4)?,
1356 created_at: row.get(5)?,
1357 updated_at: row.get(6)?,
1358 state_reason: row.get(7)?,
1359 })
1360 },
1361 )
1362 .optional()
1363 .map_err(Into::into)
1364 }
1365
1366 pub fn get_skill_version_content(
1367 &self,
1368 skill_name: &str,
1369 version: i64,
1370 ) -> Result<Option<String>, MicroClawError> {
1371 let conn = self.lock_conn();
1372 conn.query_row(
1373 "SELECT content FROM skill_versions WHERE skill_name=?1 AND version=?2",
1374 params![skill_name, version],
1375 |row| row.get(0),
1376 )
1377 .optional()
1378 .map_err(Into::into)
1379 }
1380
1381 pub fn evaluate_skill_applicability(
1385 &self,
1386 skill_name: &str,
1387 chat_id: i64,
1388 ) -> Result<SkillApplicability, MicroClawError> {
1389 let mut conn = self.lock_conn();
1390 let tx = conn.transaction()?;
1391 refresh_expired_skill_outcomes(&tx)?;
1392 let utility_confidence_z: f64 = tx.query_row(
1393 "SELECT utility_confidence_z FROM skill_governance_policy WHERE singleton_id=1",
1394 [],
1395 |row| row.get(0),
1396 )?;
1397 let context: Option<(Option<String>, String, String)> = tx
1398 .query_row(
1399 "SELECT environment_fingerprint, task_type, task_family
1400 FROM experience_runs
1401 WHERE chat_id=?1 AND status='running'
1402 ORDER BY started_at DESC LIMIT 1",
1403 params![chat_id],
1404 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1405 )
1406 .optional()?;
1407 let Some((environment, task_type, task_family)) = context else {
1408 tx.commit()?;
1409 return Ok(SkillApplicability {
1410 environment_fingerprint: None,
1411 task_type: None,
1412 task_family: None,
1413 matching_outcomes: 0,
1414 passed_outcomes: 0,
1415 failed_outcomes: 0,
1416 success_rate: None,
1417 utility_lower_bound: None,
1418 allowed: true,
1419 reason: "no comparable verified task history".into(),
1420 });
1421 };
1422 let (total, passed, failed): (i64, i64, i64) = tx.query_row(
1423 "SELECT COUNT(o.id),
1424 COALESCE(SUM(CASE WHEN o.verdict='passed' THEN 1 ELSE 0 END), 0),
1425 COALESCE(SUM(CASE WHEN o.verdict='failed' THEN 1 ELSE 0 END), 0)
1426 FROM skill_outcomes o
1427 JOIN skill_lifecycle l
1428 ON l.skill_name=o.skill_name AND l.active_version=o.skill_version
1429 JOIN experience_runs r ON r.run_id=o.run_id
1430 WHERE o.skill_name=?1
1431 AND (?2 IS NULL OR r.environment_fingerprint=?2)
1432 AND r.task_family=?3
1433 AND o.verifier_type IN ('deterministic','environmental','human','rule_based')
1434 AND o.attribution_confidence >= 0.999
1435 AND (o.valid_until IS NULL OR o.valid_until > ?4)",
1436 params![
1437 skill_name,
1438 environment,
1439 task_family,
1440 chrono::Utc::now().to_rfc3339()
1441 ],
1442 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1443 )?;
1444 let success_rate = (total > 0).then_some(passed as f64 / total as f64);
1445 let utility_lower_bound =
1446 (total > 0).then_some(wilson_lower_bound(passed, total, utility_confidence_z));
1447 let active_pattern: Option<(String, Option<String>, i64, Option<String>)> = tx
1448 .query_row(
1449 "SELECT error_category, tool_name, failure_count, cooldown_until
1450 FROM skill_failure_patterns p
1451 JOIN skill_lifecycle l ON l.skill_name=p.skill_name
1452 AND l.active_version=p.skill_version
1453 WHERE p.skill_name=?1 AND p.task_family=?2
1454 AND (p.environment_fingerprint IS NULL
1455 OR p.environment_fingerprint=?3)
1456 AND p.state IN ('active','cooldown')
1457 ORDER BY p.failure_count DESC, p.updated_at DESC LIMIT 1",
1458 params![skill_name, task_family, environment],
1459 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1460 )
1461 .optional()?;
1462 let allowed = active_pattern.is_none() && (total < 2 || success_rate.unwrap_or(0.0) >= 0.5);
1463 let result = SkillApplicability {
1464 environment_fingerprint: environment,
1465 task_type: Some(task_type),
1466 task_family: Some(task_family),
1467 matching_outcomes: total,
1468 passed_outcomes: passed,
1469 failed_outcomes: failed,
1470 success_rate,
1471 utility_lower_bound,
1472 allowed,
1473 reason: if let Some((category, tool, count, cooldown)) = active_pattern {
1474 format!(
1475 "learned contraindication: {count} {category} failure(s), tool={}, cooldown_until={}",
1476 tool.as_deref().unwrap_or("any"),
1477 cooldown.as_deref().unwrap_or("unknown")
1478 )
1479 } else if allowed {
1480 "no learned contraindication for this task family and environment".into()
1481 } else {
1482 "verified outcomes show repeated failure for this task family and environment"
1483 .into()
1484 },
1485 };
1486 tx.commit()?;
1487 Ok(result)
1488 }
1489
1490 pub fn transition_skill_state(
1491 &self,
1492 skill_name: &str,
1493 to_state: &str,
1494 reason: &str,
1495 ) -> Result<bool, MicroClawError> {
1496 if !matches!(
1497 to_state,
1498 "candidate" | "trial" | "trusted" | "degraded" | "archived"
1499 ) {
1500 return Err(MicroClawError::ToolExecution(format!(
1501 "invalid skill lifecycle state: {to_state}"
1502 )));
1503 }
1504 let mut conn = self.lock_conn();
1505 let tx = conn.transaction()?;
1506 let current: Option<(String, i64)> = tx
1507 .query_row(
1508 "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
1509 params![skill_name],
1510 |row| Ok((row.get(0)?, row.get(1)?)),
1511 )
1512 .optional()?;
1513 let Some((from_state, version)) = current else {
1514 return Ok(false);
1515 };
1516 let now = chrono::Utc::now().to_rfc3339();
1517 tx.execute(
1518 "UPDATE skill_lifecycle
1519 SET state=?2, updated_at=?3, state_reason=?4 WHERE skill_name=?1",
1520 params![skill_name, to_state, now, reason],
1521 )?;
1522 if from_state != to_state {
1523 tx.execute(
1524 "INSERT INTO skill_lifecycle_events(
1525 skill_name, from_state, to_state, version, reason, created_at
1526 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1527 params![skill_name, from_state, to_state, version, reason, now],
1528 )?;
1529 }
1530 tx.commit()?;
1531 Ok(true)
1532 }
1533
1534 pub fn rollback_skill(
1535 &self,
1536 skill_name: &str,
1537 target_version: Option<i64>,
1538 reason: &str,
1539 ) -> Result<Option<(i64, String)>, MicroClawError> {
1540 let mut conn = self.lock_conn();
1541 let tx = conn.transaction()?;
1542 let current: Option<(String, i64, Option<i64>)> = tx
1543 .query_row(
1544 "SELECT state, active_version, previous_trusted_version
1545 FROM skill_lifecycle WHERE skill_name=?1",
1546 params![skill_name],
1547 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1548 )
1549 .optional()?;
1550 let Some((from_state, active_version, previous_trusted)) = current else {
1551 return Ok(None);
1552 };
1553 let target = target_version.or(previous_trusted);
1554 let Some(target) = target else {
1555 return Ok(None);
1556 };
1557 let content: Option<String> = tx
1558 .query_row(
1559 "SELECT content FROM skill_versions WHERE skill_name=?1 AND version=?2",
1560 params![skill_name, target],
1561 |row| row.get(0),
1562 )
1563 .optional()?;
1564 let Some(content) = content else {
1565 return Ok(None);
1566 };
1567 let now = chrono::Utc::now().to_rfc3339();
1568 tx.execute(
1569 "UPDATE skill_lifecycle
1570 SET state='trusted', active_version=?2,
1571 previous_trusted_version=?3, updated_at=?4, state_reason=?5
1572 WHERE skill_name=?1",
1573 params![skill_name, target, active_version, now, reason],
1574 )?;
1575 tx.execute(
1576 "INSERT INTO skill_lifecycle_events(
1577 skill_name, from_state, to_state, version, reason, created_at
1578 ) VALUES (?1, ?2, 'trusted', ?3, ?4, ?5)",
1579 params![skill_name, from_state, target, reason, now],
1580 )?;
1581 tx.execute(
1582 "UPDATE skill_candidates SET status='rolled_back', updated_at=?3
1583 WHERE skill_name=?1 AND candidate_version=?2 AND status='promoted'",
1584 params![skill_name, active_version, now],
1585 )?;
1586 tx.execute(
1587 "INSERT INTO learning_journal_events(
1588 event_type, entity_type, entity_id, summary, evidence_json,
1589 undo_action, created_at
1590 ) VALUES ('operator_rollback','skill',?1,?2,?3,NULL,?4)",
1591 params![
1592 skill_name,
1593 format!("rolled back {skill_name} from v{active_version} to v{target}: {reason}"),
1594 serde_json::json!({
1595 "from_version": active_version,
1596 "to_version": target,
1597 "reason": reason
1598 })
1599 .to_string(),
1600 now
1601 ],
1602 )?;
1603 tx.commit()?;
1604 Ok(Some((target, content)))
1605 }
1606
1607 pub fn get_skill_learning_summaries(
1608 &self,
1609 ) -> Result<Vec<SkillLearningSummary>, MicroClawError> {
1610 let mut conn = self.lock_conn();
1611 let tx = conn.transaction()?;
1612 refresh_expired_skill_outcomes(&tx)?;
1613 let utility_confidence_z: f64 = tx.query_row(
1614 "SELECT utility_confidence_z FROM skill_governance_policy WHERE singleton_id=1",
1615 [],
1616 |row| row.get(0),
1617 )?;
1618 let mut stmt = tx.prepare(
1619 "SELECT l.skill_name, l.state, l.active_version,
1620 COUNT(o.id),
1621 COALESCE(SUM(CASE WHEN o.verdict='passed' THEN 1 ELSE 0 END), 0),
1622 COALESCE(SUM(CASE WHEN o.verdict='failed' THEN 1 ELSE 0 END), 0),
1623 MAX(o.updated_at)
1624 FROM skill_lifecycle l
1625 LEFT JOIN skill_outcomes o
1626 ON o.skill_name=l.skill_name AND o.skill_version=l.active_version
1627 AND o.verifier_type IN ('deterministic','environmental','human','rule_based')
1628 AND o.attribution_confidence >= 0.999
1629 AND (o.valid_until IS NULL OR o.valid_until >
1630 strftime('%Y-%m-%dT%H:%M:%f+00:00','now'))
1631 GROUP BY l.skill_name, l.state, l.active_version
1632 ORDER BY l.updated_at DESC",
1633 )?;
1634 let rows = stmt.query_map([], |row| {
1635 let total: i64 = row.get(3)?;
1636 let passed: i64 = row.get(4)?;
1637 let failed: i64 = row.get(5)?;
1638 Ok(SkillLearningSummary {
1639 skill_name: row.get(0)?,
1640 state: row.get(1)?,
1641 active_version: row.get(2)?,
1642 total_outcomes: total,
1643 passed_outcomes: passed,
1644 failed_outcomes: failed,
1645 success_rate: if total == 0 {
1646 0.0
1647 } else {
1648 passed as f64 / total as f64
1649 },
1650 utility_lower_bound: wilson_lower_bound(passed, total, utility_confidence_z),
1651 last_outcome_at: row.get(6)?,
1652 })
1653 })?;
1654 let summaries = rows.collect::<Result<Vec<_>, _>>()?;
1655 drop(stmt);
1656 tx.commit()?;
1657 Ok(summaries)
1658 }
1659
1660 pub fn get_skill_task_utility_summaries(
1661 &self,
1662 skill_name: Option<&str>,
1663 ) -> Result<Vec<SkillTaskUtilitySummary>, MicroClawError> {
1664 let mut conn = self.lock_conn();
1665 let tx = conn.transaction()?;
1666 refresh_expired_skill_outcomes(&tx)?;
1667 let z: f64 = tx.query_row(
1668 "SELECT utility_confidence_z FROM skill_governance_policy WHERE singleton_id=1",
1669 [],
1670 |row| row.get(0),
1671 )?;
1672 let mut stmt = tx.prepare(
1673 "SELECT o.skill_name, o.skill_version, r.task_type, r.task_family,
1674 COUNT(o.id),
1675 COALESCE(SUM(CASE WHEN o.verdict='passed' THEN 1 ELSE 0 END), 0),
1676 COALESCE(SUM(CASE WHEN o.verdict='failed' THEN 1 ELSE 0 END), 0)
1677 FROM skill_outcomes o
1678 JOIN experience_runs r ON r.run_id=o.run_id
1679 JOIN skill_lifecycle l
1680 ON l.skill_name=o.skill_name AND l.active_version=o.skill_version
1681 WHERE (?1 IS NULL OR o.skill_name=?1)
1682 AND o.verifier_type IN ('deterministic','environmental','human','rule_based')
1683 AND o.attribution_confidence >= 0.999
1684 AND (o.valid_until IS NULL OR o.valid_until >
1685 strftime('%Y-%m-%dT%H:%M:%f+00:00','now'))
1686 GROUP BY o.skill_name, o.skill_version, r.task_type, r.task_family
1687 ORDER BY o.skill_name, COUNT(o.id) DESC, r.task_family",
1688 )?;
1689 let rows = stmt
1690 .query_map(params![skill_name], |row| {
1691 let total: i64 = row.get(4)?;
1692 let passed: i64 = row.get(5)?;
1693 Ok(SkillTaskUtilitySummary {
1694 skill_name: row.get(0)?,
1695 skill_version: row.get(1)?,
1696 task_type: row.get(2)?,
1697 task_family: row.get(3)?,
1698 total_outcomes: total,
1699 passed_outcomes: passed,
1700 failed_outcomes: row.get(6)?,
1701 success_rate: if total == 0 {
1702 0.0
1703 } else {
1704 passed as f64 / total as f64
1705 },
1706 utility_lower_bound: wilson_lower_bound(passed, total, z),
1707 })
1708 })?
1709 .collect::<Result<Vec<_>, _>>()?;
1710 drop(stmt);
1711 tx.commit()?;
1712 Ok(rows)
1713 }
1714
1715 pub fn get_skill_failure_patterns(
1716 &self,
1717 skill_name: Option<&str>,
1718 ) -> Result<Vec<SkillFailurePattern>, MicroClawError> {
1719 let conn = self.lock_conn();
1720 let sql = "SELECT pattern_id, skill_name, skill_version, task_type,
1721 task_family, environment_fingerprint, tool_name,
1722 error_category, failure_count, recovery_successes,
1723 state, cooldown_until, last_evidence, updated_at
1724 FROM skill_failure_patterns
1725 WHERE (?1 IS NULL OR skill_name=?1)
1726 ORDER BY CASE state WHEN 'active' THEN 0 WHEN 'cooldown' THEN 1
1727 WHEN 'trial' THEN 2 ELSE 3 END,
1728 failure_count DESC, updated_at DESC";
1729 let mut stmt = conn.prepare(sql)?;
1730 let rows = stmt
1731 .query_map(params![skill_name], |row| {
1732 Ok(SkillFailurePattern {
1733 pattern_id: row.get(0)?,
1734 skill_name: row.get(1)?,
1735 skill_version: row.get(2)?,
1736 task_type: row.get(3)?,
1737 task_family: row.get(4)?,
1738 environment_fingerprint: row.get(5)?,
1739 tool_name: row.get(6)?,
1740 error_category: row.get(7)?,
1741 failure_count: row.get(8)?,
1742 recovery_successes: row.get(9)?,
1743 state: row.get(10)?,
1744 cooldown_until: row.get(11)?,
1745 last_evidence: row.get(12)?,
1746 updated_at: row.get(13)?,
1747 })
1748 })?
1749 .collect::<Result<Vec<_>, _>>()?;
1750 Ok(rows)
1751 }
1752
1753 pub fn begin_skill_recovery_trial(&self, skill_name: &str) -> Result<bool, MicroClawError> {
1754 if skill_name.trim().is_empty() || skill_name.len() > 256 {
1755 return Err(MicroClawError::ToolExecution(
1756 "invalid skill name for recovery trial".into(),
1757 ));
1758 }
1759 let mut conn = self.lock_conn();
1760 let tx = conn.transaction()?;
1761 let now = chrono::Utc::now().to_rfc3339();
1762 let changed = tx.execute(
1763 "UPDATE skill_failure_patterns SET state='trial', updated_at=?2
1764 WHERE skill_name=?1 AND state IN ('active','cooldown')
1765 AND cooldown_until IS NOT NULL AND cooldown_until<=?2",
1766 params![skill_name, now],
1767 )?;
1768 if changed > 0 {
1769 let lifecycle: Option<(String, i64)> = tx
1770 .query_row(
1771 "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
1772 params![skill_name],
1773 |row| Ok((row.get(0)?, row.get(1)?)),
1774 )
1775 .optional()?;
1776 if let Some((state, version)) = lifecycle {
1777 if state == "degraded" {
1778 tx.execute(
1779 "UPDATE skill_lifecycle SET state='trial',
1780 state_reason='cooldown elapsed; recovery trial explicitly started',
1781 updated_at=?2 WHERE skill_name=?1",
1782 params![skill_name, now],
1783 )?;
1784 tx.execute(
1785 "INSERT INTO skill_lifecycle_events(
1786 skill_name, from_state, to_state, version, reason, created_at
1787 ) VALUES (?1,'degraded','trial',?2,
1788 'cooldown elapsed; recovery trial explicitly started',?3)",
1789 params![skill_name, version, now],
1790 )?;
1791 }
1792 }
1793 }
1794 tx.commit()?;
1795 Ok(changed > 0)
1796 }
1797
1798 pub fn get_learning_claims(
1799 &self,
1800 chat_id: Option<i64>,
1801 limit: usize,
1802 ) -> Result<Vec<LearningClaim>, MicroClawError> {
1803 let conn = self.lock_conn();
1804 let mut stmt = conn.prepare(
1805 "SELECT c.claim_id, c.comparison_id, c.skill_name, c.base_version,
1806 c.claim_version, c.statement, c.applicability_json, c.confidence,
1807 c.evidence_json, c.counterexamples_json, c.status,
1808 c.supersedes_claim_id, c.created_at, c.updated_at
1809 FROM learning_claims c
1810 JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
1811 WHERE (?1 IS NULL OR x.chat_id=?1)
1812 ORDER BY c.updated_at DESC LIMIT ?2",
1813 )?;
1814 let rows = stmt
1815 .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
1816 let applicability: String = row.get(6)?;
1817 let evidence: String = row.get(8)?;
1818 let counterexamples: String = row.get(9)?;
1819 Ok(LearningClaim {
1820 claim_id: row.get(0)?,
1821 comparison_id: row.get(1)?,
1822 skill_name: row.get(2)?,
1823 base_version: row.get(3)?,
1824 claim_version: row.get(4)?,
1825 statement: row.get(5)?,
1826 applicability: serde_json::from_str(&applicability)
1827 .unwrap_or(serde_json::Value::Null),
1828 confidence: row.get(7)?,
1829 evidence: serde_json::from_str(&evidence).unwrap_or(serde_json::Value::Null),
1830 counterexamples: serde_json::from_str(&counterexamples)
1831 .unwrap_or(serde_json::Value::Null),
1832 status: row.get(10)?,
1833 supersedes_claim_id: row.get(11)?,
1834 created_at: row.get(12)?,
1835 updated_at: row.get(13)?,
1836 })
1837 })?
1838 .collect::<Result<Vec<_>, _>>()?;
1839 Ok(rows)
1840 }
1841
1842 pub fn get_skill_candidates(
1843 &self,
1844 chat_id: Option<i64>,
1845 limit: usize,
1846 ) -> Result<Vec<SkillCandidate>, MicroClawError> {
1847 let conn = self.lock_conn();
1848 let mut stmt = conn.prepare(
1849 "SELECT k.candidate_id, k.claim_id, k.skill_name, k.base_version,
1850 k.candidate_version, k.content, k.content_hash, k.status,
1851 k.created_at, k.updated_at
1852 FROM skill_candidates k
1853 JOIN learning_claims c ON c.claim_id=k.claim_id
1854 JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
1855 WHERE (?1 IS NULL OR x.chat_id=?1)
1856 ORDER BY k.updated_at DESC LIMIT ?2",
1857 )?;
1858 let rows = stmt
1859 .query_map(params![chat_id, limit.clamp(1, 200) as i64], |row| {
1860 Ok(SkillCandidate {
1861 candidate_id: row.get(0)?,
1862 claim_id: row.get(1)?,
1863 skill_name: row.get(2)?,
1864 base_version: row.get(3)?,
1865 candidate_version: row.get(4)?,
1866 content: row.get(5)?,
1867 content_hash: row.get(6)?,
1868 status: row.get(7)?,
1869 created_at: row.get(8)?,
1870 updated_at: row.get(9)?,
1871 })
1872 })?
1873 .collect::<Result<Vec<_>, _>>()?;
1874 Ok(rows)
1875 }
1876
1877 pub fn create_skill_candidate_from_claim(
1878 &self,
1879 claim_id: &str,
1880 chat_id: i64,
1881 ) -> Result<SkillCandidate, MicroClawError> {
1882 let mut conn = self.lock_conn();
1883 let tx = conn.transaction()?;
1884 let claim: Option<(String, i64, String, String)> = tx
1885 .query_row(
1886 "SELECT c.skill_name, c.base_version, c.statement, c.status
1887 FROM learning_claims c
1888 JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
1889 WHERE c.claim_id=?1 AND x.chat_id=?2",
1890 params![claim_id, chat_id],
1891 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1892 )
1893 .optional()?;
1894 let Some((skill_name, base_version, statement, claim_status)) = claim else {
1895 return Err(MicroClawError::ToolExecution(
1896 "learning claim not found".into(),
1897 ));
1898 };
1899 if claim_status == "archived" {
1900 return Err(MicroClawError::ToolExecution(
1901 "archived claim cannot create a candidate".into(),
1902 ));
1903 }
1904 if let Some(existing) = tx
1905 .query_row(
1906 "SELECT candidate_id FROM skill_candidates WHERE claim_id=?1",
1907 params![claim_id],
1908 |row| row.get::<_, String>(0),
1909 )
1910 .optional()?
1911 {
1912 drop(tx);
1913 drop(conn);
1914 return self
1915 .get_skill_candidates(Some(chat_id), 200)?
1916 .into_iter()
1917 .find(|candidate| candidate.candidate_id == existing)
1918 .ok_or_else(|| {
1919 MicroClawError::ToolExecution("candidate projection missing".into())
1920 });
1921 }
1922 let base_content: String = tx.query_row(
1923 "SELECT content FROM skill_versions
1924 WHERE skill_name=?1 AND version=?2",
1925 params![skill_name, base_version],
1926 |row| row.get(0),
1927 )?;
1928 let candidate_version: i64 = tx.query_row(
1929 "SELECT MAX(version)+1 FROM (
1930 SELECT version FROM skill_versions WHERE skill_name=?1
1931 UNION ALL
1932 SELECT candidate_version AS version FROM skill_candidates
1933 WHERE skill_name=?1
1934 )",
1935 params![skill_name],
1936 |row| row.get::<_, Option<i64>>(0).map(|value| value.unwrap_or(1)),
1937 )?;
1938 let content = format!(
1939 "{base_content}\n\n## Learned guardrail (candidate v{candidate_version})\n\n- {statement}\n"
1940 );
1941 use sha2::{Digest, Sha256};
1942 let content_hash = to_hex(&Sha256::digest(content.as_bytes()));
1943 let candidate_id = uuid::Uuid::new_v4().to_string();
1944 let now = chrono::Utc::now().to_rfc3339();
1945 tx.execute(
1946 "INSERT INTO skill_candidates(
1947 candidate_id, claim_id, skill_name, base_version,
1948 candidate_version, content, content_hash, status,
1949 created_at, updated_at
1950 ) VALUES (?1,?2,?3,?4,?5,?6,?7,'candidate',?8,?8)",
1951 params![
1952 candidate_id,
1953 claim_id,
1954 skill_name,
1955 base_version,
1956 candidate_version,
1957 content,
1958 content_hash,
1959 now
1960 ],
1961 )?;
1962 tx.execute(
1963 "UPDATE learning_claims SET status='trial', updated_at=?2
1964 WHERE claim_id=?1",
1965 params![claim_id, now],
1966 )?;
1967 tx.execute(
1968 "INSERT INTO learning_journal_events(
1969 event_type, entity_type, entity_id, summary, evidence_json,
1970 undo_action, created_at
1971 ) VALUES ('candidate_created','skill_candidate',?1,?2,?3,
1972 'archive_candidate',?4)",
1973 params![
1974 candidate_id,
1975 format!("{skill_name} candidate v{candidate_version} created from claim"),
1976 serde_json::json!({
1977 "claim_id": claim_id,
1978 "base_version": base_version,
1979 "content_hash": content_hash
1980 })
1981 .to_string(),
1982 now
1983 ],
1984 )?;
1985 tx.commit()?;
1986 drop(conn);
1987 self.get_skill_candidates(Some(chat_id), 200)?
1988 .into_iter()
1989 .find(|candidate| candidate.candidate_id == candidate_id)
1990 .ok_or_else(|| MicroClawError::ToolExecution("candidate projection missing".into()))
1991 }
1992
1993 #[expect(clippy::too_many_arguments)]
1994 pub fn record_shadow_observation(
1995 &self,
1996 candidate_id: &str,
1997 chat_id: i64,
1998 pair_key: &str,
1999 arm: &str,
2000 run_id: &str,
2001 verdict: &str,
2002 cost_usd: f64,
2003 duration_ms: i64,
2004 evidence: Option<&str>,
2005 ) -> Result<ShadowEvaluation, MicroClawError> {
2006 if !matches!(arm, "baseline" | "candidate")
2007 || !matches!(verdict, "passed" | "failed")
2008 || pair_key.trim().is_empty()
2009 || pair_key.len() > 128
2010 || !cost_usd.is_finite()
2011 || cost_usd < 0.0
2012 || duration_ms < 0
2013 {
2014 return Err(MicroClawError::ToolExecution(
2015 "invalid shadow observation".into(),
2016 ));
2017 }
2018 let mut conn = self.lock_conn();
2019 let tx = conn.transaction()?;
2020 let exists: i64 = tx.query_row(
2021 "SELECT COUNT(*) FROM skill_candidates c
2022 JOIN learning_claims l ON l.claim_id=c.claim_id
2023 JOIN experience_comparisons x ON x.comparison_id=l.comparison_id
2024 JOIN experience_runs r ON r.run_id=?2
2025 JOIN verifier_results v ON v.run_id=r.run_id
2026 WHERE c.candidate_id=?1
2027 AND c.status NOT IN ('archived','promoted')
2028 AND x.chat_id=?5
2029 AND r.task_family=json_extract(l.applicability_json,'$.task_family')
2030 AND (
2031 json_extract(l.applicability_json,'$.environment_fingerprint') IS NULL
2032 OR r.environment_fingerprint=
2033 json_extract(l.applicability_json,'$.environment_fingerprint')
2034 )
2035 AND v.verdict=?3
2036 AND v.verifier_type IN (
2037 'deterministic','environmental','human','rule_based'
2038 )
2039 AND (v.valid_until IS NULL OR v.valid_until>?4)",
2040 params![
2041 candidate_id,
2042 run_id,
2043 verdict,
2044 chrono::Utc::now().to_rfc3339(),
2045 chat_id
2046 ],
2047 |row| row.get(0),
2048 )?;
2049 if exists == 0 {
2050 return Err(MicroClawError::ToolExecution(
2051 "candidate or strongly verified comparable run not eligible for shadow evaluation"
2052 .into(),
2053 ));
2054 }
2055 let now = chrono::Utc::now().to_rfc3339();
2056 tx.execute(
2057 "INSERT INTO shadow_observations(
2058 candidate_id, pair_key, arm, run_id, verdict, cost_usd,
2059 duration_ms, evidence, created_at
2060 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)
2061 ON CONFLICT(candidate_id,pair_key,arm) DO UPDATE SET
2062 run_id=excluded.run_id, verdict=excluded.verdict,
2063 cost_usd=excluded.cost_usd, duration_ms=excluded.duration_ms,
2064 evidence=excluded.evidence, created_at=excluded.created_at",
2065 params![
2066 candidate_id,
2067 pair_key,
2068 arm,
2069 run_id,
2070 verdict,
2071 cost_usd,
2072 duration_ms,
2073 evidence,
2074 now
2075 ],
2076 )?;
2077 let evaluation = recompute_shadow_evaluation(&tx, candidate_id)?;
2078 tx.commit()?;
2079 Ok(evaluation)
2080 }
2081
2082 pub fn get_shadow_evaluations(
2083 &self,
2084 chat_id: Option<i64>,
2085 ) -> Result<Vec<ShadowEvaluation>, MicroClawError> {
2086 let conn = self.lock_conn();
2087 let mut stmt = conn.prepare(
2088 "SELECT e.evaluation_id, e.candidate_id, e.sample_count,
2089 e.baseline_passed, e.candidate_passed,
2090 e.baseline_utility_lower_bound, e.candidate_utility_lower_bound,
2091 e.baseline_cost_usd, e.candidate_cost_usd,
2092 e.baseline_duration_ms, e.candidate_duration_ms,
2093 e.regression_count, e.verdict, e.reason, e.updated_at
2094 FROM shadow_evaluations e
2095 JOIN skill_candidates k ON k.candidate_id=e.candidate_id
2096 JOIN learning_claims c ON c.claim_id=k.claim_id
2097 JOIN experience_comparisons x ON x.comparison_id=c.comparison_id
2098 WHERE (?1 IS NULL OR x.chat_id=?1)
2099 ORDER BY e.updated_at DESC",
2100 )?;
2101 let rows = stmt
2102 .query_map(params![chat_id], map_shadow_evaluation)?
2103 .collect::<Result<Vec<_>, _>>()?;
2104 Ok(rows)
2105 }
2106
2107 pub fn promote_shadow_candidate(
2108 &self,
2109 candidate_id: &str,
2110 chat_id: i64,
2111 ) -> Result<(String, i64), MicroClawError> {
2112 let mut conn = self.lock_conn();
2113 let tx = conn.transaction()?;
2114 let candidate: Option<(String, i64, i64, String, String, String)> = tx
2115 .query_row(
2116 "SELECT c.skill_name, c.base_version, c.candidate_version,
2117 c.content, c.content_hash, e.verdict
2118 FROM skill_candidates c
2119 JOIN shadow_evaluations e ON e.candidate_id=c.candidate_id
2120 JOIN learning_claims l ON l.claim_id=c.claim_id
2121 JOIN experience_comparisons x ON x.comparison_id=l.comparison_id
2122 WHERE c.candidate_id=?1 AND c.status='shadow_passed'
2123 AND x.chat_id=?2",
2124 params![candidate_id, chat_id],
2125 |row| {
2126 Ok((
2127 row.get(0)?,
2128 row.get(1)?,
2129 row.get(2)?,
2130 row.get(3)?,
2131 row.get(4)?,
2132 row.get(5)?,
2133 ))
2134 },
2135 )
2136 .optional()?;
2137 let Some((skill_name, base_version, candidate_version, content, hash, verdict)) = candidate
2138 else {
2139 return Err(MicroClawError::ToolExecution(
2140 "candidate has not passed shadow evaluation".into(),
2141 ));
2142 };
2143 if verdict != "passed" {
2144 return Err(MicroClawError::ToolExecution(
2145 "shadow evaluation gate did not pass".into(),
2146 ));
2147 }
2148 let lifecycle: Option<(String, i64)> = tx
2149 .query_row(
2150 "SELECT state, active_version FROM skill_lifecycle WHERE skill_name=?1",
2151 params![skill_name],
2152 |row| Ok((row.get(0)?, row.get(1)?)),
2153 )
2154 .optional()?;
2155 if lifecycle.as_ref().map(|item| item.1) != Some(base_version) {
2156 return Err(MicroClawError::ToolExecution(
2157 "candidate base version is no longer active; regenerate and re-evaluate".into(),
2158 ));
2159 }
2160 let now = chrono::Utc::now().to_rfc3339();
2161 tx.execute(
2162 "INSERT INTO skill_versions(
2163 skill_name, version, content, content_hash, source, created_at
2164 ) VALUES (?1,?2,?3,?4,'comparative-reflection',?5)",
2165 params![skill_name, candidate_version, content, hash, now],
2166 )?;
2167 tx.execute(
2168 "UPDATE skill_lifecycle SET state='trusted', active_version=?2,
2169 previous_trusted_version=?3, source='comparative-reflection',
2170 state_reason='shadow evaluation passed', updated_at=?4
2171 WHERE skill_name=?1",
2172 params![skill_name, candidate_version, base_version, now],
2173 )?;
2174 tx.execute(
2175 "UPDATE skill_candidates SET status='promoted', updated_at=?2
2176 WHERE candidate_id=?1",
2177 params![candidate_id, now],
2178 )?;
2179 tx.execute(
2180 "UPDATE learning_claims SET status='trusted', updated_at=?2
2181 WHERE claim_id=(SELECT claim_id FROM skill_candidates
2182 WHERE candidate_id=?1)",
2183 params![candidate_id, now],
2184 )?;
2185 tx.execute(
2186 "INSERT INTO skill_lifecycle_events(
2187 skill_name, from_state, to_state, version, reason, created_at
2188 ) VALUES (?1,?2,'trusted',?3,'shadow evaluation passed',?4)",
2189 params![
2190 skill_name,
2191 lifecycle
2192 .map(|item| item.0)
2193 .unwrap_or_else(|| "candidate".into()),
2194 candidate_version,
2195 now
2196 ],
2197 )?;
2198 tx.execute(
2199 "INSERT INTO learning_journal_events(
2200 event_type, entity_type, entity_id, summary, evidence_json,
2201 undo_action, created_at
2202 ) VALUES ('candidate_promoted','skill_candidate',?1,?2,?3,
2203 'rollback_skill',?4)",
2204 params![
2205 candidate_id,
2206 format!("{skill_name} promoted from v{base_version} to v{candidate_version}"),
2207 serde_json::json!({
2208 "skill_name": skill_name,
2209 "from_version": base_version,
2210 "to_version": candidate_version
2211 })
2212 .to_string(),
2213 now
2214 ],
2215 )?;
2216 tx.commit()?;
2217 Ok((skill_name, candidate_version))
2218 }
2219
2220 pub fn archive_learning_entity(
2221 &self,
2222 entity_type: &str,
2223 entity_id: &str,
2224 chat_id: i64,
2225 ) -> Result<bool, MicroClawError> {
2226 let conn = self.lock_conn();
2227 let now = chrono::Utc::now().to_rfc3339();
2228 let changed = match entity_type {
2229 "learning_claim" => conn.execute(
2230 "UPDATE learning_claims SET status='archived', updated_at=?2
2231 WHERE claim_id=?1 AND status NOT IN ('trusted','archived')
2232 AND comparison_id IN (
2233 SELECT comparison_id FROM experience_comparisons
2234 WHERE chat_id=?3
2235 )",
2236 params![entity_id, now, chat_id],
2237 )?,
2238 "skill_candidate" => conn.execute(
2239 "UPDATE skill_candidates SET status='archived', updated_at=?2
2240 WHERE candidate_id=?1 AND status NOT IN ('promoted','archived')
2241 AND claim_id IN (
2242 SELECT c.claim_id FROM learning_claims c
2243 JOIN experience_comparisons x
2244 ON x.comparison_id=c.comparison_id
2245 WHERE x.chat_id=?3
2246 )",
2247 params![entity_id, now, chat_id],
2248 )?,
2249 _ => {
2250 return Err(MicroClawError::ToolExecution(
2251 "unsupported learning entity type".into(),
2252 ));
2253 }
2254 };
2255 if changed > 0 {
2256 conn.execute(
2257 "INSERT INTO learning_journal_events(
2258 event_type, entity_type, entity_id, summary,
2259 evidence_json, created_at
2260 ) VALUES ('entity_archived',?1,?2,?3,?4,?5)",
2261 params![
2262 entity_type,
2263 entity_id,
2264 format!("archived {entity_type} {entity_id}"),
2265 serde_json::json!({"chat_id": chat_id}).to_string(),
2266 now
2267 ],
2268 )?;
2269 }
2270 Ok(changed > 0)
2271 }
2272
2273 pub fn get_learning_journal(
2274 &self,
2275 chat_id: Option<i64>,
2276 limit: usize,
2277 ) -> Result<Vec<LearningJournalEvent>, MicroClawError> {
2278 let conn = self.lock_conn();
2279 let mut stmt = conn.prepare(
2280 "SELECT id, event_type, entity_type, entity_id, summary,
2281 evidence_json, undo_action, created_at
2282 FROM learning_journal_events j
2283 WHERE ?1 IS NULL OR EXISTS(
2284 SELECT 1 FROM experience_comparisons x
2285 LEFT JOIN learning_claims c ON c.comparison_id=x.comparison_id
2286 LEFT JOIN skill_candidates k ON k.claim_id=c.claim_id
2287 WHERE x.chat_id=?1 AND (
2288 (j.entity_type='learning_claim' AND j.entity_id=c.claim_id)
2289 OR (j.entity_type='skill_candidate' AND j.entity_id=k.candidate_id)
2290 OR (j.entity_type='skill' AND j.entity_id=x.skill_name)
2291 )
2292 )
2293 ORDER BY j.created_at DESC LIMIT ?2",
2294 )?;
2295 let rows = stmt
2296 .query_map(params![chat_id, limit.clamp(1, 500) as i64], |row| {
2297 let evidence: String = row.get(5)?;
2298 Ok(LearningJournalEvent {
2299 id: row.get(0)?,
2300 event_type: row.get(1)?,
2301 entity_type: row.get(2)?,
2302 entity_id: row.get(3)?,
2303 summary: row.get(4)?,
2304 evidence: serde_json::from_str(&evidence).unwrap_or(serde_json::Value::Null),
2305 undo_action: row.get(6)?,
2306 created_at: row.get(7)?,
2307 })
2308 })?
2309 .collect::<Result<Vec<_>, _>>()?;
2310 Ok(rows)
2311 }
2312
2313 pub fn get_skill_governance_policy(&self) -> Result<SkillGovernancePolicy, MicroClawError> {
2314 let conn = self.lock_conn();
2315 conn.query_row(
2316 "SELECT candidate_failures_to_degrade, trial_min_outcomes,
2317 trial_promote_rate, trial_degrade_rate, trusted_min_outcomes,
2318 trusted_degrade_rate, utility_confidence_z,
2319 trial_promote_utility_lower_bound,
2320 failure_pattern_min_failures, failure_pattern_cooldown_hours,
2321 failure_pattern_recovery_successes, shadow_min_samples,
2322 shadow_promote_utility_margin, shadow_max_cost_ratio,
2323 shadow_max_regressions
2324 FROM skill_governance_policy WHERE singleton_id=1",
2325 [],
2326 |row| {
2327 Ok(SkillGovernancePolicy {
2328 candidate_failures_to_degrade: row.get(0)?,
2329 trial_min_outcomes: row.get(1)?,
2330 trial_promote_rate: row.get(2)?,
2331 trial_degrade_rate: row.get(3)?,
2332 trusted_min_outcomes: row.get(4)?,
2333 trusted_degrade_rate: row.get(5)?,
2334 utility_confidence_z: row.get(6)?,
2335 trial_promote_utility_lower_bound: row.get(7)?,
2336 failure_pattern_min_failures: row.get(8)?,
2337 failure_pattern_cooldown_hours: row.get(9)?,
2338 failure_pattern_recovery_successes: row.get(10)?,
2339 shadow_min_samples: row.get(11)?,
2340 shadow_promote_utility_margin: row.get(12)?,
2341 shadow_max_cost_ratio: row.get(13)?,
2342 shadow_max_regressions: row.get(14)?,
2343 })
2344 },
2345 )
2346 .map_err(Into::into)
2347 }
2348
2349 pub fn update_skill_governance_policy(
2350 &self,
2351 policy: &SkillGovernancePolicy,
2352 ) -> Result<(), MicroClawError> {
2353 validate_skill_governance_policy(policy)?;
2354 let mut conn = self.lock_conn();
2355 let tx = conn.transaction()?;
2356 tx.execute(
2357 "UPDATE skill_governance_policy
2358 SET candidate_failures_to_degrade=?1, trial_min_outcomes=?2,
2359 trial_promote_rate=?3, trial_degrade_rate=?4,
2360 trusted_min_outcomes=?5, trusted_degrade_rate=?6,
2361 utility_confidence_z=?7,
2362 trial_promote_utility_lower_bound=?8,
2363 failure_pattern_min_failures=?9,
2364 failure_pattern_cooldown_hours=?10,
2365 failure_pattern_recovery_successes=?11,
2366 shadow_min_samples=?12,
2367 shadow_promote_utility_margin=?13,
2368 shadow_max_cost_ratio=?14,
2369 shadow_max_regressions=?15,
2370 updated_at=?16
2371 WHERE singleton_id=1",
2372 params![
2373 policy.candidate_failures_to_degrade,
2374 policy.trial_min_outcomes,
2375 policy.trial_promote_rate,
2376 policy.trial_degrade_rate,
2377 policy.trusted_min_outcomes,
2378 policy.trusted_degrade_rate,
2379 policy.utility_confidence_z,
2380 policy.trial_promote_utility_lower_bound,
2381 policy.failure_pattern_min_failures,
2382 policy.failure_pattern_cooldown_hours,
2383 policy.failure_pattern_recovery_successes,
2384 policy.shadow_min_samples,
2385 policy.shadow_promote_utility_margin,
2386 policy.shadow_max_cost_ratio,
2387 policy.shadow_max_regressions,
2388 chrono::Utc::now().to_rfc3339()
2389 ],
2390 )?;
2391 let now = chrono::Utc::now();
2392 let cooldown_until =
2393 (now + chrono::Duration::hours(policy.failure_pattern_cooldown_hours)).to_rfc3339();
2394 tx.execute(
2395 "UPDATE skill_failure_patterns
2396 SET state=CASE
2397 WHEN failure_count < ?1 THEN 'observed'
2398 WHEN state='observed' THEN 'active'
2399 ELSE state END,
2400 cooldown_until=CASE
2401 WHEN failure_count < ?1 THEN NULL
2402 WHEN state='observed' THEN ?2
2403 ELSE cooldown_until END,
2404 updated_at=?3",
2405 params![
2406 policy.failure_pattern_min_failures,
2407 cooldown_until,
2408 now.to_rfc3339()
2409 ],
2410 )?;
2411 let mut stmt = tx.prepare(
2412 "SELECT skill_name, active_version FROM skill_lifecycle
2413 WHERE state IN ('candidate','trial','trusted')",
2414 )?;
2415 let active = stmt
2416 .query_map([], |row| {
2417 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2418 })?
2419 .collect::<Result<Vec<_>, _>>()?;
2420 drop(stmt);
2421 for (skill_name, version) in active {
2422 evaluate_skill_lifecycle(&tx, &skill_name, version)?;
2423 }
2424 tx.commit()?;
2425 Ok(())
2426 }
2427
2428 pub fn log_skill_activation(
2431 &self,
2432 skill_name: &str,
2433 chat_id: i64,
2434 ) -> Result<(), MicroClawError> {
2435 let conn = self.lock_conn();
2436 let now = chrono::Utc::now().to_rfc3339();
2437 let experience_run_id: Option<String> = conn
2438 .query_row(
2439 "SELECT run_id FROM experience_runs
2440 WHERE chat_id=?1 AND status='running'
2441 ORDER BY started_at DESC LIMIT 1",
2442 params![chat_id],
2443 |row| row.get(0),
2444 )
2445 .optional()?;
2446 let skill_version: Option<i64> = conn
2447 .query_row(
2448 "SELECT active_version FROM skill_lifecycle WHERE skill_name=?1",
2449 params![skill_name],
2450 |row| row.get(0),
2451 )
2452 .optional()?;
2453 conn.execute(
2454 "INSERT INTO skill_activation_logs(
2455 skill_name, chat_id, activated_at, experience_run_id, skill_version
2456 ) VALUES (?1, ?2, ?3, ?4, ?5)",
2457 params![skill_name, chat_id, now, experience_run_id, skill_version],
2458 )?;
2459 Ok(())
2460 }
2461
2462 pub fn last_skill_activation_at(
2465 &self,
2466 skill_name: &str,
2467 ) -> Result<Option<String>, MicroClawError> {
2468 let conn = self.lock_conn();
2469 let result = conn.query_row(
2470 "SELECT activated_at FROM skill_activation_logs
2471 WHERE skill_name = ?1
2472 ORDER BY activated_at DESC
2473 LIMIT 1",
2474 params![skill_name],
2475 |row| row.get::<_, String>(0),
2476 );
2477 match result {
2478 Ok(ts) => Ok(Some(ts)),
2479 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2480 Err(e) => Err(e.into()),
2481 }
2482 }
2483
2484 pub fn skill_activation_counts_since(
2488 &self,
2489 since: &str,
2490 ) -> Result<Vec<(String, i64)>, MicroClawError> {
2491 let conn = self.lock_conn();
2492 let mut stmt = conn.prepare(
2493 "SELECT skill_name, COUNT(*) AS n
2494 FROM skill_activation_logs
2495 WHERE activated_at >= ?1
2496 GROUP BY skill_name
2497 ORDER BY n DESC, skill_name ASC",
2498 )?;
2499 let rows = stmt
2500 .query_map(params![since], |row| {
2501 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2502 })?
2503 .collect::<Result<Vec<_>, _>>()?;
2504 Ok(rows)
2505 }
2506}
2507
2508#[cfg(test)]
2509mod tests {
2510 use super::*;
2511 use crate::internal::storage::db::test_support::*;
2512
2513 #[test]
2514 fn test_skill_activation_log_and_query() {
2515 let (db, dir) = test_db();
2516 assert!(db.last_skill_activation_at("alpha").unwrap().is_none());
2518
2519 db.log_skill_activation("alpha", 7).unwrap();
2520 std::thread::sleep(std::time::Duration::from_millis(5));
2521 db.log_skill_activation("alpha", 7).unwrap();
2522 db.log_skill_activation("beta", 8).unwrap();
2523
2524 let last_alpha = db.last_skill_activation_at("alpha").unwrap().unwrap();
2525 let last_beta = db.last_skill_activation_at("beta").unwrap().unwrap();
2526 assert!(last_alpha >= last_beta || last_alpha <= last_beta);
2527
2528 let counts = db
2529 .skill_activation_counts_since("1970-01-01T00:00:00Z")
2530 .unwrap();
2531 let alpha = counts.iter().find(|(n, _)| n == "alpha").unwrap();
2532 let beta = counts.iter().find(|(n, _)| n == "beta").unwrap();
2533 assert_eq!(alpha.1, 2);
2534 assert_eq!(beta.1, 1);
2535 assert_eq!(counts[0].0, "alpha");
2537
2538 let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
2540 let none = db.skill_activation_counts_since(&future).unwrap();
2541 assert!(none.is_empty());
2542 cleanup(&dir);
2543 }
2544
2545 #[test]
2546 fn learning_substrate_degrades_and_rolls_back_skill_version() {
2547 let (db, dir) = test_db();
2548 db.register_skill_version("browser-flow", 1, "safe v1", "agent-created")
2549 .unwrap();
2550 for index in 1..=3 {
2552 let run_id = format!("good-{index}");
2553 db.start_experience_run(&run_id, None, 7, "web", "interactive", "browse", None)
2554 .unwrap();
2555 db.log_skill_activation("browser-flow", 7).unwrap();
2556 db.record_verifier_result(
2557 &run_id,
2558 "deterministic",
2559 "browser_assertion",
2560 "passed",
2561 1.0,
2562 None,
2563 None,
2564 None,
2565 )
2566 .unwrap();
2567 }
2568 assert_eq!(
2569 db.get_skill_lifecycle("browser-flow")
2570 .unwrap()
2571 .unwrap()
2572 .state,
2573 "trusted"
2574 );
2575
2576 db.register_skill_version("browser-flow", 2, "risky v2", "agent-created")
2577 .unwrap();
2578 db.register_skill_version("browser-flow", 1, "safe v1", "agent-created")
2579 .unwrap();
2580 assert_eq!(
2581 db.get_skill_lifecycle("browser-flow")
2582 .unwrap()
2583 .unwrap()
2584 .active_version,
2585 2,
2586 "re-registering an older version must not perform an implicit rollback"
2587 );
2588 assert!(
2589 db.register_skill_version("browser-flow", 1, "mutated v1", "agent-created")
2590 .is_err(),
2591 "recorded versions must be immutable"
2592 );
2593 assert_eq!(
2594 db.get_skill_lifecycle("browser-flow")
2595 .unwrap()
2596 .unwrap()
2597 .state,
2598 "candidate"
2599 );
2600 for index in 1..=2 {
2601 let run_id = format!("bad-{index}");
2602 db.start_experience_run(&run_id, None, 7, "web", "interactive", "browse", None)
2603 .unwrap();
2604 db.log_skill_activation("browser-flow", 7).unwrap();
2605 db.record_verifier_result(
2606 &run_id,
2607 "deterministic",
2608 "browser_assertion",
2609 "failed",
2610 1.0,
2611 Some("wrong page"),
2612 None,
2613 None,
2614 )
2615 .unwrap();
2616 }
2617 assert_eq!(
2618 db.get_skill_lifecycle("browser-flow")
2619 .unwrap()
2620 .unwrap()
2621 .state,
2622 "degraded"
2623 );
2624 let (version, content) = db
2625 .rollback_skill("browser-flow", None, "operator rollback")
2626 .unwrap()
2627 .unwrap();
2628 assert_eq!(version, 1);
2629 assert_eq!(content, "safe v1");
2630 assert_eq!(
2631 db.get_skill_lifecycle("browser-flow")
2632 .unwrap()
2633 .unwrap()
2634 .state,
2635 "trusted"
2636 );
2637 cleanup(&dir);
2638 }
2639
2640 #[test]
2641 fn comparative_reflection_shadow_promotion_and_automatic_rollback() {
2642 let (db, dir) = test_db();
2643 db.register_skill_version("reflective-deploy", 1, "deploy safely", "built-in")
2644 .unwrap();
2645 for (run_id, verdict, summary, errors) in [
2646 ("reflection-failure", "failed", "health timeout", 1),
2647 ("reflection-success", "passed", "healthy deployment", 0),
2648 ] {
2649 db.start_experience_run(
2650 run_id,
2651 None,
2652 92,
2653 "web",
2654 "interactive",
2655 "deploy service",
2656 Some("os=linux"),
2657 )
2658 .unwrap();
2659 db.log_skill_activation("reflective-deploy", 92).unwrap();
2660 db.update_experience_metrics(run_id, 10, 5, 1, 2, errors, Some(1.0))
2661 .unwrap();
2662 db.finish_experience_run(run_id, "completed", Some(summary), 100)
2663 .unwrap();
2664 db.record_verifier_result(
2665 run_id,
2666 "deterministic",
2667 "health",
2668 verdict,
2669 1.0,
2670 Some(summary),
2671 None,
2672 None,
2673 )
2674 .unwrap();
2675 }
2676
2677 let comparisons = db.get_experience_comparisons(Some(92), 10).unwrap();
2678 assert_eq!(comparisons.len(), 1);
2679 assert_eq!(comparisons[0].task_family, "deployment");
2680 assert_eq!(comparisons[0].success_run_id, "reflection-success");
2681 assert_eq!(comparisons[0].failure_run_id, "reflection-failure");
2682 assert!(comparisons[0].minimal_difference.contains("tool_errors"));
2683
2684 let claims = db.get_learning_claims(Some(92), 10).unwrap();
2685 assert_eq!(claims.len(), 1);
2686 assert_eq!(claims[0].status, "candidate");
2687 assert_eq!(claims[0].evidence["success_run_id"], "reflection-success");
2688 assert!(claims[0].counterexamples.is_array());
2689
2690 let candidate = db
2691 .create_skill_candidate_from_claim(&claims[0].claim_id, 92)
2692 .unwrap();
2693 assert_eq!(candidate.base_version, 1);
2694 assert_eq!(candidate.candidate_version, 2);
2695 assert!(candidate.content.contains("Learned guardrail"));
2696 assert_eq!(
2697 db.get_skill_lifecycle("reflective-deploy")
2698 .unwrap()
2699 .unwrap()
2700 .active_version,
2701 1,
2702 "candidate creation and shadow evaluation must not activate it"
2703 );
2704
2705 let mut evaluation = None;
2706 for index in 0..3 {
2707 let pair = format!("pair-{index}");
2708 db.record_shadow_observation(
2709 &candidate.candidate_id,
2710 92,
2711 &pair,
2712 "baseline",
2713 "reflection-success",
2714 "passed",
2715 1.0,
2716 100,
2717 Some("baseline passed"),
2718 )
2719 .unwrap();
2720 evaluation = Some(
2721 db.record_shadow_observation(
2722 &candidate.candidate_id,
2723 92,
2724 &pair,
2725 "candidate",
2726 "reflection-failure",
2727 "failed",
2728 0.8,
2729 80,
2730 Some("candidate regressed"),
2731 )
2732 .unwrap(),
2733 );
2734 }
2735 assert_eq!(evaluation.as_ref().unwrap().verdict, "failed");
2736 assert_eq!(evaluation.as_ref().unwrap().regression_count, 3);
2737
2738 for index in 0..3 {
2739 let pair = format!("pair-{index}");
2740 db.record_shadow_observation(
2741 &candidate.candidate_id,
2742 92,
2743 &pair,
2744 "baseline",
2745 "reflection-failure",
2746 "failed",
2747 1.0,
2748 100,
2749 Some("baseline failed"),
2750 )
2751 .unwrap();
2752 evaluation = Some(
2753 db.record_shadow_observation(
2754 &candidate.candidate_id,
2755 92,
2756 &pair,
2757 "candidate",
2758 "reflection-success",
2759 "passed",
2760 0.8,
2761 80,
2762 Some("candidate passed"),
2763 )
2764 .unwrap(),
2765 );
2766 }
2767 let evaluation = evaluation.unwrap();
2768 assert_eq!(evaluation.sample_count, 3);
2769 assert_eq!(evaluation.verdict, "passed");
2770 assert!(evaluation.candidate_utility_lower_bound > 0.4);
2771 assert_eq!(
2772 db.get_skill_lifecycle("reflective-deploy")
2773 .unwrap()
2774 .unwrap()
2775 .active_version,
2776 1,
2777 "shadow pass still requires an explicit governed promotion"
2778 );
2779
2780 let promoted = db
2781 .promote_shadow_candidate(&candidate.candidate_id, 92)
2782 .unwrap();
2783 assert_eq!(promoted, ("reflective-deploy".to_string(), 2));
2784 assert_eq!(
2785 db.get_skill_lifecycle("reflective-deploy")
2786 .unwrap()
2787 .unwrap()
2788 .active_version,
2789 2
2790 );
2791
2792 for index in 0..2 {
2793 let run_id = format!("promoted-regression-{index}");
2794 db.start_experience_run(
2795 &run_id,
2796 None,
2797 92,
2798 "web",
2799 "interactive",
2800 "deploy service",
2801 Some("os=linux"),
2802 )
2803 .unwrap();
2804 db.log_skill_activation("reflective-deploy", 92).unwrap();
2805 db.record_verifier_result(
2806 &run_id,
2807 "deterministic",
2808 "health",
2809 "failed",
2810 1.0,
2811 Some("new regression"),
2812 None,
2813 None,
2814 )
2815 .unwrap();
2816 }
2817 let lifecycle = db
2818 .get_skill_lifecycle("reflective-deploy")
2819 .unwrap()
2820 .unwrap();
2821 assert_eq!(lifecycle.active_version, 1);
2822 assert_eq!(lifecycle.state, "trusted");
2823 assert!(lifecycle
2824 .state_reason
2825 .as_deref()
2826 .unwrap_or_default()
2827 .contains("automatic rollback"));
2828 let journal = db.get_learning_journal(Some(92), 50).unwrap();
2829 assert!(journal
2830 .iter()
2831 .any(|event| event.event_type == "claim_distilled"));
2832 assert!(journal
2833 .iter()
2834 .any(|event| event.event_type == "candidate_promoted"));
2835 assert!(journal
2836 .iter()
2837 .any(|event| event.event_type == "automatic_rollback"));
2838 assert!(db.clear_chat_memory(92).unwrap());
2839 assert!(db
2840 .get_experience_comparisons(Some(92), 10)
2841 .unwrap()
2842 .is_empty());
2843 assert!(db.get_learning_claims(Some(92), 10).unwrap().is_empty());
2844 assert!(db.get_skill_candidates(Some(92), 10).unwrap().is_empty());
2845 assert!(db.get_shadow_evaluations(Some(92)).unwrap().is_empty());
2846 cleanup(&dir);
2847 }
2848
2849 #[test]
2850 fn learning_substrate_does_not_assign_shared_success_to_multiple_skills() {
2851 let (db, dir) = test_db();
2852 for skill in ["planner", "executor"] {
2853 db.register_skill_version(skill, 1, "instructions", "agent-created")
2854 .unwrap();
2855 }
2856 db.start_experience_run(
2857 "shared-run",
2858 None,
2859 13,
2860 "web",
2861 "interactive",
2862 "combined task",
2863 Some("channel=web"),
2864 )
2865 .unwrap();
2866 db.log_skill_activation("planner", 13).unwrap();
2867 db.log_skill_activation("executor", 13).unwrap();
2868 db.record_verifier_result(
2869 "shared-run",
2870 "deterministic",
2871 "tests",
2872 "passed",
2873 1.0,
2874 Some("all tests passed"),
2875 None,
2876 None,
2877 )
2878 .unwrap();
2879 for skill in ["planner", "executor"] {
2880 assert_eq!(
2881 db.get_skill_lifecycle(skill).unwrap().unwrap().state,
2882 "candidate",
2883 "ambiguous shared credit must not promote either skill"
2884 );
2885 }
2886 assert!(db
2887 .get_skill_learning_summaries()
2888 .unwrap()
2889 .iter()
2890 .all(|summary| summary.skill_name != "planner" || summary.total_outcomes == 0));
2891 cleanup(&dir);
2892 }
2893
2894 #[test]
2895 fn learning_substrate_governance_policy_is_validated_and_persisted() {
2896 let (db, dir) = test_db();
2897 db.start_experience_run(
2898 "unique-run",
2899 None,
2900 15,
2901 "web",
2902 "interactive",
2903 "uniqueness",
2904 None,
2905 )
2906 .unwrap();
2907 assert!(db
2908 .start_experience_run(
2909 "unique-run",
2910 None,
2911 15,
2912 "web",
2913 "interactive",
2914 "replacement attempt",
2915 None,
2916 )
2917 .is_err());
2918 assert!(db
2919 .record_verifier_result(
2920 "missing-run",
2921 "human",
2922 "review",
2923 "passed",
2924 1.0,
2925 None,
2926 None,
2927 None,
2928 )
2929 .is_err());
2930 assert!(db
2931 .finish_experience_run("missing-run", "completed", None, 0)
2932 .is_err());
2933 let defaults = db.get_skill_governance_policy().unwrap();
2934 assert_eq!(defaults.trial_min_outcomes, 3);
2935 assert_eq!(defaults.utility_confidence_z, 1.96);
2936 assert_eq!(defaults.trial_promote_utility_lower_bound, 0.4);
2937 assert_eq!(defaults.failure_pattern_min_failures, 2);
2938 assert_eq!(defaults.failure_pattern_cooldown_hours, 24);
2939 assert_eq!(defaults.failure_pattern_recovery_successes, 2);
2940 assert_eq!(defaults.shadow_min_samples, 3);
2941 assert_eq!(defaults.shadow_promote_utility_margin, 0.05);
2942 assert_eq!(defaults.shadow_max_cost_ratio, 1.2);
2943 assert_eq!(defaults.shadow_max_regressions, 0);
2944
2945 let mut invalid = defaults.clone();
2946 invalid.trial_degrade_rate = invalid.trial_promote_rate;
2947 assert!(db.update_skill_governance_policy(&invalid).is_err());
2948 let mut invalid_confidence = defaults.clone();
2949 invalid_confidence.utility_confidence_z = -1.0;
2950 assert!(db
2951 .update_skill_governance_policy(&invalid_confidence)
2952 .is_err());
2953 let mut invalid_cooldown = defaults.clone();
2954 invalid_cooldown.failure_pattern_cooldown_hours = 0;
2955 assert!(db
2956 .update_skill_governance_policy(&invalid_cooldown)
2957 .is_err());
2958 let mut invalid_shadow_cost = defaults.clone();
2959 invalid_shadow_cost.shadow_max_cost_ratio = 0.9;
2960 assert!(db
2961 .update_skill_governance_policy(&invalid_shadow_cost)
2962 .is_err());
2963
2964 let mut updated = defaults;
2965 updated.candidate_failures_to_degrade = 3;
2966 updated.trial_min_outcomes = 4;
2967 updated.trusted_min_outcomes = 6;
2968 updated.utility_confidence_z = 1.645;
2969 updated.trial_promote_utility_lower_bound = 0.3;
2970 updated.failure_pattern_min_failures = 3;
2971 updated.failure_pattern_cooldown_hours = 12;
2972 updated.failure_pattern_recovery_successes = 1;
2973 updated.shadow_min_samples = 4;
2974 updated.shadow_promote_utility_margin = 0.1;
2975 updated.shadow_max_cost_ratio = 1.5;
2976 updated.shadow_max_regressions = 1;
2977 db.update_skill_governance_policy(&updated).unwrap();
2978 let saved = db.get_skill_governance_policy().unwrap();
2979 assert_eq!(saved.candidate_failures_to_degrade, 3);
2980 assert_eq!(saved.utility_confidence_z, 1.645);
2981 assert_eq!(saved.trial_promote_utility_lower_bound, 0.3);
2982 assert_eq!(saved.failure_pattern_min_failures, 3);
2983 assert_eq!(saved.failure_pattern_cooldown_hours, 12);
2984 assert_eq!(saved.failure_pattern_recovery_successes, 1);
2985 assert_eq!(saved.shadow_min_samples, 4);
2986 assert_eq!(saved.shadow_promote_utility_margin, 0.1);
2987 assert_eq!(saved.shadow_max_cost_ratio, 1.5);
2988 assert_eq!(saved.shadow_max_regressions, 1);
2989 cleanup(&dir);
2990 }
2991
2992 #[test]
2993 fn migration_39_creates_failure_patterns_and_rejection_audit() {
2994 let (db, dir) = test_db();
2995 db.register_skill_version("legacy-failure", 1, "deploy", "built-in")
2996 .unwrap();
2997 for index in 0..2 {
2998 let run_id = format!("legacy-failure-{index}");
2999 db.start_experience_run(
3000 &run_id,
3001 None,
3002 81,
3003 "web",
3004 "interactive",
3005 "deploy service",
3006 Some("os=linux"),
3007 )
3008 .unwrap();
3009 db.log_skill_activation("legacy-failure", 81).unwrap();
3010 db.record_verifier_result(
3011 &run_id,
3012 "deterministic",
3013 "health",
3014 "failed",
3015 1.0,
3016 Some("network connect failed"),
3017 None,
3018 None,
3019 )
3020 .unwrap();
3021 }
3022 {
3023 let conn = db.lock_conn();
3024 conn.execute_batch(
3025 "DROP TABLE skill_failure_pattern_evidence;
3026 DROP TABLE skill_failure_patterns;
3027 DROP TABLE experience_retrieval_rejections;",
3028 )
3029 .unwrap();
3030 set_schema_version(&conn, 38).unwrap();
3031 apply_schema_migrations(&conn).unwrap();
3032 let patterns: i64 = conn
3033 .query_row(
3034 "SELECT COUNT(*) FROM sqlite_master
3035 WHERE type='table' AND name='skill_failure_patterns'",
3036 [],
3037 |row| row.get(0),
3038 )
3039 .unwrap();
3040 let rejections: i64 = conn
3041 .query_row(
3042 "SELECT COUNT(*) FROM sqlite_master
3043 WHERE type='table' AND name='experience_retrieval_rejections'",
3044 [],
3045 |row| row.get(0),
3046 )
3047 .unwrap();
3048 assert_eq!(patterns, 1);
3049 assert_eq!(rejections, 1);
3050 assert_eq!(get_schema_version(&conn).unwrap(), SCHEMA_VERSION_CURRENT);
3051 }
3052 let backfilled = db
3053 .get_skill_failure_patterns(Some("legacy-failure"))
3054 .unwrap();
3055 assert_eq!(backfilled.len(), 1);
3056 assert_eq!(backfilled[0].failure_count, 2);
3057 assert_eq!(backfilled[0].error_category, "network");
3058 assert_eq!(backfilled[0].state, "active");
3059 cleanup(&dir);
3060 }
3061
3062 #[test]
3063 fn utility_lower_bound_gates_promotion_and_summaries_are_task_stratified() {
3064 let (db, dir) = test_db();
3065 db.register_skill_version("bounded", 1, "instructions", "agent-created")
3066 .unwrap();
3067 let mut policy = db.get_skill_governance_policy().unwrap();
3068 policy.trial_promote_rate = 0.6;
3069 policy.trial_promote_utility_lower_bound = 0.5;
3070 db.update_skill_governance_policy(&policy).unwrap();
3071
3072 for index in 0..3 {
3073 let run_id = format!("bounded-pass-{index}");
3074 db.start_experience_run(
3075 &run_id,
3076 None,
3077 61,
3078 "web",
3079 "interactive",
3080 "deploy service safely",
3081 Some("env=prod"),
3082 )
3083 .unwrap();
3084 db.log_skill_activation("bounded", 61).unwrap();
3085 db.record_verifier_result(
3086 &run_id,
3087 "deterministic",
3088 "check",
3089 "passed",
3090 1.0,
3091 None,
3092 None,
3093 None,
3094 )
3095 .unwrap();
3096 db.finish_experience_run(&run_id, "completed", None, 1)
3097 .unwrap();
3098 }
3099 assert_eq!(
3100 db.get_skill_lifecycle("bounded").unwrap().unwrap().state,
3101 "trial",
3102 "three perfect samples remain too uncertain at z=1.96"
3103 );
3104
3105 db.start_experience_run(
3106 "bounded-pass-3",
3107 None,
3108 61,
3109 "web",
3110 "interactive",
3111 "deploy service safely",
3112 Some("env=prod"),
3113 )
3114 .unwrap();
3115 db.log_skill_activation("bounded", 61).unwrap();
3116 db.record_verifier_result(
3117 "bounded-pass-3",
3118 "deterministic",
3119 "check",
3120 "passed",
3121 1.0,
3122 None,
3123 None,
3124 None,
3125 )
3126 .unwrap();
3127 db.finish_experience_run("bounded-pass-3", "completed", None, 1)
3128 .unwrap();
3129 assert_eq!(
3130 db.get_skill_lifecycle("bounded").unwrap().unwrap().state,
3131 "trusted"
3132 );
3133
3134 let summaries = db
3135 .get_skill_task_utility_summaries(Some("bounded"))
3136 .unwrap();
3137 assert_eq!(summaries.len(), 1);
3138 assert_eq!(summaries[0].task_family, "deployment");
3139 assert_eq!(summaries[0].total_outcomes, 4);
3140 assert!(summaries[0].utility_lower_bound > 0.5);
3141 cleanup(&dir);
3142 }
3143}