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
155fn default_judge_rate_limit() -> usize {
156 5
157}
158
159fn default_judge_rate_window_secs() -> u64 {
160 60
161}
162
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
165#[serde(rename_all = "lowercase")]
166#[non_exhaustive]
167pub enum DetectorMode {
168 #[default]
170 Regex,
171 Judge,
179 Model,
187}
188
189#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Deserialize, Serialize)]
204pub struct LearningConfig {
205 #[serde(default)]
207 pub enabled: bool,
208 #[serde(default)]
210 pub auto_activate: bool,
211 #[serde(default = "default_min_failures")]
212 pub min_failures: u32,
213 #[serde(default = "default_improve_threshold")]
214 pub improve_threshold: f64,
215 #[serde(default = "default_rollback_threshold")]
216 pub rollback_threshold: f64,
217 #[serde(default = "default_min_evaluations")]
218 pub min_evaluations: u32,
219 #[serde(default = "default_max_versions")]
220 pub max_versions: u32,
221 #[serde(default = "default_cooldown_minutes")]
222 pub cooldown_minutes: u64,
223 #[serde(default = "default_correction_detection")]
224 pub correction_detection: bool,
225 #[serde(default = "default_correction_confidence_threshold")]
226 pub correction_confidence_threshold: f32,
227 #[serde(default)]
229 pub detector_mode: DetectorMode,
230 #[serde(default)]
232 pub judge_model: String,
233 #[serde(default)]
238 pub judge_provider: String,
239 #[serde(default)]
244 pub feedback_provider: ProviderName,
245 #[serde(default = "default_judge_adaptive_low")]
247 pub judge_adaptive_low: f32,
248 #[serde(default = "default_judge_adaptive_high")]
250 pub judge_adaptive_high: f32,
251 #[serde(default = "default_judge_llm_timeout_secs")]
254 pub judge_llm_timeout_secs: u64,
255 #[serde(default = "default_correction_recall_limit")]
256 pub correction_recall_limit: u32,
257 #[serde(default = "default_correction_min_similarity")]
258 pub correction_min_similarity: f32,
259 #[serde(default = "default_auto_promote_min_uses")]
260 pub auto_promote_min_uses: u32,
261 #[serde(default = "default_auto_promote_threshold")]
262 pub auto_promote_threshold: f64,
263 #[serde(default = "default_auto_demote_min_uses")]
264 pub auto_demote_min_uses: u32,
265 #[serde(default = "default_auto_demote_threshold")]
266 pub auto_demote_threshold: f64,
267 #[serde(default)]
272 pub cross_session_rollout: bool,
273 #[serde(default = "default_min_sessions_before_promote")]
276 pub min_sessions_before_promote: u32,
277 #[serde(default = "default_min_sessions_before_demote")]
283 pub min_sessions_before_demote: u32,
284 #[serde(default = "default_max_auto_sections")]
288 pub max_auto_sections: u32,
289 #[serde(default)]
293 pub domain_success_gate: bool,
294
295 #[serde(default)]
298 pub arise_enabled: bool,
299 #[serde(default = "default_arise_min_tool_calls")]
301 pub arise_min_tool_calls: u32,
302 #[serde(default)]
305 pub arise_trace_provider: ProviderName,
306
307 #[serde(default)]
310 pub stem_enabled: bool,
311 #[serde(default = "default_stem_min_occurrences")]
313 pub stem_min_occurrences: u32,
314 #[serde(default = "default_stem_min_success_rate")]
316 pub stem_min_success_rate: f64,
317 #[serde(default)]
320 pub stem_provider: ProviderName,
321 #[serde(default = "default_stem_retention_days")]
323 pub stem_retention_days: u32,
324 #[serde(default = "default_stem_pattern_window_days")]
326 pub stem_pattern_window_days: u32,
327
328 #[serde(default)]
331 pub erl_enabled: bool,
332 #[serde(default)]
335 pub erl_extract_provider: ProviderName,
336 #[serde(default = "default_erl_max_heuristics_per_skill")]
338 pub erl_max_heuristics_per_skill: u32,
339 #[serde(default = "default_erl_dedup_threshold")]
342 pub erl_dedup_threshold: f32,
343 #[serde(default = "default_erl_min_confidence")]
345 pub erl_min_confidence: f64,
346
347 #[serde(default)]
354 pub d2skill_enabled: bool,
355 #[serde(default = "default_d2skill_max_corrections")]
357 pub d2skill_max_corrections: u32,
358 #[serde(default)]
361 pub d2skill_provider: ProviderName,
362
363 #[serde(default)]
366 pub trace_extraction_enabled: bool,
367 #[serde(default)]
370 pub trace_extraction_provider: ProviderName,
371 #[serde(default)]
374 pub trace_extraction_embedding_provider: ProviderName,
375 #[serde(default = "default_trace_extraction_max_turns")]
377 pub trace_extraction_max_turns: u32,
378 #[serde(default = "default_trace_extraction_max_sessions_queued")]
380 pub trace_extraction_max_sessions_queued: usize,
381 #[serde(default = "default_trace_extraction_max_input_bytes")]
383 pub trace_extraction_max_input_bytes: usize,
384
385 #[serde(default = "default_skill_merge_enabled")]
391 pub skill_merge_enabled: bool,
392 #[serde(default)]
395 pub skill_merge_provider: ProviderName,
396 #[serde(default = "default_merge_threshold")]
400 pub merge_threshold: f32,
401 #[serde(default = "default_dedup_threshold")]
405 pub dedup_threshold: f32,
406
407 #[serde(default)]
413 pub heuristic_promotion_enabled: bool,
414 #[serde(default)]
419 pub heuristic_promotion_provider: ProviderName,
420 #[serde(default = "default_heuristic_promotion_threshold")]
424 pub heuristic_promotion_threshold: u32,
425 #[serde(default = "default_heuristic_promotion_interval_hours")]
427 pub heuristic_promotion_interval_hours: u64,
428
429 #[serde(default = "default_judge_rate_limit")]
434 pub judge_rate_limit: usize,
435
436 #[serde(default = "default_judge_rate_window_secs")]
440 pub judge_rate_window_secs: u64,
441}
442
443impl Default for LearningConfig {
444 fn default() -> Self {
445 Self {
446 enabled: false,
447 auto_activate: false,
448 min_failures: default_min_failures(),
449 improve_threshold: default_improve_threshold(),
450 rollback_threshold: default_rollback_threshold(),
451 min_evaluations: default_min_evaluations(),
452 max_versions: default_max_versions(),
453 cooldown_minutes: default_cooldown_minutes(),
454 correction_detection: default_correction_detection(),
455 correction_confidence_threshold: default_correction_confidence_threshold(),
456 detector_mode: DetectorMode::default(),
457 judge_model: String::new(),
458 judge_provider: String::new(),
459 feedback_provider: ProviderName::default(),
460 judge_adaptive_low: default_judge_adaptive_low(),
461 judge_adaptive_high: default_judge_adaptive_high(),
462 judge_llm_timeout_secs: default_judge_llm_timeout_secs(),
463 correction_recall_limit: default_correction_recall_limit(),
464 correction_min_similarity: default_correction_min_similarity(),
465 auto_promote_min_uses: default_auto_promote_min_uses(),
466 auto_promote_threshold: default_auto_promote_threshold(),
467 auto_demote_min_uses: default_auto_demote_min_uses(),
468 auto_demote_threshold: default_auto_demote_threshold(),
469 cross_session_rollout: false,
470 min_sessions_before_promote: default_min_sessions_before_promote(),
471 min_sessions_before_demote: default_min_sessions_before_demote(),
472 max_auto_sections: default_max_auto_sections(),
473 domain_success_gate: false,
474 arise_enabled: false,
475 arise_min_tool_calls: default_arise_min_tool_calls(),
476 arise_trace_provider: ProviderName::default(),
477 stem_enabled: false,
478 stem_min_occurrences: default_stem_min_occurrences(),
479 stem_min_success_rate: default_stem_min_success_rate(),
480 stem_provider: ProviderName::default(),
481 stem_retention_days: default_stem_retention_days(),
482 stem_pattern_window_days: default_stem_pattern_window_days(),
483 erl_enabled: false,
484 erl_extract_provider: ProviderName::default(),
485 erl_max_heuristics_per_skill: default_erl_max_heuristics_per_skill(),
486 erl_dedup_threshold: default_erl_dedup_threshold(),
487 erl_min_confidence: default_erl_min_confidence(),
488 d2skill_enabled: false,
489 d2skill_max_corrections: default_d2skill_max_corrections(),
490 d2skill_provider: ProviderName::default(),
491 trace_extraction_enabled: false,
492 trace_extraction_provider: ProviderName::default(),
493 trace_extraction_embedding_provider: ProviderName::default(),
494 trace_extraction_max_turns: default_trace_extraction_max_turns(),
495 trace_extraction_max_sessions_queued: default_trace_extraction_max_sessions_queued(),
496 trace_extraction_max_input_bytes: default_trace_extraction_max_input_bytes(),
497 skill_merge_enabled: default_skill_merge_enabled(),
498 skill_merge_provider: ProviderName::default(),
499 merge_threshold: default_merge_threshold(),
500 dedup_threshold: default_dedup_threshold(),
501 heuristic_promotion_enabled: false,
502 heuristic_promotion_provider: ProviderName::default(),
503 heuristic_promotion_threshold: default_heuristic_promotion_threshold(),
504 heuristic_promotion_interval_hours: default_heuristic_promotion_interval_hours(),
505 judge_rate_limit: default_judge_rate_limit(),
506 judge_rate_window_secs: default_judge_rate_window_secs(),
507 }
508 }
509}
510
511impl LearningConfig {
512 #[must_use = "validation result must be checked"]
518 pub fn validate(&self) -> Result<(), String> {
519 if self.merge_threshold >= self.dedup_threshold {
520 return Err(format!(
521 "skills.learning.merge_threshold ({}) must be strictly less than dedup_threshold ({})",
522 self.merge_threshold, self.dedup_threshold
523 ));
524 }
525 Ok(())
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn detector_mode_default_is_regex() {
535 assert_eq!(DetectorMode::default(), DetectorMode::Regex);
536 }
537
538 #[test]
539 fn detector_mode_serde_roundtrip() {
540 for (mode, expected_str) in [
541 (DetectorMode::Regex, "\"regex\""),
542 (DetectorMode::Judge, "\"judge\""),
543 (DetectorMode::Model, "\"model\""),
544 ] {
545 let serialized = serde_json::to_string(&mode).unwrap();
546 assert_eq!(serialized, expected_str, "serialize {mode:?}");
547 let deserialized: DetectorMode = serde_json::from_str(&serialized).unwrap();
548 assert_eq!(deserialized, mode, "deserialize {mode:?}");
549 }
550 }
551
552 #[test]
553 fn learning_config_default_detector_mode_is_regex() {
554 let cfg = LearningConfig::default();
555 assert_eq!(cfg.detector_mode, DetectorMode::Regex);
556 }
557
558 #[test]
559 fn learning_config_default_feedback_provider_is_empty() {
560 let cfg = LearningConfig::default();
561 assert!(cfg.feedback_provider.is_empty());
562 }
563
564 #[test]
565 fn learning_config_deserialize_model_mode() {
566 let toml = r#"detector_mode = "model"
567feedback_provider = "fast""#;
568 let cfg: LearningConfig = toml::from_str(toml).unwrap();
569 assert_eq!(cfg.detector_mode, DetectorMode::Model);
570 assert_eq!(cfg.feedback_provider, "fast");
571 }
572
573 #[test]
574 fn learning_config_deserialize_empty_feedback_provider() {
575 let toml = r#"detector_mode = "model""#;
576 let cfg: LearningConfig = toml::from_str(toml).unwrap();
577 assert_eq!(cfg.detector_mode, DetectorMode::Model);
578 assert!(
579 cfg.feedback_provider.is_empty(),
580 "empty feedback_provider must default to empty string (fallback to primary)"
581 );
582 }
583
584 #[test]
585 fn learning_config_deserialize_empty_section_uses_defaults() {
586 let cfg: LearningConfig = toml::from_str("").unwrap();
587 assert!(!cfg.enabled);
588 assert_eq!(cfg.min_failures, 3);
589 assert_eq!(cfg.detector_mode, DetectorMode::Regex);
590 assert!(cfg.feedback_provider.is_empty());
591 }
592
593 #[test]
594 fn judge_llm_timeout_secs_default_and_roundtrip() {
595 let cfg = LearningConfig::default();
596 assert_eq!(cfg.judge_llm_timeout_secs, 30);
597 let cfg: LearningConfig = toml::from_str("judge_llm_timeout_secs = 60").unwrap();
598 assert_eq!(cfg.judge_llm_timeout_secs, 60);
599 }
600
601 #[test]
602 fn learning_config_defaults_for_new_fields() {
603 let cfg = LearningConfig::default();
604 assert!(!cfg.cross_session_rollout);
605 assert_eq!(cfg.min_sessions_before_promote, 2);
606 assert_eq!(cfg.max_auto_sections, 3);
607 assert!(!cfg.domain_success_gate);
608 }
609
610 #[test]
611 fn learning_config_min_sessions_before_demote_default() {
612 let cfg = LearningConfig::default();
613 assert_eq!(cfg.min_sessions_before_demote, 1);
614 }
615
616 #[test]
617 fn arise_stem_erl_defaults() {
618 let cfg = LearningConfig::default();
619 assert!(!cfg.arise_enabled);
620 assert_eq!(cfg.arise_min_tool_calls, 2);
621 assert!(cfg.arise_trace_provider.is_empty());
622 assert!(!cfg.stem_enabled);
623 assert_eq!(cfg.stem_min_occurrences, 3);
624 assert!((cfg.stem_min_success_rate - 0.8).abs() < f64::EPSILON);
625 assert!(cfg.stem_provider.is_empty());
626 assert_eq!(cfg.stem_retention_days, 90);
627 assert_eq!(cfg.stem_pattern_window_days, 30);
628 assert!(!cfg.erl_enabled);
629 assert!(cfg.erl_extract_provider.is_empty());
630 assert_eq!(cfg.erl_max_heuristics_per_skill, 3);
631 assert!((cfg.erl_dedup_threshold - 0.9).abs() < f32::EPSILON);
632 assert!((cfg.erl_min_confidence - 0.5).abs() < f64::EPSILON);
633 }
634
635 #[test]
636 fn arise_stem_erl_serde_roundtrip() {
637 let toml = r#"
638arise_enabled = true
639arise_min_tool_calls = 3
640arise_trace_provider = "fast"
641stem_enabled = true
642stem_min_occurrences = 5
643stem_min_success_rate = 0.9
644stem_provider = "mid"
645stem_retention_days = 60
646stem_pattern_window_days = 14
647erl_enabled = true
648erl_extract_provider = "fast"
649erl_max_heuristics_per_skill = 5
650erl_dedup_threshold = 0.85
651erl_min_confidence = 0.6
652"#;
653 let cfg: LearningConfig = toml::from_str(toml).unwrap();
654 assert!(cfg.arise_enabled);
655 assert_eq!(cfg.arise_min_tool_calls, 3);
656 assert_eq!(cfg.arise_trace_provider, "fast");
657 assert!(cfg.stem_enabled);
658 assert_eq!(cfg.stem_min_occurrences, 5);
659 assert!((cfg.stem_min_success_rate - 0.9).abs() < f64::EPSILON);
660 assert_eq!(cfg.stem_provider, "mid");
661 assert_eq!(cfg.stem_retention_days, 60);
662 assert_eq!(cfg.stem_pattern_window_days, 14);
663 assert!(cfg.erl_enabled);
664 assert_eq!(cfg.erl_extract_provider, "fast");
665 assert_eq!(cfg.erl_max_heuristics_per_skill, 5);
666 assert!((cfg.erl_dedup_threshold - 0.85_f32).abs() < f32::EPSILON);
667 assert!((cfg.erl_min_confidence - 0.6).abs() < f64::EPSILON);
668 }
669
670 #[test]
671 fn arise_stem_erl_empty_section_uses_defaults() {
672 let cfg: LearningConfig = toml::from_str("").unwrap();
673 assert!(!cfg.arise_enabled);
674 assert!(!cfg.stem_enabled);
675 assert!(!cfg.erl_enabled);
676 }
677
678 #[test]
679 fn autoskill_a2_defaults() {
680 let cfg = LearningConfig::default();
681 assert!(cfg.skill_merge_enabled);
682 assert!(cfg.skill_merge_provider.is_empty());
683 assert!((cfg.merge_threshold - 0.75_f32).abs() < f32::EPSILON);
684 assert!((cfg.dedup_threshold - 0.90_f32).abs() < f32::EPSILON);
685 }
686
687 #[test]
688 fn validate_merge_lt_dedup_ok() {
689 let cfg = LearningConfig::default(); assert!(cfg.validate().is_ok());
691 }
692
693 #[test]
694 fn validate_merge_eq_dedup_err() {
695 let cfg = LearningConfig {
696 merge_threshold: 0.90,
697 dedup_threshold: 0.90,
698 ..LearningConfig::default()
699 };
700 let err = cfg.validate().unwrap_err();
701 assert!(
702 err.contains("merge_threshold") && err.contains("dedup_threshold"),
703 "unexpected error: {err}"
704 );
705 }
706
707 #[test]
708 fn validate_merge_gt_dedup_err() {
709 let cfg = LearningConfig {
710 merge_threshold: 0.95,
711 dedup_threshold: 0.90,
712 ..LearningConfig::default()
713 };
714 let err = cfg.validate().unwrap_err();
715 assert!(
716 err.contains("merge_threshold") && err.contains("dedup_threshold"),
717 "unexpected error: {err}"
718 );
719 }
720
721 #[test]
722 fn autoskill_a2_dedup_threshold_default_and_roundtrip() {
723 let cfg = LearningConfig::default();
724 assert!((cfg.dedup_threshold - 0.90_f32).abs() < f32::EPSILON);
725 let cfg: LearningConfig = toml::from_str("dedup_threshold = 0.95").unwrap();
726 assert!((cfg.dedup_threshold - 0.95_f32).abs() < f32::EPSILON);
727 }
728
729 #[test]
730 fn learning_config_new_fields_serde_roundtrip() {
731 let toml = r"
732cross_session_rollout = true
733min_sessions_before_promote = 5
734min_sessions_before_demote = 2
735max_auto_sections = 4
736domain_success_gate = true
737";
738 let cfg: LearningConfig = toml::from_str(toml).unwrap();
739 assert!(cfg.cross_session_rollout);
740 assert_eq!(cfg.min_sessions_before_promote, 5);
741 assert_eq!(cfg.min_sessions_before_demote, 2);
742 assert_eq!(cfg.max_auto_sections, 4);
743 assert!(cfg.domain_success_gate);
744 }
745
746 #[test]
747 fn trace_extraction_embedding_provider_default_and_roundtrip() {
748 let cfg = LearningConfig::default();
749 assert!(cfg.trace_extraction_embedding_provider.is_empty());
750 let cfg: LearningConfig =
751 toml::from_str(r#"trace_extraction_embedding_provider = "embed-fast""#).unwrap();
752 assert_eq!(cfg.trace_extraction_embedding_provider, "embed-fast");
753 }
754
755 #[test]
756 fn heuristic_promotion_defaults() {
757 let cfg = LearningConfig::default();
758 assert!(!cfg.heuristic_promotion_enabled);
759 assert!(cfg.heuristic_promotion_provider.is_empty());
760 assert_eq!(cfg.heuristic_promotion_threshold, 5);
761 assert_eq!(cfg.heuristic_promotion_interval_hours, 24);
762 }
763
764 #[test]
765 fn heuristic_promotion_serde_roundtrip() {
766 let toml = r#"
767heuristic_promotion_enabled = true
768heuristic_promotion_provider = "quality"
769heuristic_promotion_threshold = 10
770heuristic_promotion_interval_hours = 48
771"#;
772 let cfg: LearningConfig = toml::from_str(toml).unwrap();
773 assert!(cfg.heuristic_promotion_enabled);
774 assert_eq!(cfg.heuristic_promotion_provider, "quality");
775 assert_eq!(cfg.heuristic_promotion_threshold, 10);
776 assert_eq!(cfg.heuristic_promotion_interval_hours, 48);
777 }
778
779 #[test]
780 fn heuristic_promotion_empty_section_uses_defaults() {
781 let cfg: LearningConfig = toml::from_str("").unwrap();
782 assert!(!cfg.heuristic_promotion_enabled);
783 assert_eq!(cfg.heuristic_promotion_threshold, 5);
784 assert_eq!(cfg.heuristic_promotion_interval_hours, 24);
785 }
786
787 #[test]
788 fn judge_provider_default_is_empty() {
789 let cfg = LearningConfig::default();
790 assert!(cfg.judge_provider.is_empty());
791 }
792
793 #[test]
794 fn judge_provider_serde_roundtrip() {
795 let cfg: LearningConfig = toml::from_str(r#"judge_provider = "quality""#).unwrap();
796 assert_eq!(cfg.judge_provider, "quality");
797 }
798
799 #[test]
800 fn judge_provider_and_judge_model_coexist() {
801 let toml = r#"
802judge_model = "claude-sonnet-5"
803judge_provider = "quality"
804detector_mode = "judge"
805"#;
806 let cfg: LearningConfig = toml::from_str(toml).unwrap();
807 assert_eq!(cfg.judge_model, "claude-sonnet-5");
808 assert_eq!(cfg.judge_provider, "quality");
809 assert_eq!(cfg.detector_mode, DetectorMode::Judge);
810 }
811
812 #[test]
813 fn judge_provider_absent_falls_back_to_empty_default() {
814 let cfg: LearningConfig = toml::from_str("judge_model = \"gpt-4o\"").unwrap();
815 assert!(
816 cfg.judge_provider.is_empty(),
817 "missing judge_provider must default to empty string"
818 );
819 assert_eq!(cfg.judge_model, "gpt-4o");
820 }
821
822 #[test]
823 fn judge_rate_limit_defaults() {
824 let cfg = LearningConfig::default();
825 assert_eq!(cfg.judge_rate_limit, 5);
826 assert_eq!(cfg.judge_rate_window_secs, 60);
827 }
828
829 #[test]
830 fn judge_rate_limit_empty_section_uses_defaults() {
831 let cfg: LearningConfig = toml::from_str("").unwrap();
832 assert_eq!(cfg.judge_rate_limit, 5);
833 assert_eq!(cfg.judge_rate_window_secs, 60);
834 }
835
836 #[test]
837 fn judge_rate_limit_serde_roundtrip() {
838 let toml = r"
839judge_rate_limit = 10
840judge_rate_window_secs = 120
841";
842 let cfg: LearningConfig = toml::from_str(toml).unwrap();
843 assert_eq!(cfg.judge_rate_limit, 10);
844 assert_eq!(cfg.judge_rate_window_secs, 120);
845 }
846}