Skip to main content

zeph_config/
learning.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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 // 128 KB
133}
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/// Strategy for detecting implicit user corrections.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
165#[serde(rename_all = "lowercase")]
166#[non_exhaustive]
167pub enum DetectorMode {
168    /// Pattern-matching only — zero LLM calls. Default behavior.
169    #[default]
170    Regex,
171    /// LLM-based judge for borderline / missed cases. Invoked only when
172    /// regex confidence falls below `judge_adaptive_high` or regex returns None.
173    ///
174    /// Note: with current regex values (ExplicitRejection=0.85, SelfCorrection=0.80,
175    /// Repetition=0.75, AlternativeRequest=0.70) and `adaptive_high=0.80`,
176    /// `ExplicitRejection` and `SelfCorrection` bypass the judge (confidence >= `adaptive_high`),
177    /// while `AlternativeRequest`, `Repetition`, and regex misses go through it.
178    Judge,
179    /// ML model-backed feedback classification via `LlmClassifier`.
180    ///
181    /// Uses the provider named in `feedback_provider` (or the primary provider if empty).
182    /// Shares the same adaptive thresholds and rate limiter as `Judge` mode.
183    /// Returns `JudgeVerdict` directly, preserving `kind` and `reasoning` metadata.
184    ///
185    /// Falls back to regex-only if the provider cannot be resolved — never fails startup.
186    Model,
187}
188
189/// Self-learning and skill evolution configuration, nested under `[skills.learning]` in TOML.
190///
191/// When `enabled = true`, Zeph tracks skill performance and can automatically improve or roll
192/// back skill definitions based on usage outcomes (ARISE, STEM, `D2Skill` pipelines).
193///
194/// # Example (TOML)
195///
196/// ```toml
197/// [skills.learning]
198/// enabled = true
199/// auto_activate = false
200/// min_failures = 3
201/// ```
202#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
203#[derive(Debug, Clone, Deserialize, Serialize)]
204pub struct LearningConfig {
205    /// Enable self-learning pipelines. Default: `false`.
206    #[serde(default)]
207    pub enabled: bool,
208    /// Automatically activate improved skill versions without user confirmation. Default: `false`.
209    #[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    /// Detector strategy: "regex" (default) or "judge".
228    #[serde(default)]
229    pub detector_mode: DetectorMode,
230    /// Named provider from `[[llm.providers]]` for the judge detector (legacy field, prefer `judge_provider`). Empty = use primary provider.
231    #[serde(default)]
232    pub judge_model: String,
233    /// Named provider from `[[llm.providers]]` for the judge detector (`detector_mode = "judge"`).
234    ///
235    /// When set, overrides the model-level fallback: the named provider is resolved and used
236    /// instead of the primary provider. Empty = use primary provider (same as leaving unset).
237    #[serde(default)]
238    pub judge_provider: String,
239    /// Provider name from `[[llm.providers]]` for `detector_mode = "model"` (`LlmClassifier`).
240    ///
241    /// Empty = use the primary provider. Named but not found in registry = log warning,
242    /// degrade to regex-only. Never fails startup.
243    #[serde(default)]
244    pub feedback_provider: ProviderName,
245    /// Regex confidence below this value is treated as "not a correction" — judge not invoked.
246    #[serde(default = "default_judge_adaptive_low")]
247    pub judge_adaptive_low: f32,
248    /// Regex confidence at or above this value is accepted without judge confirmation.
249    #[serde(default = "default_judge_adaptive_high")]
250    pub judge_adaptive_high: f32,
251    /// Maximum seconds to wait for the judge LLM to respond before timing out.
252    /// Applies to `detector_mode = "judge"` only.
253    #[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    /// When true, auto-promote and auto-demote decisions require the skill to have been used
268    /// across at least `min_sessions_before_promote` (for promotion) or
269    /// `min_sessions_before_demote` (for demotion) distinct conversation sessions.
270    /// Prevents trust transitions from a single long session.
271    #[serde(default)]
272    pub cross_session_rollout: bool,
273    /// Minimum number of distinct `conversation_id` values in `skill_outcomes` before
274    /// auto-promotion is eligible. Only checked when `cross_session_rollout = true`.
275    #[serde(default = "default_min_sessions_before_promote")]
276    pub min_sessions_before_promote: u32,
277    /// Minimum distinct sessions before auto-demotion when `cross_session_rollout = true`.
278    ///
279    /// Default 1 (demotion can happen after a single bad session by default). Separate from
280    /// `min_sessions_before_promote` because demotion should be fast (low threshold) while
281    /// promotion benefits from conservative validation (higher threshold).
282    #[serde(default = "default_min_sessions_before_demote")]
283    pub min_sessions_before_demote: u32,
284    /// Maximum number of top-level content sections (markdown H2 headers) allowed in
285    /// auto-generated skill bodies. Bodies exceeding this limit are rejected by
286    /// `validate_body_sections()`.
287    #[serde(default = "default_max_auto_sections")]
288    pub max_auto_sections: u32,
289    /// When true, auto-generated skill versions must pass a domain-conditioned evaluation
290    /// before promotion. If the improved body drifts from the original skill's domain,
291    /// activation is skipped (the version is still saved for manual review).
292    #[serde(default)]
293    pub domain_success_gate: bool,
294
295    // --- ARISE: trace-based skill improvement ---
296    /// Enable ARISE trace-based skill improvement (disabled by default).
297    #[serde(default)]
298    pub arise_enabled: bool,
299    /// Minimum tool calls in a turn to trigger ARISE trace improvement.
300    #[serde(default = "default_arise_min_tool_calls")]
301    pub arise_min_tool_calls: u32,
302    /// Provider name from `[[llm.providers]]` for ARISE trace summarization.
303    /// Empty = fall back to primary provider.
304    #[serde(default)]
305    pub arise_trace_provider: ProviderName,
306
307    // --- STEM: pattern-to-skill conversion ---
308    /// Enable STEM automatic tool pattern detection and skill generation (disabled by default).
309    #[serde(default)]
310    pub stem_enabled: bool,
311    /// Minimum occurrences of a tool sequence before generating a skill candidate.
312    #[serde(default = "default_stem_min_occurrences")]
313    pub stem_min_occurrences: u32,
314    /// Minimum success rate of the pattern before generating a skill candidate.
315    #[serde(default = "default_stem_min_success_rate")]
316    pub stem_min_success_rate: f64,
317    /// Provider name from `[[llm.providers]]` for STEM skill generation.
318    /// Empty = fall back to primary provider.
319    #[serde(default)]
320    pub stem_provider: ProviderName,
321    /// Days to retain rows in `skill_usage_log` before pruning.
322    #[serde(default = "default_stem_retention_days")]
323    pub stem_retention_days: u32,
324    /// Window in days for pattern detection queries (limits scan cost on large tables).
325    #[serde(default = "default_stem_pattern_window_days")]
326    pub stem_pattern_window_days: u32,
327
328    // --- ERL: experiential reflective learning ---
329    /// Enable ERL post-task heuristic extraction (disabled by default).
330    #[serde(default)]
331    pub erl_enabled: bool,
332    /// Provider name from `[[llm.providers]]` for ERL heuristic extraction.
333    /// Empty = fall back to primary provider.
334    #[serde(default)]
335    pub erl_extract_provider: ProviderName,
336    /// Maximum heuristics prepended per skill at match time.
337    #[serde(default = "default_erl_max_heuristics_per_skill")]
338    pub erl_max_heuristics_per_skill: u32,
339    /// Text similarity threshold (Jaccard) for heuristic deduplication.
340    /// When exact text match exceeds this, increment `use_count` instead of inserting.
341    #[serde(default = "default_erl_dedup_threshold")]
342    pub erl_dedup_threshold: f32,
343    /// Minimum confidence to include a heuristic at match time.
344    #[serde(default = "default_erl_min_confidence")]
345    pub erl_min_confidence: f64,
346
347    // --- D2Skill: step-level error correction ---
348    /// Enable `D2Skill` step-level error correction (disabled by default).
349    ///
350    /// Requires `arise_enabled = true` to populate corrections from ARISE traces.
351    /// If `d2skill_enabled = true` and `arise_enabled = false`, existing corrections
352    /// are still applied but no new ones are generated via ARISE.
353    #[serde(default)]
354    pub d2skill_enabled: bool,
355    /// Maximum corrections to inject per failure event.
356    #[serde(default = "default_d2skill_max_corrections")]
357    pub d2skill_max_corrections: u32,
358    /// Provider name from `[[llm.providers]]` for correction extraction from ARISE traces.
359    /// Empty = fall back to primary provider.
360    #[serde(default)]
361    pub d2skill_provider: ProviderName,
362
363    // --- AutoSkill A1: Conversation trace extraction (spec 056) ---
364    /// Enable background skill extraction from completed conversation traces. Default: `false`.
365    #[serde(default)]
366    pub trace_extraction_enabled: bool,
367    /// Provider name from `[[llm.providers]]` for trace extraction LLM calls.
368    /// Empty = fall back to the primary provider.
369    #[serde(default)]
370    pub trace_extraction_provider: ProviderName,
371    /// Provider name from `[[llm.providers]]` for embedding calls during trace extraction.
372    /// Must reference a provider that supports `embed()`. Empty = fall back to the primary provider.
373    #[serde(default)]
374    pub trace_extraction_embedding_provider: ProviderName,
375    /// Maximum user messages to include per extraction session. Default: 200.
376    #[serde(default = "default_trace_extraction_max_turns")]
377    pub trace_extraction_max_turns: u32,
378    /// Maximum concurrent background extraction tasks before dropping oldest. Default: 10.
379    #[serde(default = "default_trace_extraction_max_sessions_queued")]
380    pub trace_extraction_max_sessions_queued: usize,
381    /// Maximum total bytes of user messages to send to the extraction LLM. Default: 131072 (128 KB).
382    #[serde(default = "default_trace_extraction_max_input_bytes")]
383    pub trace_extraction_max_input_bytes: usize,
384
385    // --- AutoSkill A2: Versioned merging (spec 057) ---
386    /// Enable the Merge branch in the Add/Merge/Discard decision flow. Default: `true`.
387    ///
388    /// When `false`, candidates in the merge zone (`merge_threshold <= sim < dedup_threshold`)
389    /// are Discarded instead of merged.
390    #[serde(default = "default_skill_merge_enabled")]
391    pub skill_merge_enabled: bool,
392    /// Provider name from `[[llm.providers]]` for LLM merge calls.
393    /// Empty = fall back to the primary provider.
394    #[serde(default)]
395    pub skill_merge_provider: ProviderName,
396    /// Minimum cosine similarity to trigger a merge with the nearest skill. Default: 0.75.
397    ///
398    /// Must be strictly less than `dedup_threshold` (validated at startup).
399    #[serde(default = "default_merge_threshold")]
400    pub merge_threshold: f32,
401    /// Minimum cosine similarity to discard a candidate as a near-exact duplicate. Default: 0.90.
402    ///
403    /// Must be strictly greater than `merge_threshold` (validated at startup).
404    #[serde(default = "default_dedup_threshold")]
405    pub dedup_threshold: f32,
406
407    // --- AutoSkill A6: Heuristic promotion from ERL (spec 061) ---
408    /// Enable periodic heuristic promotion from ERL to full skills. Default: `false`.
409    ///
410    /// When `true`, a background task runs every `heuristic_promotion_interval_hours` hours
411    /// and evaluates whether accumulated ERL heuristics are substantial enough for promotion.
412    #[serde(default)]
413    pub heuristic_promotion_enabled: bool,
414    /// Provider name from `[[llm.providers]]` for heuristic promotion LLM calls.
415    ///
416    /// Use a quality provider — promotion is an offline, non-latency-sensitive analysis.
417    /// Empty = fall back to the primary provider.
418    #[serde(default)]
419    pub heuristic_promotion_provider: ProviderName,
420    /// Minimum heuristic count per skill to trigger promotion evaluation. Default: `5`.
421    ///
422    /// Skills with fewer heuristics (above `erl_min_confidence`) are skipped.
423    #[serde(default = "default_heuristic_promotion_threshold")]
424    pub heuristic_promotion_threshold: u32,
425    /// Interval in hours between promotion evaluation runs. Default: `24`.
426    #[serde(default = "default_heuristic_promotion_interval_hours")]
427    pub heuristic_promotion_interval_hours: u64,
428
429    /// Maximum number of judge LLM calls allowed within the rate window. Default: `5`.
430    ///
431    /// Applies to `detector_mode = "judge"` and `detector_mode = "model"`. When the limit
432    /// is reached, borderline messages are classified by regex alone until the window expires.
433    #[serde(default = "default_judge_rate_limit")]
434    pub judge_rate_limit: usize,
435
436    /// Sliding window duration for judge rate limiting, in seconds. Default: `60`.
437    ///
438    /// Calls older than this are evicted from the rate limiter before each new call is checked.
439    #[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    /// Validate invariants that cannot be expressed through serde defaults alone.
513    ///
514    /// # Errors
515    ///
516    /// Returns an error string if `merge_threshold >= dedup_threshold`.
517    #[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(); // merge=0.75, dedup=0.90
690        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}