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_correction_recall_limit() -> u32 {
48    3
49}
50
51fn default_correction_min_similarity() -> f32 {
52    0.75
53}
54
55fn default_auto_promote_min_uses() -> u32 {
56    50
57}
58
59fn default_auto_promote_threshold() -> f64 {
60    0.95
61}
62
63fn default_auto_demote_min_uses() -> u32 {
64    30
65}
66
67fn default_auto_demote_threshold() -> f64 {
68    0.40
69}
70
71fn default_min_sessions_before_promote() -> u32 {
72    2
73}
74
75fn default_min_sessions_before_demote() -> u32 {
76    1
77}
78
79fn default_max_auto_sections() -> u32 {
80    3
81}
82
83fn default_arise_min_tool_calls() -> u32 {
84    2
85}
86
87fn default_stem_min_occurrences() -> u32 {
88    3
89}
90
91fn default_stem_min_success_rate() -> f64 {
92    0.8
93}
94
95fn default_stem_retention_days() -> u32 {
96    90
97}
98
99fn default_stem_pattern_window_days() -> u32 {
100    30
101}
102
103fn default_erl_max_heuristics_per_skill() -> u32 {
104    3
105}
106
107fn default_erl_dedup_threshold() -> f32 {
108    0.9
109}
110
111fn default_erl_min_confidence() -> f64 {
112    0.5
113}
114
115fn default_d2skill_max_corrections() -> u32 {
116    3
117}
118
119/// Strategy for detecting implicit user corrections.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
121#[serde(rename_all = "lowercase")]
122pub enum DetectorMode {
123    /// Pattern-matching only — zero LLM calls. Default behavior.
124    #[default]
125    Regex,
126    /// LLM-based judge for borderline / missed cases. Invoked only when
127    /// regex confidence falls below `judge_adaptive_high` or regex returns None.
128    ///
129    /// Note: with current regex values (ExplicitRejection=0.85, SelfCorrection=0.80,
130    /// Repetition=0.75, AlternativeRequest=0.70) and `adaptive_high=0.80`,
131    /// `ExplicitRejection` and `SelfCorrection` bypass the judge (confidence >= `adaptive_high`),
132    /// while `AlternativeRequest`, `Repetition`, and regex misses go through it.
133    Judge,
134    /// ML model-backed feedback classification via `LlmClassifier`.
135    ///
136    /// Uses the provider named in `feedback_provider` (or the primary provider if empty).
137    /// Shares the same adaptive thresholds and rate limiter as `Judge` mode.
138    /// Returns `JudgeVerdict` directly, preserving `kind` and `reasoning` metadata.
139    ///
140    /// Falls back to regex-only if the provider cannot be resolved — never fails startup.
141    Model,
142}
143
144#[allow(clippy::struct_excessive_bools)]
145#[derive(Debug, Clone, Deserialize, Serialize)]
146pub struct LearningConfig {
147    #[serde(default)]
148    pub enabled: bool,
149    #[serde(default)]
150    pub auto_activate: bool,
151    #[serde(default = "default_min_failures")]
152    pub min_failures: u32,
153    #[serde(default = "default_improve_threshold")]
154    pub improve_threshold: f64,
155    #[serde(default = "default_rollback_threshold")]
156    pub rollback_threshold: f64,
157    #[serde(default = "default_min_evaluations")]
158    pub min_evaluations: u32,
159    #[serde(default = "default_max_versions")]
160    pub max_versions: u32,
161    #[serde(default = "default_cooldown_minutes")]
162    pub cooldown_minutes: u64,
163    #[serde(default = "default_correction_detection")]
164    pub correction_detection: bool,
165    #[serde(default = "default_correction_confidence_threshold")]
166    pub correction_confidence_threshold: f32,
167    /// Detector strategy: "regex" (default) or "judge".
168    #[serde(default)]
169    pub detector_mode: DetectorMode,
170    /// Model for the judge detector (e.g. "claude-sonnet-4-6"). Empty = use primary provider.
171    #[serde(default)]
172    pub judge_model: String,
173    /// Provider name from `[[llm.providers]]` for `detector_mode = "model"` (`LlmClassifier`).
174    ///
175    /// Empty = use the primary provider. Named but not found in registry = log warning,
176    /// degrade to regex-only. Never fails startup.
177    #[serde(default)]
178    pub feedback_provider: ProviderName,
179    /// Regex confidence below this value is treated as "not a correction" — judge not invoked.
180    #[serde(default = "default_judge_adaptive_low")]
181    pub judge_adaptive_low: f32,
182    /// Regex confidence at or above this value is accepted without judge confirmation.
183    #[serde(default = "default_judge_adaptive_high")]
184    pub judge_adaptive_high: f32,
185    #[serde(default = "default_correction_recall_limit")]
186    pub correction_recall_limit: u32,
187    #[serde(default = "default_correction_min_similarity")]
188    pub correction_min_similarity: f32,
189    #[serde(default = "default_auto_promote_min_uses")]
190    pub auto_promote_min_uses: u32,
191    #[serde(default = "default_auto_promote_threshold")]
192    pub auto_promote_threshold: f64,
193    #[serde(default = "default_auto_demote_min_uses")]
194    pub auto_demote_min_uses: u32,
195    #[serde(default = "default_auto_demote_threshold")]
196    pub auto_demote_threshold: f64,
197    /// When true, auto-promote and auto-demote decisions require the skill to have been used
198    /// across at least `min_sessions_before_promote` (for promotion) or
199    /// `min_sessions_before_demote` (for demotion) distinct conversation sessions.
200    /// Prevents trust transitions from a single long session.
201    #[serde(default)]
202    pub cross_session_rollout: bool,
203    /// Minimum number of distinct `conversation_id` values in `skill_outcomes` before
204    /// auto-promotion is eligible. Only checked when `cross_session_rollout = true`.
205    #[serde(default = "default_min_sessions_before_promote")]
206    pub min_sessions_before_promote: u32,
207    /// Minimum distinct sessions before auto-demotion when `cross_session_rollout = true`.
208    ///
209    /// Default 1 (demotion can happen after a single bad session by default). Separate from
210    /// `min_sessions_before_promote` because demotion should be fast (low threshold) while
211    /// promotion benefits from conservative validation (higher threshold).
212    #[serde(default = "default_min_sessions_before_demote")]
213    pub min_sessions_before_demote: u32,
214    /// Maximum number of top-level content sections (markdown H2 headers) allowed in
215    /// auto-generated skill bodies. Bodies exceeding this limit are rejected by
216    /// `validate_body_sections()`.
217    #[serde(default = "default_max_auto_sections")]
218    pub max_auto_sections: u32,
219    /// When true, auto-generated skill versions must pass a domain-conditioned evaluation
220    /// before promotion. If the improved body drifts from the original skill's domain,
221    /// activation is skipped (the version is still saved for manual review).
222    #[serde(default)]
223    pub domain_success_gate: bool,
224
225    // --- ARISE: trace-based skill improvement ---
226    /// Enable ARISE trace-based skill improvement (disabled by default).
227    #[serde(default)]
228    pub arise_enabled: bool,
229    /// Minimum tool calls in a turn to trigger ARISE trace improvement.
230    #[serde(default = "default_arise_min_tool_calls")]
231    pub arise_min_tool_calls: u32,
232    /// Provider name from `[[llm.providers]]` for ARISE trace summarization.
233    /// Empty = fall back to primary provider.
234    #[serde(default)]
235    pub arise_trace_provider: ProviderName,
236
237    // --- STEM: pattern-to-skill conversion ---
238    /// Enable STEM automatic tool pattern detection and skill generation (disabled by default).
239    #[serde(default)]
240    pub stem_enabled: bool,
241    /// Minimum occurrences of a tool sequence before generating a skill candidate.
242    #[serde(default = "default_stem_min_occurrences")]
243    pub stem_min_occurrences: u32,
244    /// Minimum success rate of the pattern before generating a skill candidate.
245    #[serde(default = "default_stem_min_success_rate")]
246    pub stem_min_success_rate: f64,
247    /// Provider name from `[[llm.providers]]` for STEM skill generation.
248    /// Empty = fall back to primary provider.
249    #[serde(default)]
250    pub stem_provider: ProviderName,
251    /// Days to retain rows in `skill_usage_log` before pruning.
252    #[serde(default = "default_stem_retention_days")]
253    pub stem_retention_days: u32,
254    /// Window in days for pattern detection queries (limits scan cost on large tables).
255    #[serde(default = "default_stem_pattern_window_days")]
256    pub stem_pattern_window_days: u32,
257
258    // --- ERL: experiential reflective learning ---
259    /// Enable ERL post-task heuristic extraction (disabled by default).
260    #[serde(default)]
261    pub erl_enabled: bool,
262    /// Provider name from `[[llm.providers]]` for ERL heuristic extraction.
263    /// Empty = fall back to primary provider.
264    #[serde(default)]
265    pub erl_extract_provider: ProviderName,
266    /// Maximum heuristics prepended per skill at match time.
267    #[serde(default = "default_erl_max_heuristics_per_skill")]
268    pub erl_max_heuristics_per_skill: u32,
269    /// Text similarity threshold (Jaccard) for heuristic deduplication.
270    /// When exact text match exceeds this, increment `use_count` instead of inserting.
271    #[serde(default = "default_erl_dedup_threshold")]
272    pub erl_dedup_threshold: f32,
273    /// Minimum confidence to include a heuristic at match time.
274    #[serde(default = "default_erl_min_confidence")]
275    pub erl_min_confidence: f64,
276
277    // --- D2Skill: step-level error correction ---
278    /// Enable `D2Skill` step-level error correction (disabled by default).
279    ///
280    /// Requires `arise_enabled = true` to populate corrections from ARISE traces.
281    /// If `d2skill_enabled = true` and `arise_enabled = false`, existing corrections
282    /// are still applied but no new ones are generated via ARISE.
283    #[serde(default)]
284    pub d2skill_enabled: bool,
285    /// Maximum corrections to inject per failure event.
286    #[serde(default = "default_d2skill_max_corrections")]
287    pub d2skill_max_corrections: u32,
288    /// Provider name from `[[llm.providers]]` for correction extraction from ARISE traces.
289    /// Empty = fall back to primary provider.
290    #[serde(default)]
291    pub d2skill_provider: ProviderName,
292}
293
294impl Default for LearningConfig {
295    fn default() -> Self {
296        Self {
297            enabled: false,
298            auto_activate: false,
299            min_failures: default_min_failures(),
300            improve_threshold: default_improve_threshold(),
301            rollback_threshold: default_rollback_threshold(),
302            min_evaluations: default_min_evaluations(),
303            max_versions: default_max_versions(),
304            cooldown_minutes: default_cooldown_minutes(),
305            correction_detection: default_correction_detection(),
306            correction_confidence_threshold: default_correction_confidence_threshold(),
307            detector_mode: DetectorMode::default(),
308            judge_model: String::new(),
309            feedback_provider: ProviderName::default(),
310            judge_adaptive_low: default_judge_adaptive_low(),
311            judge_adaptive_high: default_judge_adaptive_high(),
312            correction_recall_limit: default_correction_recall_limit(),
313            correction_min_similarity: default_correction_min_similarity(),
314            auto_promote_min_uses: default_auto_promote_min_uses(),
315            auto_promote_threshold: default_auto_promote_threshold(),
316            auto_demote_min_uses: default_auto_demote_min_uses(),
317            auto_demote_threshold: default_auto_demote_threshold(),
318            cross_session_rollout: false,
319            min_sessions_before_promote: default_min_sessions_before_promote(),
320            min_sessions_before_demote: default_min_sessions_before_demote(),
321            max_auto_sections: default_max_auto_sections(),
322            domain_success_gate: false,
323            arise_enabled: false,
324            arise_min_tool_calls: default_arise_min_tool_calls(),
325            arise_trace_provider: ProviderName::default(),
326            stem_enabled: false,
327            stem_min_occurrences: default_stem_min_occurrences(),
328            stem_min_success_rate: default_stem_min_success_rate(),
329            stem_provider: ProviderName::default(),
330            stem_retention_days: default_stem_retention_days(),
331            stem_pattern_window_days: default_stem_pattern_window_days(),
332            erl_enabled: false,
333            erl_extract_provider: ProviderName::default(),
334            erl_max_heuristics_per_skill: default_erl_max_heuristics_per_skill(),
335            erl_dedup_threshold: default_erl_dedup_threshold(),
336            erl_min_confidence: default_erl_min_confidence(),
337            d2skill_enabled: false,
338            d2skill_max_corrections: default_d2skill_max_corrections(),
339            d2skill_provider: ProviderName::default(),
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn detector_mode_default_is_regex() {
350        assert_eq!(DetectorMode::default(), DetectorMode::Regex);
351    }
352
353    #[test]
354    fn detector_mode_serde_roundtrip() {
355        for (mode, expected_str) in [
356            (DetectorMode::Regex, "\"regex\""),
357            (DetectorMode::Judge, "\"judge\""),
358            (DetectorMode::Model, "\"model\""),
359        ] {
360            let serialized = serde_json::to_string(&mode).unwrap();
361            assert_eq!(serialized, expected_str, "serialize {mode:?}");
362            let deserialized: DetectorMode = serde_json::from_str(&serialized).unwrap();
363            assert_eq!(deserialized, mode, "deserialize {mode:?}");
364        }
365    }
366
367    #[test]
368    fn learning_config_default_detector_mode_is_regex() {
369        let cfg = LearningConfig::default();
370        assert_eq!(cfg.detector_mode, DetectorMode::Regex);
371    }
372
373    #[test]
374    fn learning_config_default_feedback_provider_is_empty() {
375        let cfg = LearningConfig::default();
376        assert!(cfg.feedback_provider.is_empty());
377    }
378
379    #[test]
380    fn learning_config_deserialize_model_mode() {
381        let toml = r#"detector_mode = "model"
382feedback_provider = "fast""#;
383        let cfg: LearningConfig = toml::from_str(toml).unwrap();
384        assert_eq!(cfg.detector_mode, DetectorMode::Model);
385        assert_eq!(cfg.feedback_provider, "fast");
386    }
387
388    #[test]
389    fn learning_config_deserialize_empty_feedback_provider() {
390        let toml = r#"detector_mode = "model""#;
391        let cfg: LearningConfig = toml::from_str(toml).unwrap();
392        assert_eq!(cfg.detector_mode, DetectorMode::Model);
393        assert!(
394            cfg.feedback_provider.is_empty(),
395            "empty feedback_provider must default to empty string (fallback to primary)"
396        );
397    }
398
399    #[test]
400    fn learning_config_deserialize_empty_section_uses_defaults() {
401        let cfg: LearningConfig = toml::from_str("").unwrap();
402        assert!(!cfg.enabled);
403        assert_eq!(cfg.min_failures, 3);
404        assert_eq!(cfg.detector_mode, DetectorMode::Regex);
405        assert!(cfg.feedback_provider.is_empty());
406    }
407
408    #[test]
409    fn learning_config_defaults_for_new_fields() {
410        let cfg = LearningConfig::default();
411        assert!(!cfg.cross_session_rollout);
412        assert_eq!(cfg.min_sessions_before_promote, 2);
413        assert_eq!(cfg.max_auto_sections, 3);
414        assert!(!cfg.domain_success_gate);
415    }
416
417    #[test]
418    fn learning_config_min_sessions_before_demote_default() {
419        let cfg = LearningConfig::default();
420        assert_eq!(cfg.min_sessions_before_demote, 1);
421    }
422
423    #[test]
424    fn arise_stem_erl_defaults() {
425        let cfg = LearningConfig::default();
426        assert!(!cfg.arise_enabled);
427        assert_eq!(cfg.arise_min_tool_calls, 2);
428        assert!(cfg.arise_trace_provider.is_empty());
429        assert!(!cfg.stem_enabled);
430        assert_eq!(cfg.stem_min_occurrences, 3);
431        assert!((cfg.stem_min_success_rate - 0.8).abs() < f64::EPSILON);
432        assert!(cfg.stem_provider.is_empty());
433        assert_eq!(cfg.stem_retention_days, 90);
434        assert_eq!(cfg.stem_pattern_window_days, 30);
435        assert!(!cfg.erl_enabled);
436        assert!(cfg.erl_extract_provider.is_empty());
437        assert_eq!(cfg.erl_max_heuristics_per_skill, 3);
438        assert!((cfg.erl_dedup_threshold - 0.9).abs() < f32::EPSILON);
439        assert!((cfg.erl_min_confidence - 0.5).abs() < f64::EPSILON);
440    }
441
442    #[test]
443    fn arise_stem_erl_serde_roundtrip() {
444        let toml = r#"
445arise_enabled = true
446arise_min_tool_calls = 3
447arise_trace_provider = "fast"
448stem_enabled = true
449stem_min_occurrences = 5
450stem_min_success_rate = 0.9
451stem_provider = "mid"
452stem_retention_days = 60
453stem_pattern_window_days = 14
454erl_enabled = true
455erl_extract_provider = "fast"
456erl_max_heuristics_per_skill = 5
457erl_dedup_threshold = 0.85
458erl_min_confidence = 0.6
459"#;
460        let cfg: LearningConfig = toml::from_str(toml).unwrap();
461        assert!(cfg.arise_enabled);
462        assert_eq!(cfg.arise_min_tool_calls, 3);
463        assert_eq!(cfg.arise_trace_provider, "fast");
464        assert!(cfg.stem_enabled);
465        assert_eq!(cfg.stem_min_occurrences, 5);
466        assert!((cfg.stem_min_success_rate - 0.9).abs() < f64::EPSILON);
467        assert_eq!(cfg.stem_provider, "mid");
468        assert_eq!(cfg.stem_retention_days, 60);
469        assert_eq!(cfg.stem_pattern_window_days, 14);
470        assert!(cfg.erl_enabled);
471        assert_eq!(cfg.erl_extract_provider, "fast");
472        assert_eq!(cfg.erl_max_heuristics_per_skill, 5);
473        assert!((cfg.erl_dedup_threshold - 0.85_f32).abs() < f32::EPSILON);
474        assert!((cfg.erl_min_confidence - 0.6).abs() < f64::EPSILON);
475    }
476
477    #[test]
478    fn arise_stem_erl_empty_section_uses_defaults() {
479        let cfg: LearningConfig = toml::from_str("").unwrap();
480        assert!(!cfg.arise_enabled);
481        assert!(!cfg.stem_enabled);
482        assert!(!cfg.erl_enabled);
483    }
484
485    #[test]
486    fn learning_config_new_fields_serde_roundtrip() {
487        let toml = r"
488cross_session_rollout = true
489min_sessions_before_promote = 5
490min_sessions_before_demote = 2
491max_auto_sections = 4
492domain_success_gate = true
493";
494        let cfg: LearningConfig = toml::from_str(toml).unwrap();
495        assert!(cfg.cross_session_rollout);
496        assert_eq!(cfg.min_sessions_before_promote, 5);
497        assert_eq!(cfg.min_sessions_before_demote, 2);
498        assert_eq!(cfg.max_auto_sections, 4);
499        assert!(cfg.domain_success_gate);
500    }
501}