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 serde::{Deserialize, Serialize};
5
6fn default_min_failures() -> u32 {
7    3
8}
9
10fn default_improve_threshold() -> f64 {
11    0.7
12}
13
14fn default_rollback_threshold() -> f64 {
15    0.5
16}
17
18fn default_min_evaluations() -> u32 {
19    5
20}
21
22fn default_max_versions() -> u32 {
23    10
24}
25
26fn default_cooldown_minutes() -> u64 {
27    60
28}
29
30fn default_correction_detection() -> bool {
31    true
32}
33
34fn default_correction_confidence_threshold() -> f32 {
35    0.6
36}
37
38fn default_judge_adaptive_low() -> f32 {
39    0.5
40}
41
42fn default_judge_adaptive_high() -> f32 {
43    0.8
44}
45
46fn default_correction_recall_limit() -> u32 {
47    3
48}
49
50fn default_correction_min_similarity() -> f32 {
51    0.75
52}
53
54fn default_auto_promote_min_uses() -> u32 {
55    50
56}
57
58fn default_auto_promote_threshold() -> f64 {
59    0.95
60}
61
62fn default_auto_demote_min_uses() -> u32 {
63    30
64}
65
66fn default_auto_demote_threshold() -> f64 {
67    0.40
68}
69
70/// Strategy for detecting implicit user corrections.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
72#[serde(rename_all = "lowercase")]
73pub enum DetectorMode {
74    /// Pattern-matching only — zero LLM calls. Default behavior.
75    #[default]
76    Regex,
77    /// LLM-based judge for borderline / missed cases. Invoked only when
78    /// regex confidence falls below `judge_adaptive_high` or regex returns None.
79    ///
80    /// Note: with current regex values (ExplicitRejection=0.85, SelfCorrection=0.80,
81    /// Repetition=0.75, AlternativeRequest=0.70) and `adaptive_high=0.80`,
82    /// `ExplicitRejection` and `SelfCorrection` bypass the judge (confidence >= `adaptive_high`),
83    /// while `AlternativeRequest`, `Repetition`, and regex misses go through it.
84    Judge,
85    /// ML model-backed feedback classification via `LlmClassifier`.
86    ///
87    /// Uses the provider named in `feedback_provider` (or the primary provider if empty).
88    /// Shares the same adaptive thresholds and rate limiter as `Judge` mode.
89    /// Returns `JudgeVerdict` directly, preserving `kind` and `reasoning` metadata.
90    ///
91    /// Falls back to regex-only if the provider cannot be resolved — never fails startup.
92    Model,
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize)]
96pub struct LearningConfig {
97    #[serde(default)]
98    pub enabled: bool,
99    #[serde(default)]
100    pub auto_activate: bool,
101    #[serde(default = "default_min_failures")]
102    pub min_failures: u32,
103    #[serde(default = "default_improve_threshold")]
104    pub improve_threshold: f64,
105    #[serde(default = "default_rollback_threshold")]
106    pub rollback_threshold: f64,
107    #[serde(default = "default_min_evaluations")]
108    pub min_evaluations: u32,
109    #[serde(default = "default_max_versions")]
110    pub max_versions: u32,
111    #[serde(default = "default_cooldown_minutes")]
112    pub cooldown_minutes: u64,
113    #[serde(default = "default_correction_detection")]
114    pub correction_detection: bool,
115    #[serde(default = "default_correction_confidence_threshold")]
116    pub correction_confidence_threshold: f32,
117    /// Detector strategy: "regex" (default) or "judge".
118    #[serde(default)]
119    pub detector_mode: DetectorMode,
120    /// Model for the judge detector (e.g. "claude-sonnet-4-6"). Empty = use primary provider.
121    #[serde(default)]
122    pub judge_model: String,
123    /// Provider name from `[[llm.providers]]` for `detector_mode = "model"` (`LlmClassifier`).
124    ///
125    /// Empty = use the primary provider. Named but not found in registry = log warning,
126    /// degrade to regex-only. Never fails startup.
127    #[serde(default)]
128    pub feedback_provider: String,
129    /// Regex confidence below this value is treated as "not a correction" — judge not invoked.
130    #[serde(default = "default_judge_adaptive_low")]
131    pub judge_adaptive_low: f32,
132    /// Regex confidence at or above this value is accepted without judge confirmation.
133    #[serde(default = "default_judge_adaptive_high")]
134    pub judge_adaptive_high: f32,
135    #[serde(default = "default_correction_recall_limit")]
136    pub correction_recall_limit: u32,
137    #[serde(default = "default_correction_min_similarity")]
138    pub correction_min_similarity: f32,
139    #[serde(default = "default_auto_promote_min_uses")]
140    pub auto_promote_min_uses: u32,
141    #[serde(default = "default_auto_promote_threshold")]
142    pub auto_promote_threshold: f64,
143    #[serde(default = "default_auto_demote_min_uses")]
144    pub auto_demote_min_uses: u32,
145    #[serde(default = "default_auto_demote_threshold")]
146    pub auto_demote_threshold: f64,
147}
148
149impl Default for LearningConfig {
150    fn default() -> Self {
151        Self {
152            enabled: false,
153            auto_activate: false,
154            min_failures: default_min_failures(),
155            improve_threshold: default_improve_threshold(),
156            rollback_threshold: default_rollback_threshold(),
157            min_evaluations: default_min_evaluations(),
158            max_versions: default_max_versions(),
159            cooldown_minutes: default_cooldown_minutes(),
160            correction_detection: default_correction_detection(),
161            correction_confidence_threshold: default_correction_confidence_threshold(),
162            detector_mode: DetectorMode::default(),
163            judge_model: String::new(),
164            feedback_provider: String::new(),
165            judge_adaptive_low: default_judge_adaptive_low(),
166            judge_adaptive_high: default_judge_adaptive_high(),
167            correction_recall_limit: default_correction_recall_limit(),
168            correction_min_similarity: default_correction_min_similarity(),
169            auto_promote_min_uses: default_auto_promote_min_uses(),
170            auto_promote_threshold: default_auto_promote_threshold(),
171            auto_demote_min_uses: default_auto_demote_min_uses(),
172            auto_demote_threshold: default_auto_demote_threshold(),
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn detector_mode_default_is_regex() {
183        assert_eq!(DetectorMode::default(), DetectorMode::Regex);
184    }
185
186    #[test]
187    fn detector_mode_serde_roundtrip() {
188        for (mode, expected_str) in [
189            (DetectorMode::Regex, "\"regex\""),
190            (DetectorMode::Judge, "\"judge\""),
191            (DetectorMode::Model, "\"model\""),
192        ] {
193            let serialized = serde_json::to_string(&mode).unwrap();
194            assert_eq!(serialized, expected_str, "serialize {mode:?}");
195            let deserialized: DetectorMode = serde_json::from_str(&serialized).unwrap();
196            assert_eq!(deserialized, mode, "deserialize {mode:?}");
197        }
198    }
199
200    #[test]
201    fn learning_config_default_detector_mode_is_regex() {
202        let cfg = LearningConfig::default();
203        assert_eq!(cfg.detector_mode, DetectorMode::Regex);
204    }
205
206    #[test]
207    fn learning_config_default_feedback_provider_is_empty() {
208        let cfg = LearningConfig::default();
209        assert!(cfg.feedback_provider.is_empty());
210    }
211
212    #[test]
213    fn learning_config_deserialize_model_mode() {
214        let toml = r#"detector_mode = "model"
215feedback_provider = "fast""#;
216        let cfg: LearningConfig = toml::from_str(toml).unwrap();
217        assert_eq!(cfg.detector_mode, DetectorMode::Model);
218        assert_eq!(cfg.feedback_provider, "fast");
219    }
220
221    #[test]
222    fn learning_config_deserialize_empty_feedback_provider() {
223        let toml = r#"detector_mode = "model""#;
224        let cfg: LearningConfig = toml::from_str(toml).unwrap();
225        assert_eq!(cfg.detector_mode, DetectorMode::Model);
226        assert!(
227            cfg.feedback_provider.is_empty(),
228            "empty feedback_provider must default to empty string (fallback to primary)"
229        );
230    }
231
232    #[test]
233    fn learning_config_deserialize_empty_section_uses_defaults() {
234        let cfg: LearningConfig = toml::from_str("").unwrap();
235        assert!(!cfg.enabled);
236        assert_eq!(cfg.min_failures, 3);
237        assert_eq!(cfg.detector_mode, DetectorMode::Regex);
238        assert!(cfg.feedback_provider.is_empty());
239    }
240}