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