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