Skip to main content

zeph_config/
experiment.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt;
5use std::str::FromStr;
6
7use crate::providers::ProviderName;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Strategy applied when a task in the orchestration graph fails.
12///
13/// Set at the graph level via [`OrchestrationConfig::default_failure_strategy`] and overridden
14/// per-task in the task node. Variants map directly to the `serde` lowercase string form used in
15/// TOML config and LLM-produced JSON plans.
16///
17/// # Examples
18///
19/// ```rust
20/// use zeph_config::FailureStrategy;
21///
22/// assert_eq!(FailureStrategy::default(), FailureStrategy::Abort);
23///
24/// let s: FailureStrategy = serde_json::from_str("\"skip\"").unwrap();
25/// assert_eq!(s, FailureStrategy::Skip);
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum FailureStrategy {
30    /// Abort the entire graph and cancel all running tasks.
31    #[default]
32    Abort,
33    /// Retry the task up to the configured `max_retries` limit, then abort.
34    Retry,
35    /// Skip the failed task and transitively skip all its dependents.
36    Skip,
37    /// Pause the graph and wait for user intervention.
38    Ask,
39}
40
41impl fmt::Display for FailureStrategy {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Abort => write!(f, "abort"),
45            Self::Retry => write!(f, "retry"),
46            Self::Skip => write!(f, "skip"),
47            Self::Ask => write!(f, "ask"),
48        }
49    }
50}
51
52impl FromStr for FailureStrategy {
53    type Err = String;
54
55    fn from_str(s: &str) -> Result<Self, Self::Err> {
56        match s {
57            "abort" => Ok(Self::Abort),
58            "retry" => Ok(Self::Retry),
59            "skip" => Ok(Self::Skip),
60            "ask" => Ok(Self::Ask),
61            other => Err(format!(
62                "unknown failure strategy '{other}': expected one of abort, retry, skip, ask"
63            )),
64        }
65    }
66}
67
68fn default_planner_max_tokens() -> u32 {
69    4096
70}
71
72fn default_aggregator_max_tokens() -> u32 {
73    4096
74}
75
76fn default_deferral_backoff_ms() -> u64 {
77    100
78}
79
80fn default_experiment_max_experiments() -> u32 {
81    20
82}
83
84fn default_experiment_max_wall_time_secs() -> u64 {
85    3600
86}
87
88fn default_experiment_min_improvement() -> f64 {
89    0.5
90}
91
92fn default_experiment_eval_budget_tokens() -> u64 {
93    100_000
94}
95
96fn default_experiment_schedule_cron() -> String {
97    "0 3 * * *".to_string()
98}
99
100fn default_experiment_max_experiments_per_run() -> u32 {
101    20
102}
103
104fn default_experiment_schedule_max_wall_time_secs() -> u64 {
105    1800
106}
107
108fn default_verify_max_tokens() -> u32 {
109    1024
110}
111
112fn default_max_replans() -> u32 {
113    2
114}
115
116fn default_completeness_threshold() -> f32 {
117    0.7
118}
119
120fn default_cascade_failure_threshold() -> f32 {
121    0.5
122}
123
124fn default_cascade_chain_threshold() -> usize {
125    3
126}
127
128fn default_lineage_ttl_secs() -> u64 {
129    300
130}
131
132fn default_max_predicate_replans() -> u32 {
133    2
134}
135
136fn default_predicate_timeout_secs() -> u64 {
137    30
138}
139
140fn default_persistence_enabled() -> bool {
141    true
142}
143
144fn default_aggregator_timeout_secs() -> u64 {
145    60
146}
147
148fn default_planner_timeout_secs() -> u64 {
149    120
150}
151
152fn default_verifier_timeout_secs() -> u64 {
153    30
154}
155
156fn default_plan_cache_similarity_threshold() -> f32 {
157    0.90
158}
159
160fn default_plan_cache_ttl_days() -> u32 {
161    30
162}
163
164fn default_plan_cache_max_templates() -> u32 {
165    100
166}
167
168/// Configuration for plan template caching (`[orchestration.plan_cache]` TOML section).
169#[derive(Debug, Clone, Deserialize, Serialize)]
170#[serde(default)]
171pub struct PlanCacheConfig {
172    /// Enable plan template caching. Default: false.
173    pub enabled: bool,
174    /// Minimum cosine similarity to consider a cached template a match. Default: 0.90.
175    #[serde(default = "default_plan_cache_similarity_threshold")]
176    pub similarity_threshold: f32,
177    /// Days since last access before a template is evicted. Default: 30.
178    #[serde(default = "default_plan_cache_ttl_days")]
179    pub ttl_days: u32,
180    /// Maximum number of cached templates. Default: 100.
181    #[serde(default = "default_plan_cache_max_templates")]
182    pub max_templates: u32,
183}
184
185impl Default for PlanCacheConfig {
186    fn default() -> Self {
187        Self {
188            enabled: false,
189            similarity_threshold: default_plan_cache_similarity_threshold(),
190            ttl_days: default_plan_cache_ttl_days(),
191            max_templates: default_plan_cache_max_templates(),
192        }
193    }
194}
195
196impl PlanCacheConfig {
197    /// Validate that all fields are within sane operating limits.
198    ///
199    /// # Errors
200    ///
201    /// Returns a description string if any field is outside the allowed range.
202    pub fn validate(&self) -> Result<(), String> {
203        if !(0.5..=1.0).contains(&self.similarity_threshold) {
204            return Err(format!(
205                "plan_cache.similarity_threshold must be in [0.5, 1.0], got {}",
206                self.similarity_threshold
207            ));
208        }
209        if self.max_templates == 0 || self.max_templates > 10_000 {
210            return Err(format!(
211                "plan_cache.max_templates must be in [1, 10000], got {}",
212                self.max_templates
213            ));
214        }
215        if self.ttl_days == 0 || self.ttl_days > 365 {
216            return Err(format!(
217                "plan_cache.ttl_days must be in [1, 365], got {}",
218                self.ttl_days
219            ));
220        }
221        Ok(())
222    }
223}
224
225/// Configuration for the task orchestration subsystem (`[orchestration]` TOML section).
226#[derive(Debug, Clone, Deserialize, Serialize)]
227#[serde(default)]
228#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
229pub struct OrchestrationConfig {
230    /// Enable the orchestration subsystem.
231    pub enabled: bool,
232    /// Maximum number of tasks in a single graph.
233    pub max_tasks: u32,
234    /// Maximum number of tasks that can run in parallel.
235    pub max_parallel: u32,
236    /// Default failure strategy applied to every task graph unless overridden per-task.
237    #[serde(default)]
238    pub default_failure_strategy: FailureStrategy,
239    /// Default number of retries for the `retry` failure strategy.
240    pub default_max_retries: u32,
241    /// Timeout in seconds for a single task. `0` means no timeout.
242    pub task_timeout_secs: u64,
243    /// Provider name from `[[llm.providers]]` for planning LLM calls.
244    /// Empty string = use the agent's primary provider.
245    #[serde(default)]
246    pub planner_provider: ProviderName,
247    /// Maximum tokens budget hint for planner responses. Reserved for future use when
248    /// per-call token limits are added to the `LlmProvider::chat` API.
249    #[serde(default = "default_planner_max_tokens")]
250    pub planner_max_tokens: u32,
251    /// Total character budget for cross-task dependency context injection.
252    pub dependency_context_budget: usize,
253    /// Whether to show a confirmation prompt before executing a plan.
254    pub confirm_before_execute: bool,
255    /// Maximum tokens budget for aggregation LLM calls. Default: 4096.
256    #[serde(default = "default_aggregator_max_tokens")]
257    pub aggregator_max_tokens: u32,
258    /// Base backoff for `ConcurrencyLimit` retries; grows exponentially (×2 each attempt) up to 5 s.
259    #[serde(default = "default_deferral_backoff_ms")]
260    pub deferral_backoff_ms: u64,
261    /// Plan template caching configuration.
262    #[serde(default)]
263    pub plan_cache: PlanCacheConfig,
264    /// Enable topology-aware concurrency selection. When true, `TopologyClassifier`
265    /// adjusts `max_parallel` based on the DAG structure. Default: false (opt-in).
266    #[serde(default)]
267    pub topology_selection: bool,
268    /// Provider name from `[[llm.providers]]` for verification LLM calls.
269    /// Empty string = use the agent's primary provider. Should be a cheap/fast provider.
270    #[serde(default)]
271    pub verify_provider: ProviderName,
272    /// Maximum tokens budget for verification LLM calls. Default: 1024.
273    #[serde(default = "default_verify_max_tokens")]
274    pub verify_max_tokens: u32,
275    /// Maximum number of replan cycles per graph execution. Default: 2.
276    ///
277    /// Prevents infinite verify-replan loops. 0 = disable replan (verification still
278    /// runs, gaps are logged only).
279    #[serde(default = "default_max_replans")]
280    pub max_replans: u32,
281    /// Enable post-task completeness verification. Default: false (opt-in).
282    ///
283    /// When true, completed tasks are evaluated by `PlanVerifier`. Task stays
284    /// `Completed` during verification; downstream tasks are unblocked immediately.
285    /// Verification is best-effort and does not gate dispatch.
286    #[serde(default)]
287    pub verify_completeness: bool,
288    /// Provider name from `[[llm.providers]]` for tool-dispatch routing.
289    /// When set, tool-heavy tasks prefer this provider over the primary.
290    /// Prefer mid-tier models (e.g., qwen2.5:14b) for reliability per arXiv:2601.16280.
291    /// Empty string = use the primary provider.
292    #[serde(default)]
293    pub tool_provider: ProviderName,
294    /// Minimum completeness score (0.0–1.0) for the plan to be accepted without
295    /// replanning. Default: 0.7. When the verifier reports `confidence <
296    /// completeness_threshold` AND gaps exist, a replan cycle is triggered.
297    /// Used by both per-task and whole-plan verification.
298    /// Values outside [0.0, 1.0] are rejected at startup by `Config::validate()`.
299    #[serde(default = "default_completeness_threshold")]
300    pub completeness_threshold: f32,
301    /// Enable cascade-aware routing for Mixed-topology DAGs. Requires `topology_selection = true`.
302    /// When enabled, tasks in failing subtrees are deprioritized in favour of healthy branches.
303    /// Default: false (opt-in).
304    #[serde(default)]
305    pub cascade_routing: bool,
306    /// Failure rate threshold (0.0–1.0) above which a DAG region is considered "cascading".
307    /// Must be in (0.0, 1.0]. Default: 0.5.
308    #[serde(default = "default_cascade_failure_threshold")]
309    pub cascade_failure_threshold: f32,
310    /// Enable tree-optimized dispatch for FanOut/FanIn topologies.
311    /// Sorts the ready queue by critical-path distance (deepest tasks first) to minimize
312    /// end-to-end latency. Default: false (opt-in).
313    #[serde(default)]
314    pub tree_optimized_dispatch: bool,
315
316    /// `AdaptOrch` bandit-driven topology advisor. Default: disabled.
317    #[serde(default)]
318    pub adaptorch: AdaptOrchConfig,
319    /// Consecutive-chain cascade abort threshold: number of consecutive `Failed` entries
320    /// in a `depends_on` chain that triggers a DAG abort.
321    ///
322    /// `0` disables linear-chain cascade abort. Default: 3.
323    /// Must not be `1` — a threshold of 1 would abort on every single failure.
324    #[serde(default = "default_cascade_chain_threshold")]
325    pub cascade_chain_threshold: usize,
326    /// Fan-out cascade abort failure-rate threshold (0.0–1.0).
327    ///
328    /// When a DAG region's failure rate reaches this value AND the region has ≥ 3 tasks,
329    /// the DAG is aborted immediately. `0.0` disables this signal (opt-in).
330    /// Recommended production value: `0.7`.
331    #[serde(default)]
332    pub cascade_failure_rate_abort_threshold: f32,
333    /// TTL for lineage entries in seconds. Entries older than this are pruned during
334    /// chain merge. Setting this too low can prevent detection of slow-build cascades.
335    ///
336    /// Default: 300 seconds (5 minutes).
337    #[serde(default = "default_lineage_ttl_secs")]
338    pub lineage_ttl_secs: u64,
339    /// Enable per-subtask predicate verification gate.
340    ///
341    /// Requires `predicate_provider` or a primary LLM provider to be configured.
342    /// Default: false (opt-in).
343    #[serde(default)]
344    pub verify_predicate_enabled: bool,
345    /// Provider name from `[[llm.providers]]` for predicate evaluation.
346    ///
347    /// Empty string = fall back to `verify_provider`, then primary.
348    #[serde(default)]
349    pub predicate_provider: ProviderName,
350    /// Maximum number of predicate-driven task re-runs across the entire DAG.
351    ///
352    /// Independent of `max_replans` (verifier completeness budget). Default: 2.
353    #[serde(default = "default_max_predicate_replans")]
354    pub max_predicate_replans: u32,
355    /// Timeout in seconds for each predicate LLM evaluation call.
356    ///
357    /// On timeout the evaluator returns a fail-open outcome (`passed = true`,
358    /// `confidence = 0.0`) and logs a warning. Default: 30.
359    #[serde(default = "default_predicate_timeout_secs")]
360    pub predicate_timeout_secs: u64,
361    /// Persist task graph state to `SQLite` across scheduler ticks.
362    ///
363    /// When `true` and a `SemanticMemory` store is available, the scheduler
364    /// snapshots the graph once per tick and on plan completion. Graphs can
365    /// then be rehydrated via `/plan resume <id>` after a restart.
366    /// Default: `true`.
367    #[serde(default = "default_persistence_enabled")]
368    pub persistence_enabled: bool,
369    /// Provider name from `[[llm.providers]]` for scheduling-tier LLM calls
370    /// (aggregation, predicate evaluation, verification when no specific provider is set).
371    ///
372    /// Acts as fallback for `verify_provider` and `predicate_provider` when those are empty.
373    /// Does NOT affect `planner_provider` — planning is a complex task and stays on the quality
374    /// provider. Empty string = use the agent's primary provider.
375    ///
376    /// # Trade-off
377    ///
378    /// Setting this to a fast/cheap model reduces aggregation quality because `LlmAggregator`
379    /// produces user-visible output. See CHANGELOG for details.
380    #[serde(default)]
381    pub orchestrator_provider: ProviderName,
382
383    /// Default per-task cost budget in US cents. `0.0` = unlimited (no budget check).
384    ///
385    /// When a sub-agent task completes, the scheduler emits a `tracing::warn!` if the
386    /// task exceeded this budget. In MVP this is **warn-only** — hard enforcement requires
387    /// per-task `CostTracker` scoping, which is deferred post-v1.0.0.
388    ///
389    /// Individual tasks can override this via `TaskNode::token_budget_cents`.
390    /// Default: `0.0` (unlimited).
391    #[serde(default)]
392    pub default_task_budget_cents: f64,
393
394    /// Timeout in seconds for aggregation LLM calls. Default: 60.
395    ///
396    /// On timeout the aggregator falls back to raw concatenation so that a graph
397    /// result is always returned. Set to `0` is rejected by `Config::validate()`.
398    #[serde(default = "default_aggregator_timeout_secs")]
399    pub aggregator_timeout_secs: u64,
400
401    /// Timeout in seconds for planner LLM calls. Default: 120.
402    ///
403    /// On timeout the planner returns `OrchestrationError::PlanningFailed`.
404    /// Planning has no fallback — without a graph no tasks can be dispatched.
405    /// Set to `0` is rejected by `Config::validate()`.
406    #[serde(default = "default_planner_timeout_secs")]
407    pub planner_timeout_secs: u64,
408
409    /// Timeout in seconds for verifier LLM calls (per-task and whole-plan). Default: 30.
410    ///
411    /// On timeout the verifier returns a fail-open result (`complete = true`, no gaps).
412    /// Matches the existing `predicate_timeout_secs` default.
413    /// Set to `0` is rejected by `Config::validate()`.
414    #[serde(default = "default_verifier_timeout_secs")]
415    pub verifier_timeout_secs: u64,
416}
417
418impl Default for OrchestrationConfig {
419    fn default() -> Self {
420        Self {
421            enabled: false,
422            max_tasks: 20,
423            max_parallel: 4,
424            default_failure_strategy: FailureStrategy::default(),
425            default_max_retries: 3,
426            task_timeout_secs: 300,
427            planner_provider: ProviderName::default(),
428            planner_max_tokens: default_planner_max_tokens(),
429            dependency_context_budget: 16384,
430            confirm_before_execute: true,
431            aggregator_max_tokens: default_aggregator_max_tokens(),
432            deferral_backoff_ms: default_deferral_backoff_ms(),
433            plan_cache: PlanCacheConfig::default(),
434            topology_selection: false,
435            verify_provider: ProviderName::default(),
436            verify_max_tokens: default_verify_max_tokens(),
437            max_replans: default_max_replans(),
438            verify_completeness: false,
439            completeness_threshold: default_completeness_threshold(),
440            tool_provider: ProviderName::default(),
441            cascade_routing: false,
442            cascade_failure_threshold: default_cascade_failure_threshold(),
443            tree_optimized_dispatch: false,
444            adaptorch: AdaptOrchConfig::default(),
445            cascade_chain_threshold: default_cascade_chain_threshold(),
446            cascade_failure_rate_abort_threshold: 0.0,
447            lineage_ttl_secs: default_lineage_ttl_secs(),
448            verify_predicate_enabled: false,
449            predicate_provider: ProviderName::default(),
450            max_predicate_replans: default_max_predicate_replans(),
451            predicate_timeout_secs: default_predicate_timeout_secs(),
452            persistence_enabled: default_persistence_enabled(),
453            orchestrator_provider: ProviderName::default(),
454            default_task_budget_cents: 0.0,
455            aggregator_timeout_secs: default_aggregator_timeout_secs(),
456            planner_timeout_secs: default_planner_timeout_secs(),
457            verifier_timeout_secs: default_verifier_timeout_secs(),
458        }
459    }
460}
461
462/// Configuration for the autonomous self-experimentation engine (`[experiments]` TOML section).
463///
464/// When `enabled = true`, Zeph periodically runs A/B experiments on its own skill and
465/// prompt configurations to find improvements automatically.
466///
467/// # Example (TOML)
468///
469/// ```toml
470/// [experiments]
471/// enabled = false
472/// max_experiments = 20
473/// auto_apply = false
474/// ```
475#[derive(Debug, Clone, Deserialize, Serialize)]
476#[serde(default)]
477pub struct ExperimentConfig {
478    /// Enable autonomous self-experimentation. Default: `false`.
479    pub enabled: bool,
480    /// Model identifier used for evaluating experiment outcomes.
481    pub eval_model: Option<String>,
482    /// Path to a benchmark JSONL file for evaluating experiments.
483    pub benchmark_file: Option<std::path::PathBuf>,
484    #[serde(default = "default_experiment_max_experiments")]
485    pub max_experiments: u32,
486    #[serde(default = "default_experiment_max_wall_time_secs")]
487    pub max_wall_time_secs: u64,
488    #[serde(default = "default_experiment_min_improvement")]
489    pub min_improvement: f64,
490    #[serde(default = "default_experiment_eval_budget_tokens")]
491    pub eval_budget_tokens: u64,
492    pub auto_apply: bool,
493    #[serde(default)]
494    pub schedule: ExperimentSchedule,
495}
496
497impl Default for ExperimentConfig {
498    fn default() -> Self {
499        Self {
500            enabled: false,
501            eval_model: None,
502            benchmark_file: None,
503            max_experiments: default_experiment_max_experiments(),
504            max_wall_time_secs: default_experiment_max_wall_time_secs(),
505            min_improvement: default_experiment_min_improvement(),
506            eval_budget_tokens: default_experiment_eval_budget_tokens(),
507            auto_apply: false,
508            schedule: ExperimentSchedule::default(),
509        }
510    }
511}
512
513/// Configuration for `AdaptOrch` — bandit-driven topology advisor (`[orchestration.adaptorch]`).
514///
515/// # Example
516///
517/// ```toml
518/// [orchestration.adaptorch]
519/// enabled = true
520/// topology_provider = "fast"
521/// classify_timeout_secs = 4
522/// state_path = ""
523/// ```
524#[derive(Debug, Clone, Deserialize, Serialize)]
525#[serde(default)]
526pub struct AdaptOrchConfig {
527    /// Enable `AdaptOrch`. When `false`, planning uses the default `plan()` path.
528    pub enabled: bool,
529    /// Provider name from `[[llm.providers]]` for goal classification. Empty → primary provider.
530    pub topology_provider: ProviderName,
531    /// Hard timeout (seconds) for the classification LLM call.
532    #[serde(default = "default_classify_timeout_secs")]
533    pub classify_timeout_secs: u64,
534    /// Path to the persisted Beta-arm JSON state file.
535    /// Empty string → `~/.zeph/adaptorch_state.json` (resolved at runtime).
536    #[serde(default)]
537    pub state_path: String,
538    /// Maximum tokens for the classification LLM call.
539    #[serde(default = "default_max_classify_tokens")]
540    pub max_classify_tokens: u32,
541}
542
543fn default_classify_timeout_secs() -> u64 {
544    4
545}
546
547fn default_max_classify_tokens() -> u32 {
548    80
549}
550
551impl Default for AdaptOrchConfig {
552    fn default() -> Self {
553        Self {
554            enabled: false,
555            topology_provider: ProviderName::default(),
556            classify_timeout_secs: default_classify_timeout_secs(),
557            state_path: String::new(),
558            max_classify_tokens: default_max_classify_tokens(),
559        }
560    }
561}
562
563/// Cron scheduling configuration for automatic experiment runs.
564#[derive(Debug, Clone, Deserialize, Serialize)]
565#[serde(default)]
566pub struct ExperimentSchedule {
567    pub enabled: bool,
568    #[serde(default = "default_experiment_schedule_cron")]
569    pub cron: String,
570    #[serde(default = "default_experiment_max_experiments_per_run")]
571    pub max_experiments_per_run: u32,
572    /// Wall-time cap for a single scheduled experiment session (seconds).
573    ///
574    /// Overrides `experiments.max_wall_time_secs` for scheduled runs. Defaults to 1800s so
575    /// a background session cannot overlap the next cron trigger on typical schedules.
576    #[serde(default = "default_experiment_schedule_max_wall_time_secs")]
577    pub max_wall_time_secs: u64,
578}
579
580impl Default for ExperimentSchedule {
581    fn default() -> Self {
582        Self {
583            enabled: false,
584            cron: default_experiment_schedule_cron(),
585            max_experiments_per_run: default_experiment_max_experiments_per_run(),
586            max_wall_time_secs: default_experiment_schedule_max_wall_time_secs(),
587        }
588    }
589}
590
591impl ExperimentConfig {
592    /// Validate that numeric bounds are within sane operating limits.
593    ///
594    /// # Errors
595    ///
596    /// Returns a description string if any field is outside allowed range.
597    pub fn validate(&self) -> Result<(), String> {
598        if !(1..=1_000).contains(&self.max_experiments) {
599            return Err(format!(
600                "experiments.max_experiments must be in 1..=1000, got {}",
601                self.max_experiments
602            ));
603        }
604        if !(60..=86_400).contains(&self.max_wall_time_secs) {
605            return Err(format!(
606                "experiments.max_wall_time_secs must be in 60..=86400, got {}",
607                self.max_wall_time_secs
608            ));
609        }
610        if !(1_000..=10_000_000).contains(&self.eval_budget_tokens) {
611            return Err(format!(
612                "experiments.eval_budget_tokens must be in 1000..=10000000, got {}",
613                self.eval_budget_tokens
614            ));
615        }
616        if !(0.0..=100.0).contains(&self.min_improvement) {
617            return Err(format!(
618                "experiments.min_improvement must be in 0.0..=100.0, got {}",
619                self.min_improvement
620            ));
621        }
622        if !(1..=100).contains(&self.schedule.max_experiments_per_run) {
623            return Err(format!(
624                "experiments.schedule.max_experiments_per_run must be in 1..=100, got {}",
625                self.schedule.max_experiments_per_run
626            ));
627        }
628        if !(60..=86_400).contains(&self.schedule.max_wall_time_secs) {
629            return Err(format!(
630                "experiments.schedule.max_wall_time_secs must be in 60..=86400, got {}",
631                self.schedule.max_wall_time_secs
632            ));
633        }
634        Ok(())
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn plan_cache_similarity_threshold_above_one_is_rejected() {
644        let cfg = PlanCacheConfig {
645            similarity_threshold: 1.1,
646            ..PlanCacheConfig::default()
647        };
648        let result = cfg.validate();
649        assert!(
650            result.is_err(),
651            "similarity_threshold = 1.1 must return a validation error"
652        );
653    }
654
655    #[test]
656    fn completeness_threshold_default_is_0_7() {
657        let cfg = OrchestrationConfig::default();
658        assert!(
659            (cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
660            "completeness_threshold default must be 0.7, got {}",
661            cfg.completeness_threshold
662        );
663    }
664
665    #[test]
666    fn completeness_threshold_serde_round_trip() {
667        let toml_in = r"
668            enabled = true
669            completeness_threshold = 0.85
670        ";
671        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
672        assert!((cfg.completeness_threshold - 0.85).abs() < f32::EPSILON);
673
674        let serialized = toml::to_string(&cfg).expect("serialize");
675        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
676        assert!((cfg2.completeness_threshold - 0.85).abs() < f32::EPSILON);
677    }
678
679    #[test]
680    fn completeness_threshold_missing_uses_default() {
681        let toml_in = "enabled = true\n";
682        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
683        assert!(
684            (cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
685            "missing field must use default 0.7, got {}",
686            cfg.completeness_threshold
687        );
688    }
689}