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