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