1use crate::providers::ProviderName;
5use serde::{Deserialize, Serialize};
6
7fn default_min_failures() -> u32 {
8 3
9}
10
11fn default_improve_threshold() -> f64 {
12 0.7
13}
14
15fn default_rollback_threshold() -> f64 {
16 0.5
17}
18
19fn default_min_evaluations() -> u32 {
20 5
21}
22
23fn default_max_versions() -> u32 {
24 10
25}
26
27fn default_cooldown_minutes() -> u64 {
28 60
29}
30
31fn default_correction_detection() -> bool {
32 true
33}
34
35fn default_correction_confidence_threshold() -> f32 {
36 0.6
37}
38
39fn default_judge_adaptive_low() -> f32 {
40 0.5
41}
42
43fn default_judge_adaptive_high() -> f32 {
44 0.8
45}
46
47fn default_judge_llm_timeout_secs() -> u64 {
48 30
49}
50
51fn default_correction_recall_limit() -> u32 {
52 3
53}
54
55fn default_correction_min_similarity() -> f32 {
56 0.75
57}
58
59fn default_auto_promote_min_uses() -> u32 {
60 50
61}
62
63fn default_auto_promote_threshold() -> f64 {
64 0.95
65}
66
67fn default_auto_demote_min_uses() -> u32 {
68 30
69}
70
71fn default_auto_demote_threshold() -> f64 {
72 0.40
73}
74
75fn default_min_sessions_before_promote() -> u32 {
76 2
77}
78
79fn default_min_sessions_before_demote() -> u32 {
80 1
81}
82
83fn default_max_auto_sections() -> u32 {
84 3
85}
86
87fn default_arise_min_tool_calls() -> u32 {
88 2
89}
90
91fn default_stem_min_occurrences() -> u32 {
92 3
93}
94
95fn default_stem_min_success_rate() -> f64 {
96 0.8
97}
98
99fn default_stem_retention_days() -> u32 {
100 90
101}
102
103fn default_stem_pattern_window_days() -> u32 {
104 30
105}
106
107fn default_erl_max_heuristics_per_skill() -> u32 {
108 3
109}
110
111fn default_erl_dedup_threshold() -> f32 {
112 0.9
113}
114
115fn default_erl_min_confidence() -> f64 {
116 0.5
117}
118
119fn default_d2skill_max_corrections() -> u32 {
120 3
121}
122
123fn default_trace_extraction_max_turns() -> u32 {
124 200
125}
126
127fn default_trace_extraction_max_sessions_queued() -> usize {
128 10
129}
130
131fn default_trace_extraction_max_input_bytes() -> usize {
132 131_072 }
134
135fn default_merge_threshold() -> f32 {
136 0.75
137}
138
139fn default_dedup_threshold() -> f32 {
140 0.90
141}
142
143fn default_skill_merge_enabled() -> bool {
144 true
145}
146
147fn default_heuristic_promotion_threshold() -> u32 {
148 5
149}
150
151fn default_heuristic_promotion_interval_hours() -> u64 {
152 24
153}
154
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
157#[serde(rename_all = "lowercase")]
158#[non_exhaustive]
159pub enum DetectorMode {
160 #[default]
162 Regex,
163 Judge,
171 Model,
179}
180
181#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct LearningConfig {
197 #[serde(default)]
199 pub enabled: bool,
200 #[serde(default)]
202 pub auto_activate: bool,
203 #[serde(default = "default_min_failures")]
204 pub min_failures: u32,
205 #[serde(default = "default_improve_threshold")]
206 pub improve_threshold: f64,
207 #[serde(default = "default_rollback_threshold")]
208 pub rollback_threshold: f64,
209 #[serde(default = "default_min_evaluations")]
210 pub min_evaluations: u32,
211 #[serde(default = "default_max_versions")]
212 pub max_versions: u32,
213 #[serde(default = "default_cooldown_minutes")]
214 pub cooldown_minutes: u64,
215 #[serde(default = "default_correction_detection")]
216 pub correction_detection: bool,
217 #[serde(default = "default_correction_confidence_threshold")]
218 pub correction_confidence_threshold: f32,
219 #[serde(default)]
221 pub detector_mode: DetectorMode,
222 #[serde(default)]
224 pub judge_model: String,
225 #[serde(default)]
230 pub feedback_provider: ProviderName,
231 #[serde(default = "default_judge_adaptive_low")]
233 pub judge_adaptive_low: f32,
234 #[serde(default = "default_judge_adaptive_high")]
236 pub judge_adaptive_high: f32,
237 #[serde(default = "default_judge_llm_timeout_secs")]
240 pub judge_llm_timeout_secs: u64,
241 #[serde(default = "default_correction_recall_limit")]
242 pub correction_recall_limit: u32,
243 #[serde(default = "default_correction_min_similarity")]
244 pub correction_min_similarity: f32,
245 #[serde(default = "default_auto_promote_min_uses")]
246 pub auto_promote_min_uses: u32,
247 #[serde(default = "default_auto_promote_threshold")]
248 pub auto_promote_threshold: f64,
249 #[serde(default = "default_auto_demote_min_uses")]
250 pub auto_demote_min_uses: u32,
251 #[serde(default = "default_auto_demote_threshold")]
252 pub auto_demote_threshold: f64,
253 #[serde(default)]
258 pub cross_session_rollout: bool,
259 #[serde(default = "default_min_sessions_before_promote")]
262 pub min_sessions_before_promote: u32,
263 #[serde(default = "default_min_sessions_before_demote")]
269 pub min_sessions_before_demote: u32,
270 #[serde(default = "default_max_auto_sections")]
274 pub max_auto_sections: u32,
275 #[serde(default)]
279 pub domain_success_gate: bool,
280
281 #[serde(default)]
284 pub arise_enabled: bool,
285 #[serde(default = "default_arise_min_tool_calls")]
287 pub arise_min_tool_calls: u32,
288 #[serde(default)]
291 pub arise_trace_provider: ProviderName,
292
293 #[serde(default)]
296 pub stem_enabled: bool,
297 #[serde(default = "default_stem_min_occurrences")]
299 pub stem_min_occurrences: u32,
300 #[serde(default = "default_stem_min_success_rate")]
302 pub stem_min_success_rate: f64,
303 #[serde(default)]
306 pub stem_provider: ProviderName,
307 #[serde(default = "default_stem_retention_days")]
309 pub stem_retention_days: u32,
310 #[serde(default = "default_stem_pattern_window_days")]
312 pub stem_pattern_window_days: u32,
313
314 #[serde(default)]
317 pub erl_enabled: bool,
318 #[serde(default)]
321 pub erl_extract_provider: ProviderName,
322 #[serde(default = "default_erl_max_heuristics_per_skill")]
324 pub erl_max_heuristics_per_skill: u32,
325 #[serde(default = "default_erl_dedup_threshold")]
328 pub erl_dedup_threshold: f32,
329 #[serde(default = "default_erl_min_confidence")]
331 pub erl_min_confidence: f64,
332
333 #[serde(default)]
340 pub d2skill_enabled: bool,
341 #[serde(default = "default_d2skill_max_corrections")]
343 pub d2skill_max_corrections: u32,
344 #[serde(default)]
347 pub d2skill_provider: ProviderName,
348
349 #[serde(default)]
352 pub trace_extraction_enabled: bool,
353 #[serde(default)]
356 pub trace_extraction_provider: ProviderName,
357 #[serde(default)]
360 pub trace_extraction_embedding_provider: ProviderName,
361 #[serde(default = "default_trace_extraction_max_turns")]
363 pub trace_extraction_max_turns: u32,
364 #[serde(default = "default_trace_extraction_max_sessions_queued")]
366 pub trace_extraction_max_sessions_queued: usize,
367 #[serde(default = "default_trace_extraction_max_input_bytes")]
369 pub trace_extraction_max_input_bytes: usize,
370
371 #[serde(default = "default_skill_merge_enabled")]
377 pub skill_merge_enabled: bool,
378 #[serde(default)]
381 pub skill_merge_provider: ProviderName,
382 #[serde(default = "default_merge_threshold")]
386 pub merge_threshold: f32,
387 #[serde(default = "default_dedup_threshold")]
391 pub dedup_threshold: f32,
392
393 #[serde(default)]
399 pub heuristic_promotion_enabled: bool,
400 #[serde(default)]
405 pub heuristic_promotion_provider: ProviderName,
406 #[serde(default = "default_heuristic_promotion_threshold")]
410 pub heuristic_promotion_threshold: u32,
411 #[serde(default = "default_heuristic_promotion_interval_hours")]
413 pub heuristic_promotion_interval_hours: u64,
414}
415
416impl Default for LearningConfig {
417 fn default() -> Self {
418 Self {
419 enabled: false,
420 auto_activate: false,
421 min_failures: default_min_failures(),
422 improve_threshold: default_improve_threshold(),
423 rollback_threshold: default_rollback_threshold(),
424 min_evaluations: default_min_evaluations(),
425 max_versions: default_max_versions(),
426 cooldown_minutes: default_cooldown_minutes(),
427 correction_detection: default_correction_detection(),
428 correction_confidence_threshold: default_correction_confidence_threshold(),
429 detector_mode: DetectorMode::default(),
430 judge_model: String::new(),
431 feedback_provider: ProviderName::default(),
432 judge_adaptive_low: default_judge_adaptive_low(),
433 judge_adaptive_high: default_judge_adaptive_high(),
434 judge_llm_timeout_secs: default_judge_llm_timeout_secs(),
435 correction_recall_limit: default_correction_recall_limit(),
436 correction_min_similarity: default_correction_min_similarity(),
437 auto_promote_min_uses: default_auto_promote_min_uses(),
438 auto_promote_threshold: default_auto_promote_threshold(),
439 auto_demote_min_uses: default_auto_demote_min_uses(),
440 auto_demote_threshold: default_auto_demote_threshold(),
441 cross_session_rollout: false,
442 min_sessions_before_promote: default_min_sessions_before_promote(),
443 min_sessions_before_demote: default_min_sessions_before_demote(),
444 max_auto_sections: default_max_auto_sections(),
445 domain_success_gate: false,
446 arise_enabled: false,
447 arise_min_tool_calls: default_arise_min_tool_calls(),
448 arise_trace_provider: ProviderName::default(),
449 stem_enabled: false,
450 stem_min_occurrences: default_stem_min_occurrences(),
451 stem_min_success_rate: default_stem_min_success_rate(),
452 stem_provider: ProviderName::default(),
453 stem_retention_days: default_stem_retention_days(),
454 stem_pattern_window_days: default_stem_pattern_window_days(),
455 erl_enabled: false,
456 erl_extract_provider: ProviderName::default(),
457 erl_max_heuristics_per_skill: default_erl_max_heuristics_per_skill(),
458 erl_dedup_threshold: default_erl_dedup_threshold(),
459 erl_min_confidence: default_erl_min_confidence(),
460 d2skill_enabled: false,
461 d2skill_max_corrections: default_d2skill_max_corrections(),
462 d2skill_provider: ProviderName::default(),
463 trace_extraction_enabled: false,
464 trace_extraction_provider: ProviderName::default(),
465 trace_extraction_embedding_provider: ProviderName::default(),
466 trace_extraction_max_turns: default_trace_extraction_max_turns(),
467 trace_extraction_max_sessions_queued: default_trace_extraction_max_sessions_queued(),
468 trace_extraction_max_input_bytes: default_trace_extraction_max_input_bytes(),
469 skill_merge_enabled: default_skill_merge_enabled(),
470 skill_merge_provider: ProviderName::default(),
471 merge_threshold: default_merge_threshold(),
472 dedup_threshold: default_dedup_threshold(),
473 heuristic_promotion_enabled: false,
474 heuristic_promotion_provider: ProviderName::default(),
475 heuristic_promotion_threshold: default_heuristic_promotion_threshold(),
476 heuristic_promotion_interval_hours: default_heuristic_promotion_interval_hours(),
477 }
478 }
479}
480
481impl LearningConfig {
482 pub fn validate(&self) -> Result<(), String> {
488 if self.merge_threshold >= self.dedup_threshold {
489 return Err(format!(
490 "skills.learning.merge_threshold ({}) must be strictly less than dedup_threshold ({})",
491 self.merge_threshold, self.dedup_threshold
492 ));
493 }
494 Ok(())
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn detector_mode_default_is_regex() {
504 assert_eq!(DetectorMode::default(), DetectorMode::Regex);
505 }
506
507 #[test]
508 fn detector_mode_serde_roundtrip() {
509 for (mode, expected_str) in [
510 (DetectorMode::Regex, "\"regex\""),
511 (DetectorMode::Judge, "\"judge\""),
512 (DetectorMode::Model, "\"model\""),
513 ] {
514 let serialized = serde_json::to_string(&mode).unwrap();
515 assert_eq!(serialized, expected_str, "serialize {mode:?}");
516 let deserialized: DetectorMode = serde_json::from_str(&serialized).unwrap();
517 assert_eq!(deserialized, mode, "deserialize {mode:?}");
518 }
519 }
520
521 #[test]
522 fn learning_config_default_detector_mode_is_regex() {
523 let cfg = LearningConfig::default();
524 assert_eq!(cfg.detector_mode, DetectorMode::Regex);
525 }
526
527 #[test]
528 fn learning_config_default_feedback_provider_is_empty() {
529 let cfg = LearningConfig::default();
530 assert!(cfg.feedback_provider.is_empty());
531 }
532
533 #[test]
534 fn learning_config_deserialize_model_mode() {
535 let toml = r#"detector_mode = "model"
536feedback_provider = "fast""#;
537 let cfg: LearningConfig = toml::from_str(toml).unwrap();
538 assert_eq!(cfg.detector_mode, DetectorMode::Model);
539 assert_eq!(cfg.feedback_provider, "fast");
540 }
541
542 #[test]
543 fn learning_config_deserialize_empty_feedback_provider() {
544 let toml = r#"detector_mode = "model""#;
545 let cfg: LearningConfig = toml::from_str(toml).unwrap();
546 assert_eq!(cfg.detector_mode, DetectorMode::Model);
547 assert!(
548 cfg.feedback_provider.is_empty(),
549 "empty feedback_provider must default to empty string (fallback to primary)"
550 );
551 }
552
553 #[test]
554 fn learning_config_deserialize_empty_section_uses_defaults() {
555 let cfg: LearningConfig = toml::from_str("").unwrap();
556 assert!(!cfg.enabled);
557 assert_eq!(cfg.min_failures, 3);
558 assert_eq!(cfg.detector_mode, DetectorMode::Regex);
559 assert!(cfg.feedback_provider.is_empty());
560 }
561
562 #[test]
563 fn judge_llm_timeout_secs_default_and_roundtrip() {
564 let cfg = LearningConfig::default();
565 assert_eq!(cfg.judge_llm_timeout_secs, 30);
566 let cfg: LearningConfig = toml::from_str("judge_llm_timeout_secs = 60").unwrap();
567 assert_eq!(cfg.judge_llm_timeout_secs, 60);
568 }
569
570 #[test]
571 fn learning_config_defaults_for_new_fields() {
572 let cfg = LearningConfig::default();
573 assert!(!cfg.cross_session_rollout);
574 assert_eq!(cfg.min_sessions_before_promote, 2);
575 assert_eq!(cfg.max_auto_sections, 3);
576 assert!(!cfg.domain_success_gate);
577 }
578
579 #[test]
580 fn learning_config_min_sessions_before_demote_default() {
581 let cfg = LearningConfig::default();
582 assert_eq!(cfg.min_sessions_before_demote, 1);
583 }
584
585 #[test]
586 fn arise_stem_erl_defaults() {
587 let cfg = LearningConfig::default();
588 assert!(!cfg.arise_enabled);
589 assert_eq!(cfg.arise_min_tool_calls, 2);
590 assert!(cfg.arise_trace_provider.is_empty());
591 assert!(!cfg.stem_enabled);
592 assert_eq!(cfg.stem_min_occurrences, 3);
593 assert!((cfg.stem_min_success_rate - 0.8).abs() < f64::EPSILON);
594 assert!(cfg.stem_provider.is_empty());
595 assert_eq!(cfg.stem_retention_days, 90);
596 assert_eq!(cfg.stem_pattern_window_days, 30);
597 assert!(!cfg.erl_enabled);
598 assert!(cfg.erl_extract_provider.is_empty());
599 assert_eq!(cfg.erl_max_heuristics_per_skill, 3);
600 assert!((cfg.erl_dedup_threshold - 0.9).abs() < f32::EPSILON);
601 assert!((cfg.erl_min_confidence - 0.5).abs() < f64::EPSILON);
602 }
603
604 #[test]
605 fn arise_stem_erl_serde_roundtrip() {
606 let toml = r#"
607arise_enabled = true
608arise_min_tool_calls = 3
609arise_trace_provider = "fast"
610stem_enabled = true
611stem_min_occurrences = 5
612stem_min_success_rate = 0.9
613stem_provider = "mid"
614stem_retention_days = 60
615stem_pattern_window_days = 14
616erl_enabled = true
617erl_extract_provider = "fast"
618erl_max_heuristics_per_skill = 5
619erl_dedup_threshold = 0.85
620erl_min_confidence = 0.6
621"#;
622 let cfg: LearningConfig = toml::from_str(toml).unwrap();
623 assert!(cfg.arise_enabled);
624 assert_eq!(cfg.arise_min_tool_calls, 3);
625 assert_eq!(cfg.arise_trace_provider, "fast");
626 assert!(cfg.stem_enabled);
627 assert_eq!(cfg.stem_min_occurrences, 5);
628 assert!((cfg.stem_min_success_rate - 0.9).abs() < f64::EPSILON);
629 assert_eq!(cfg.stem_provider, "mid");
630 assert_eq!(cfg.stem_retention_days, 60);
631 assert_eq!(cfg.stem_pattern_window_days, 14);
632 assert!(cfg.erl_enabled);
633 assert_eq!(cfg.erl_extract_provider, "fast");
634 assert_eq!(cfg.erl_max_heuristics_per_skill, 5);
635 assert!((cfg.erl_dedup_threshold - 0.85_f32).abs() < f32::EPSILON);
636 assert!((cfg.erl_min_confidence - 0.6).abs() < f64::EPSILON);
637 }
638
639 #[test]
640 fn arise_stem_erl_empty_section_uses_defaults() {
641 let cfg: LearningConfig = toml::from_str("").unwrap();
642 assert!(!cfg.arise_enabled);
643 assert!(!cfg.stem_enabled);
644 assert!(!cfg.erl_enabled);
645 }
646
647 #[test]
648 fn autoskill_a2_defaults() {
649 let cfg = LearningConfig::default();
650 assert!(cfg.skill_merge_enabled);
651 assert!(cfg.skill_merge_provider.is_empty());
652 assert!((cfg.merge_threshold - 0.75_f32).abs() < f32::EPSILON);
653 assert!((cfg.dedup_threshold - 0.90_f32).abs() < f32::EPSILON);
654 }
655
656 #[test]
657 fn validate_merge_lt_dedup_ok() {
658 let cfg = LearningConfig::default(); assert!(cfg.validate().is_ok());
660 }
661
662 #[test]
663 fn validate_merge_eq_dedup_err() {
664 let cfg = LearningConfig {
665 merge_threshold: 0.90,
666 dedup_threshold: 0.90,
667 ..LearningConfig::default()
668 };
669 let err = cfg.validate().unwrap_err();
670 assert!(
671 err.contains("merge_threshold") && err.contains("dedup_threshold"),
672 "unexpected error: {err}"
673 );
674 }
675
676 #[test]
677 fn validate_merge_gt_dedup_err() {
678 let cfg = LearningConfig {
679 merge_threshold: 0.95,
680 dedup_threshold: 0.90,
681 ..LearningConfig::default()
682 };
683 let err = cfg.validate().unwrap_err();
684 assert!(
685 err.contains("merge_threshold") && err.contains("dedup_threshold"),
686 "unexpected error: {err}"
687 );
688 }
689
690 #[test]
691 fn autoskill_a2_dedup_threshold_default_and_roundtrip() {
692 let cfg = LearningConfig::default();
693 assert!((cfg.dedup_threshold - 0.90_f32).abs() < f32::EPSILON);
694 let cfg: LearningConfig = toml::from_str("dedup_threshold = 0.95").unwrap();
695 assert!((cfg.dedup_threshold - 0.95_f32).abs() < f32::EPSILON);
696 }
697
698 #[test]
699 fn learning_config_new_fields_serde_roundtrip() {
700 let toml = r"
701cross_session_rollout = true
702min_sessions_before_promote = 5
703min_sessions_before_demote = 2
704max_auto_sections = 4
705domain_success_gate = true
706";
707 let cfg: LearningConfig = toml::from_str(toml).unwrap();
708 assert!(cfg.cross_session_rollout);
709 assert_eq!(cfg.min_sessions_before_promote, 5);
710 assert_eq!(cfg.min_sessions_before_demote, 2);
711 assert_eq!(cfg.max_auto_sections, 4);
712 assert!(cfg.domain_success_gate);
713 }
714
715 #[test]
716 fn trace_extraction_embedding_provider_default_and_roundtrip() {
717 let cfg = LearningConfig::default();
718 assert!(cfg.trace_extraction_embedding_provider.is_empty());
719 let cfg: LearningConfig =
720 toml::from_str(r#"trace_extraction_embedding_provider = "embed-fast""#).unwrap();
721 assert_eq!(cfg.trace_extraction_embedding_provider, "embed-fast");
722 }
723
724 #[test]
725 fn heuristic_promotion_defaults() {
726 let cfg = LearningConfig::default();
727 assert!(!cfg.heuristic_promotion_enabled);
728 assert!(cfg.heuristic_promotion_provider.is_empty());
729 assert_eq!(cfg.heuristic_promotion_threshold, 5);
730 assert_eq!(cfg.heuristic_promotion_interval_hours, 24);
731 }
732
733 #[test]
734 fn heuristic_promotion_serde_roundtrip() {
735 let toml = r#"
736heuristic_promotion_enabled = true
737heuristic_promotion_provider = "quality"
738heuristic_promotion_threshold = 10
739heuristic_promotion_interval_hours = 48
740"#;
741 let cfg: LearningConfig = toml::from_str(toml).unwrap();
742 assert!(cfg.heuristic_promotion_enabled);
743 assert_eq!(cfg.heuristic_promotion_provider, "quality");
744 assert_eq!(cfg.heuristic_promotion_threshold, 10);
745 assert_eq!(cfg.heuristic_promotion_interval_hours, 48);
746 }
747
748 #[test]
749 fn heuristic_promotion_empty_section_uses_defaults() {
750 let cfg: LearningConfig = toml::from_str("").unwrap();
751 assert!(!cfg.heuristic_promotion_enabled);
752 assert_eq!(cfg.heuristic_promotion_threshold, 5);
753 assert_eq!(cfg.heuristic_promotion_interval_hours, 24);
754 }
755}