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_ensemble_ema_alpha() -> f64 {
188    0.3
189}
190
191fn default_ensemble_ema_decay() -> f64 {
192    0.95
193}
194
195fn default_ensemble_min_observations() -> u32 {
196    5
197}
198
199fn default_plan_cache_similarity_threshold() -> f32 {
200    0.90
201}
202
203fn default_plan_cache_ttl_days() -> u32 {
204    30
205}
206
207fn default_plan_cache_max_templates() -> u32 {
208    100
209}
210
211/// Configuration for plan template caching (`[orchestration.plan_cache]` TOML section).
212#[derive(Debug, Clone, Deserialize, Serialize)]
213#[serde(default)]
214pub struct PlanCacheConfig {
215    /// Enable plan template caching. Default: false.
216    pub enabled: bool,
217    /// Minimum cosine similarity to consider a cached template a match. Default: 0.90.
218    #[serde(default = "default_plan_cache_similarity_threshold")]
219    pub similarity_threshold: f32,
220    /// Days since last access before a template is evicted. Default: 30.
221    #[serde(default = "default_plan_cache_ttl_days")]
222    pub ttl_days: u32,
223    /// Maximum number of cached templates. Default: 100.
224    #[serde(default = "default_plan_cache_max_templates")]
225    pub max_templates: u32,
226}
227
228impl Default for PlanCacheConfig {
229    fn default() -> Self {
230        Self {
231            enabled: false,
232            similarity_threshold: default_plan_cache_similarity_threshold(),
233            ttl_days: default_plan_cache_ttl_days(),
234            max_templates: default_plan_cache_max_templates(),
235        }
236    }
237}
238
239impl PlanCacheConfig {
240    /// Validate that all fields are within sane operating limits.
241    ///
242    /// # Errors
243    ///
244    /// Returns a description string if any field is outside the allowed range.
245    #[must_use = "validation result must be checked"]
246    pub fn validate(&self) -> Result<(), String> {
247        if !(0.5..=1.0).contains(&self.similarity_threshold) {
248            return Err(format!(
249                "plan_cache.similarity_threshold must be in [0.5, 1.0], got {}",
250                self.similarity_threshold
251            ));
252        }
253        if self.max_templates == 0 || self.max_templates > 10_000 {
254            return Err(format!(
255                "plan_cache.max_templates must be in [1, 10000], got {}",
256                self.max_templates
257            ));
258        }
259        if self.ttl_days == 0 || self.ttl_days > 365 {
260            return Err(format!(
261                "plan_cache.ttl_days must be in [1, 365], got {}",
262                self.ttl_days
263            ));
264        }
265        Ok(())
266    }
267}
268
269/// Configuration for ORCH-style deterministic verifier ensemble-merge
270/// (`[orchestration.ensemble]` TOML section, spec `073-orch-ensemble-merge`).
271///
272/// Opt-in and default-`OFF`: when `enabled = false` (the default), `PlanVerifier::verify()`
273/// behaves byte-for-byte identically to the single-provider path — no ensemble code runs.
274///
275/// `size` and `min_quorum` are intentionally NOT configurable fields — both are always
276/// derived from `members.len()` (`quorum = members.len() / 2 + 1`) so they can never drift
277/// out of sync with the member list.
278#[derive(Debug, Clone, Deserialize, Serialize)]
279#[serde(default)]
280pub struct EnsembleConfig {
281    /// Enable ensemble machinery (member resolution at bootstrap). Default: `false`.
282    ///
283    /// Separate from `verify` so the ensemble can be resolved/warmed without yet being used
284    /// for verification decisions.
285    pub enabled: bool,
286    /// Use the ensemble for `PlanVerifier` per-task verification. Default: `false`.
287    ///
288    /// Requires `enabled = true`. When `false` (even with `enabled = true`), the
289    /// `SchedulerAction::Verify` handler still uses the single-provider `verify_provider`
290    /// path — `verify` is the per-target activation flag.
291    pub verify: bool,
292    /// Provider names from `[[llm.providers]]`, one per ensemble member.
293    ///
294    /// Validated at config load time (when `enabled && verify`) to be odd-length, `>= 3`,
295    /// and free of duplicates — this guarantees a strict majority with no ties by
296    /// construction. Default: empty.
297    pub members: Vec<String>,
298    /// EMA smoothing factor for `EnsembleTracker`'s per-member agreement score, in `[0.0, 1.0]`.
299    /// Higher values weight the most recent observation more heavily. Default: `0.3`.
300    #[serde(default = "default_ensemble_ema_alpha")]
301    pub ema_alpha: f64,
302    /// Decay-toward-neutral-prior factor for `EnsembleTracker`, in `[0.0, 1.0]`. Default: `0.95`.
303    #[serde(default = "default_ensemble_ema_decay")]
304    pub ema_decay: f64,
305    /// Minimum recorded observations before `EnsembleTracker::ema()` returns a score instead
306    /// of `None` (cold-start gate). Default: `5`.
307    #[serde(default = "default_ensemble_min_observations")]
308    pub min_observations: u32,
309    /// Per-member `chat_typed` call timeout in seconds. `0` = fall back to
310    /// `verifier_timeout_secs`. Default: `0`.
311    #[serde(default)]
312    pub member_timeout_secs: u64,
313}
314
315impl Default for EnsembleConfig {
316    fn default() -> Self {
317        Self {
318            enabled: false,
319            verify: false,
320            members: Vec::new(),
321            ema_alpha: default_ensemble_ema_alpha(),
322            ema_decay: default_ensemble_ema_decay(),
323            min_observations: default_ensemble_min_observations(),
324            member_timeout_secs: 0,
325        }
326    }
327}
328
329/// Configuration for the task orchestration subsystem (`[orchestration]` TOML section).
330#[derive(Debug, Clone, Deserialize, Serialize)]
331#[serde(default)]
332#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
333pub struct OrchestrationConfig {
334    /// Enable the orchestration subsystem.
335    pub enabled: bool,
336    /// Maximum number of tasks in a single graph.
337    pub max_tasks: u32,
338    /// Maximum number of tasks that can run in parallel.
339    pub max_parallel: u32,
340    /// Default failure strategy applied to every task graph unless overridden per-task.
341    #[serde(default)]
342    pub default_failure_strategy: FailureStrategy,
343    /// Default number of retries for the `retry` failure strategy.
344    pub default_max_retries: u32,
345    /// Timeout in seconds for a single task. `0` means no timeout.
346    pub task_timeout_secs: u64,
347    /// Provider name from `[[llm.providers]]` for planning LLM calls.
348    /// Empty string = use the agent's primary provider.
349    #[serde(default)]
350    pub planner_provider: ProviderName,
351    /// Maximum tokens budget hint for planner responses. Reserved for future use when
352    /// per-call token limits are added to the `LlmProvider::chat` API.
353    #[serde(default = "default_planner_max_tokens")]
354    pub planner_max_tokens: u32,
355    /// Total character budget for cross-task dependency context injection.
356    pub dependency_context_budget: usize,
357    /// Whether to show a confirmation prompt before executing a plan.
358    pub confirm_before_execute: bool,
359    /// Maximum tokens budget for aggregation LLM calls. Default: 4096.
360    #[serde(default = "default_aggregator_max_tokens")]
361    pub aggregator_max_tokens: u32,
362    /// Base backoff for `ConcurrencyLimit` retries; grows exponentially (×2 each attempt) up to 5 s.
363    #[serde(default = "default_deferral_backoff_ms")]
364    pub deferral_backoff_ms: u64,
365    /// Plan template caching configuration.
366    #[serde(default)]
367    pub plan_cache: PlanCacheConfig,
368    /// Enable topology-aware concurrency selection. When true, `TopologyClassifier`
369    /// adjusts `max_parallel` based on the DAG structure. Default: false (opt-in).
370    #[serde(default)]
371    pub topology_selection: bool,
372    /// Provider name from `[[llm.providers]]` for verification LLM calls.
373    /// Empty string = use the agent's primary provider. Should be a cheap/fast provider.
374    #[serde(default)]
375    pub verify_provider: ProviderName,
376    /// Maximum tokens budget for verification LLM calls. Default: 1024.
377    #[serde(default = "default_verify_max_tokens")]
378    pub verify_max_tokens: u32,
379    /// Maximum number of replan cycles per graph execution. Default: 2.
380    ///
381    /// Prevents infinite verify-replan loops. 0 = disable replan (verification still
382    /// runs, gaps are logged only).
383    #[serde(default = "default_max_replans")]
384    pub max_replans: u32,
385    /// Enable post-task completeness verification. Default: false (opt-in).
386    ///
387    /// When true, completed tasks are evaluated by `PlanVerifier`. Task stays
388    /// `Completed` during verification; downstream tasks are unblocked immediately.
389    /// Verification is best-effort and does not gate dispatch.
390    #[serde(default)]
391    pub verify_completeness: bool,
392    /// Provider name from `[[llm.providers]]` for tool-dispatch routing.
393    /// When set, tool-heavy tasks prefer this provider over the primary.
394    /// Prefer mid-tier models (e.g., qwen2.5:14b) for reliability per arXiv:2601.16280.
395    /// Empty string = use the primary provider.
396    #[serde(default)]
397    pub tool_provider: ProviderName,
398    /// Minimum completeness score (0.0–1.0) for the plan to be accepted without
399    /// replanning. Default: 0.7. When the verifier reports `confidence <
400    /// completeness_threshold` AND gaps exist, a replan cycle is triggered.
401    /// Used by both per-task and whole-plan verification.
402    /// Values outside [0.0, 1.0] are rejected at startup by `Config::validate()`.
403    #[serde(default = "default_completeness_threshold")]
404    pub completeness_threshold: f32,
405    /// Enable cascade-aware routing for Mixed-topology DAGs. Requires `topology_selection = true`.
406    /// When enabled, tasks in failing subtrees are deprioritized in favour of healthy branches.
407    /// Default: false (opt-in).
408    #[serde(default)]
409    pub cascade_routing: bool,
410    /// Failure rate threshold (0.0–1.0) above which a DAG region is considered "cascading".
411    /// Must be in (0.0, 1.0]. Default: 0.5.
412    #[serde(default = "default_cascade_failure_threshold")]
413    pub cascade_failure_threshold: f32,
414    /// Enable tree-optimized dispatch for FanOut/FanIn topologies.
415    /// Sorts the ready queue by critical-path distance (deepest tasks first) to minimize
416    /// end-to-end latency. Default: false (opt-in).
417    #[serde(default)]
418    pub tree_optimized_dispatch: bool,
419
420    /// `AdaptOrch` bandit-driven topology advisor. Default: disabled.
421    #[serde(default)]
422    pub adaptorch: AdaptOrchConfig,
423    /// Consecutive-chain cascade abort threshold: number of consecutive `Failed` entries
424    /// in a `depends_on` chain that triggers a DAG abort.
425    ///
426    /// `0` disables linear-chain cascade abort. Default: 3.
427    /// Must not be `1` — a threshold of 1 would abort on every single failure.
428    #[serde(default = "default_cascade_chain_threshold")]
429    pub cascade_chain_threshold: usize,
430    /// Fan-out cascade abort failure-rate threshold (0.0–1.0).
431    ///
432    /// When a DAG region's failure rate reaches this value AND the region has ≥ 3 tasks,
433    /// the DAG is aborted immediately. `0.0` disables this signal (opt-in).
434    /// Recommended production value: `0.7`.
435    #[serde(default)]
436    pub cascade_failure_rate_abort_threshold: f32,
437    /// TTL for lineage entries in seconds. Entries older than this are pruned during
438    /// chain merge. Setting this too low can prevent detection of slow-build cascades.
439    ///
440    /// Default: 300 seconds (5 minutes).
441    #[serde(default = "default_lineage_ttl_secs")]
442    pub lineage_ttl_secs: u64,
443    /// Enable per-subtask predicate verification gate.
444    ///
445    /// Requires `predicate_provider` or a primary LLM provider to be configured.
446    /// Default: false (opt-in).
447    #[serde(default)]
448    pub verify_predicate_enabled: bool,
449    /// Provider name from `[[llm.providers]]` for predicate evaluation.
450    ///
451    /// Empty string = fall back to `verify_provider`, then primary.
452    #[serde(default)]
453    pub predicate_provider: ProviderName,
454    /// Maximum number of predicate-driven task re-runs across the entire DAG.
455    ///
456    /// Independent of `max_replans` (verifier completeness budget). Default: 2.
457    #[serde(default = "default_max_predicate_replans")]
458    pub max_predicate_replans: u32,
459    /// Timeout in seconds for each predicate LLM evaluation call.
460    ///
461    /// On timeout the evaluator returns a fail-open outcome (`passed = true`,
462    /// `confidence = 0.0`) and logs a warning. Default: 30.
463    #[serde(default = "default_predicate_timeout_secs")]
464    pub predicate_timeout_secs: u64,
465    /// Persist task graph state to `SQLite` across scheduler ticks.
466    ///
467    /// When `true` and a `SemanticMemory` store is available, the scheduler
468    /// snapshots the graph once per tick and on plan completion. Graphs can
469    /// then be rehydrated via `/plan resume <id>` after a restart.
470    /// Default: `true`.
471    #[serde(default = "default_persistence_enabled")]
472    pub persistence_enabled: bool,
473    /// Provider name from `[[llm.providers]]` for scheduling-tier LLM calls
474    /// (aggregation, predicate evaluation, verification when no specific provider is set).
475    ///
476    /// Acts as fallback for `verify_provider` and `predicate_provider` when those are empty.
477    /// Does NOT affect `planner_provider` — planning is a complex task and stays on the quality
478    /// provider. Empty string = use the agent's primary provider.
479    ///
480    /// # Trade-off
481    ///
482    /// Setting this to a fast/cheap model reduces aggregation quality because `LlmAggregator`
483    /// produces user-visible output. See CHANGELOG for details.
484    #[serde(default)]
485    pub orchestrator_provider: ProviderName,
486
487    /// Default per-task cost budget in US cents. `0.0` = unlimited (no budget check).
488    ///
489    /// When a sub-agent task completes, the scheduler emits a `tracing::warn!` if the
490    /// task exceeded this budget. In MVP this is **warn-only** — hard enforcement requires
491    /// per-task `CostTracker` scoping, which is deferred post-v1.0.0.
492    ///
493    /// Individual tasks can override this via `TaskNode::token_budget_cents`.
494    /// Default: `0.0` (unlimited).
495    #[serde(default)]
496    pub default_task_budget_cents: f64,
497
498    /// Default asset sensitivity level for task nodes that do not set their own.
499    ///
500    /// Advisory only in the current implementation — the dispatcher does not yet
501    /// auto-restrict tool access based on this field. See `specs/069-threat-model/spec.md §5`.
502    ///
503    /// TOML: `[orchestration] default_asset_sensitivity = "public"`
504    /// Default: `public` (no restriction).
505    #[serde(default)]
506    pub default_asset_sensitivity: AssetSensitivity,
507
508    /// Timeout in seconds for aggregation LLM calls. Default: 60.
509    ///
510    /// On timeout the aggregator falls back to raw concatenation so that a graph
511    /// result is always returned. Set to `0` is rejected by `Config::validate()`.
512    #[serde(default = "default_aggregator_timeout_secs")]
513    pub aggregator_timeout_secs: u64,
514
515    /// Timeout in seconds for planner LLM calls. Default: 120.
516    ///
517    /// On timeout the planner returns `OrchestrationError::PlanningFailed`.
518    /// Planning has no fallback — without a graph no tasks can be dispatched.
519    /// Set to `0` is rejected by `Config::validate()`.
520    #[serde(default = "default_planner_timeout_secs")]
521    pub planner_timeout_secs: u64,
522
523    /// Timeout in seconds for verifier LLM calls (per-task and whole-plan). Default: 30.
524    ///
525    /// On timeout the verifier returns a fail-open result (`complete = true`, no gaps).
526    /// Matches the existing `predicate_timeout_secs` default.
527    /// Set to `0` is rejected by `Config::validate()`.
528    #[serde(default = "default_verifier_timeout_secs")]
529    pub verifier_timeout_secs: u64,
530
531    /// ORCH-style deterministic verifier ensemble-merge configuration. Default: disabled.
532    /// See `specs/073-orch-ensemble-merge/spec.md`.
533    #[serde(default)]
534    pub ensemble: EnsembleConfig,
535
536    /// Global default idle/no-progress timeout in seconds, used when a `TaskNode`'s own
537    /// `TimeoutPolicy.idle_timeout_secs` is unset.
538    ///
539    /// **RESERVED — not yet enforced.** Defined and config-surfaced so the value can be
540    /// persisted ahead of the progress-signal plumbing (Alt A) that will consume it in a
541    /// future release. `None` = off. See
542    /// `specs/075-orchestration-node-control-parity/spec.md` §4/FR-005.
543    #[serde(default)]
544    pub default_idle_timeout_secs: Option<u64>,
545}
546
547impl Default for OrchestrationConfig {
548    fn default() -> Self {
549        Self {
550            enabled: false,
551            max_tasks: 20,
552            max_parallel: 4,
553            default_failure_strategy: FailureStrategy::default(),
554            default_max_retries: 3,
555            task_timeout_secs: 300,
556            planner_provider: ProviderName::default(),
557            planner_max_tokens: default_planner_max_tokens(),
558            dependency_context_budget: 16384,
559            confirm_before_execute: true,
560            aggregator_max_tokens: default_aggregator_max_tokens(),
561            deferral_backoff_ms: default_deferral_backoff_ms(),
562            plan_cache: PlanCacheConfig::default(),
563            topology_selection: false,
564            verify_provider: ProviderName::default(),
565            verify_max_tokens: default_verify_max_tokens(),
566            max_replans: default_max_replans(),
567            verify_completeness: false,
568            completeness_threshold: default_completeness_threshold(),
569            tool_provider: ProviderName::default(),
570            cascade_routing: false,
571            cascade_failure_threshold: default_cascade_failure_threshold(),
572            tree_optimized_dispatch: false,
573            adaptorch: AdaptOrchConfig::default(),
574            cascade_chain_threshold: default_cascade_chain_threshold(),
575            cascade_failure_rate_abort_threshold: 0.0,
576            lineage_ttl_secs: default_lineage_ttl_secs(),
577            verify_predicate_enabled: false,
578            predicate_provider: ProviderName::default(),
579            max_predicate_replans: default_max_predicate_replans(),
580            predicate_timeout_secs: default_predicate_timeout_secs(),
581            persistence_enabled: default_persistence_enabled(),
582            orchestrator_provider: ProviderName::default(),
583            default_task_budget_cents: 0.0,
584            default_asset_sensitivity: AssetSensitivity::default(),
585            aggregator_timeout_secs: default_aggregator_timeout_secs(),
586            planner_timeout_secs: default_planner_timeout_secs(),
587            verifier_timeout_secs: default_verifier_timeout_secs(),
588            ensemble: EnsembleConfig::default(),
589            default_idle_timeout_secs: None,
590        }
591    }
592}
593
594/// Configuration for the autonomous self-experimentation engine (`[experiments]` TOML section).
595///
596/// When `enabled = true`, Zeph periodically runs A/B experiments on its own skill and
597/// prompt configurations to find improvements automatically.
598///
599/// # Example (TOML)
600///
601/// ```toml
602/// [experiments]
603/// enabled = false
604/// max_experiments = 20
605/// auto_apply = false
606/// ```
607#[derive(Debug, Clone, Deserialize, Serialize)]
608#[serde(default)]
609pub struct ExperimentConfig {
610    /// Enable autonomous self-experimentation. Default: `false`.
611    pub enabled: bool,
612    /// Provider name (from `[[llm.providers]]`) used as the LLM-as-judge for experiment
613    /// evaluation. An empty value falls back to the primary provider. Prefer a capable,
614    /// low-self-judge-bias model (e.g. a different provider than the one being evaluated).
615    #[serde(default)]
616    pub eval_provider: ProviderName,
617    /// Path to a benchmark JSONL file for evaluating experiments.
618    pub benchmark_file: Option<std::path::PathBuf>,
619    #[serde(default = "default_experiment_max_experiments")]
620    pub max_experiments: u32,
621    #[serde(default = "default_experiment_max_wall_time_secs")]
622    pub max_wall_time_secs: u64,
623    #[serde(default = "default_experiment_min_improvement")]
624    pub min_improvement: f64,
625    #[serde(default = "default_experiment_eval_budget_tokens")]
626    pub eval_budget_tokens: u64,
627    pub auto_apply: bool,
628    #[serde(default)]
629    pub schedule: ExperimentSchedule,
630    /// When `true`, a subject call failure (LLM error or timeout) excludes the case from
631    /// scoring instead of aborting the entire evaluation run.
632    ///
633    /// Default: `false` (preserves existing abort-on-error semantics). Set to `true` when
634    /// running parallel evaluations where a single subject timeout should not discard all
635    /// already-billed responses from other in-flight futures — at the cost of producing a
636    /// partial result rather than a guaranteed complete evaluation.
637    #[serde(default)]
638    pub tolerate_subject_errors: bool,
639}
640
641impl Default for ExperimentConfig {
642    fn default() -> Self {
643        Self {
644            enabled: false,
645            eval_provider: ProviderName::default(),
646            benchmark_file: None,
647            max_experiments: default_experiment_max_experiments(),
648            max_wall_time_secs: default_experiment_max_wall_time_secs(),
649            min_improvement: default_experiment_min_improvement(),
650            eval_budget_tokens: default_experiment_eval_budget_tokens(),
651            auto_apply: false,
652            schedule: ExperimentSchedule::default(),
653            tolerate_subject_errors: false,
654        }
655    }
656}
657
658/// Configuration for `AdaptOrch` — bandit-driven topology advisor (`[orchestration.adaptorch]`).
659///
660/// # Example
661///
662/// ```toml
663/// [orchestration.adaptorch]
664/// enabled = true
665/// topology_provider = "fast"
666/// classify_timeout_secs = 4
667/// state_path = ""
668/// ```
669#[derive(Debug, Clone, Deserialize, Serialize)]
670#[serde(default)]
671pub struct AdaptOrchConfig {
672    /// Enable `AdaptOrch`. When `false`, planning uses the default `plan()` path.
673    pub enabled: bool,
674    /// Provider name from `[[llm.providers]]` for goal classification. Empty → primary provider.
675    pub topology_provider: ProviderName,
676    /// Hard timeout (seconds) for the classification LLM call.
677    #[serde(default = "default_classify_timeout_secs")]
678    pub classify_timeout_secs: u64,
679    /// Path to the persisted Beta-arm JSON state file.
680    /// Empty string → `~/.zeph/adaptorch_state.json` (resolved at runtime).
681    #[serde(default)]
682    pub state_path: String,
683    /// Maximum tokens for the classification LLM call.
684    #[serde(default = "default_max_classify_tokens")]
685    pub max_classify_tokens: u32,
686}
687
688fn default_classify_timeout_secs() -> u64 {
689    4
690}
691
692fn default_max_classify_tokens() -> u32 {
693    80
694}
695
696impl Default for AdaptOrchConfig {
697    fn default() -> Self {
698        Self {
699            enabled: false,
700            topology_provider: ProviderName::default(),
701            classify_timeout_secs: default_classify_timeout_secs(),
702            state_path: String::new(),
703            max_classify_tokens: default_max_classify_tokens(),
704        }
705    }
706}
707
708/// Cron scheduling configuration for automatic experiment runs.
709#[derive(Debug, Clone, Deserialize, Serialize)]
710#[serde(default)]
711pub struct ExperimentSchedule {
712    pub enabled: bool,
713    #[serde(default = "default_experiment_schedule_cron")]
714    pub cron: String,
715    #[serde(default = "default_experiment_max_experiments_per_run")]
716    pub max_experiments_per_run: u32,
717    /// Wall-time cap for a single scheduled experiment session (seconds).
718    ///
719    /// Overrides `experiments.max_wall_time_secs` for scheduled runs. Defaults to 1800s so
720    /// a background session cannot overlap the next cron trigger on typical schedules.
721    #[serde(default = "default_experiment_schedule_max_wall_time_secs")]
722    pub max_wall_time_secs: u64,
723}
724
725impl Default for ExperimentSchedule {
726    fn default() -> Self {
727        Self {
728            enabled: false,
729            cron: default_experiment_schedule_cron(),
730            max_experiments_per_run: default_experiment_max_experiments_per_run(),
731            max_wall_time_secs: default_experiment_schedule_max_wall_time_secs(),
732        }
733    }
734}
735
736impl ExperimentConfig {
737    /// Validate that numeric bounds are within sane operating limits.
738    ///
739    /// # Errors
740    ///
741    /// Returns a description string if any field is outside allowed range.
742    #[must_use = "validation result must be checked"]
743    pub fn validate(&self) -> Result<(), String> {
744        if !(1..=1_000).contains(&self.max_experiments) {
745            return Err(format!(
746                "experiments.max_experiments must be in 1..=1000, got {}",
747                self.max_experiments
748            ));
749        }
750        if !(60..=86_400).contains(&self.max_wall_time_secs) {
751            return Err(format!(
752                "experiments.max_wall_time_secs must be in 60..=86400, got {}",
753                self.max_wall_time_secs
754            ));
755        }
756        if !(1_000..=10_000_000).contains(&self.eval_budget_tokens) {
757            return Err(format!(
758                "experiments.eval_budget_tokens must be in 1000..=10000000, got {}",
759                self.eval_budget_tokens
760            ));
761        }
762        if !(0.0..=100.0).contains(&self.min_improvement) {
763            return Err(format!(
764                "experiments.min_improvement must be in 0.0..=100.0, got {}",
765                self.min_improvement
766            ));
767        }
768        if !(1..=100).contains(&self.schedule.max_experiments_per_run) {
769            return Err(format!(
770                "experiments.schedule.max_experiments_per_run must be in 1..=100, got {}",
771                self.schedule.max_experiments_per_run
772            ));
773        }
774        if !(60..=86_400).contains(&self.schedule.max_wall_time_secs) {
775            return Err(format!(
776                "experiments.schedule.max_wall_time_secs must be in 60..=86400, got {}",
777                self.schedule.max_wall_time_secs
778            ));
779        }
780        Ok(())
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn plan_cache_similarity_threshold_above_one_is_rejected() {
790        let cfg = PlanCacheConfig {
791            similarity_threshold: 1.1,
792            ..PlanCacheConfig::default()
793        };
794        let result = cfg.validate();
795        assert!(
796            result.is_err(),
797            "similarity_threshold = 1.1 must return a validation error"
798        );
799    }
800
801    #[test]
802    fn completeness_threshold_default_is_0_7() {
803        let cfg = OrchestrationConfig::default();
804        assert!(
805            (cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
806            "completeness_threshold default must be 0.7, got {}",
807            cfg.completeness_threshold
808        );
809    }
810
811    #[test]
812    fn completeness_threshold_serde_round_trip() {
813        let toml_in = r"
814            enabled = true
815            completeness_threshold = 0.85
816        ";
817        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
818        assert!((cfg.completeness_threshold - 0.85).abs() < f32::EPSILON);
819
820        let serialized = toml::to_string(&cfg).expect("serialize");
821        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
822        assert!((cfg2.completeness_threshold - 0.85).abs() < f32::EPSILON);
823    }
824
825    #[test]
826    fn completeness_threshold_missing_uses_default() {
827        let toml_in = "enabled = true\n";
828        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
829        assert!(
830            (cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
831            "missing field must use default 0.7, got {}",
832            cfg.completeness_threshold
833        );
834    }
835
836    #[test]
837    fn ensemble_config_default_is_disabled() {
838        let cfg = EnsembleConfig::default();
839        assert!(!cfg.enabled);
840        assert!(!cfg.verify);
841        assert!(cfg.members.is_empty());
842        assert!((cfg.ema_alpha - 0.3).abs() < f64::EPSILON);
843        assert!((cfg.ema_decay - 0.95).abs() < f64::EPSILON);
844        assert_eq!(cfg.min_observations, 5);
845        assert_eq!(cfg.member_timeout_secs, 0);
846    }
847
848    #[test]
849    fn orchestration_config_ensemble_is_disabled_by_default() {
850        assert!(!OrchestrationConfig::default().ensemble.enabled);
851    }
852
853    #[test]
854    fn ensemble_config_serde_round_trip() {
855        let toml_in = r#"
856            enabled = true
857            verify = true
858            members = ["fast", "quality", "cheap"]
859            ema_alpha = 0.4
860            ema_decay = 0.9
861            min_observations = 10
862            member_timeout_secs = 15
863        "#;
864        let cfg: EnsembleConfig = toml::from_str(toml_in).expect("deserialize");
865        assert!(cfg.enabled);
866        assert!(cfg.verify);
867        assert_eq!(cfg.members, vec!["fast", "quality", "cheap"]);
868        assert!((cfg.ema_alpha - 0.4).abs() < f64::EPSILON);
869
870        let serialized = toml::to_string(&cfg).expect("serialize");
871        let cfg2: EnsembleConfig = toml::from_str(&serialized).expect("re-deserialize");
872        assert_eq!(cfg2.members, cfg.members);
873        assert_eq!(cfg2.member_timeout_secs, 15);
874    }
875
876    #[test]
877    fn ensemble_config_missing_section_uses_defaults() {
878        // OrchestrationConfig has #[serde(default)] on the whole struct, and `ensemble` has
879        // #[serde(default)] too — an existing config with no [orchestration.ensemble] table
880        // at all must parse to the disabled default (no migration step required).
881        let toml_in = "enabled = true\n";
882        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
883        assert!(!cfg.ensemble.enabled);
884        assert!(cfg.ensemble.members.is_empty());
885    }
886
887    #[test]
888    fn asset_sensitivity_default_is_public() {
889        assert_eq!(AssetSensitivity::default(), AssetSensitivity::Public);
890    }
891
892    #[test]
893    fn asset_sensitivity_serde_snake_case() {
894        assert_eq!(
895            serde_json::to_string(&AssetSensitivity::Public).unwrap(),
896            "\"public\""
897        );
898        assert_eq!(
899            serde_json::to_string(&AssetSensitivity::Confidential).unwrap(),
900            "\"confidential\""
901        );
902        let v: AssetSensitivity = serde_json::from_str("\"internal\"").unwrap();
903        assert_eq!(v, AssetSensitivity::Internal);
904    }
905
906    #[test]
907    fn orchestration_config_default_asset_sensitivity_is_public() {
908        let cfg = OrchestrationConfig::default();
909        assert_eq!(cfg.default_asset_sensitivity, AssetSensitivity::Public);
910    }
911
912    #[test]
913    fn orchestration_config_asset_sensitivity_toml_roundtrip() {
914        let toml_in = "enabled = true\ndefault_asset_sensitivity = \"confidential\"\n";
915        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
916        assert_eq!(
917            cfg.default_asset_sensitivity,
918            AssetSensitivity::Confidential
919        );
920        let serialized = toml::to_string(&cfg).expect("serialize");
921        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
922        assert_eq!(
923            cfg2.default_asset_sensitivity,
924            AssetSensitivity::Confidential
925        );
926    }
927
928    #[test]
929    fn orchestration_config_missing_asset_sensitivity_uses_default() {
930        let toml_in = "enabled = true\n";
931        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
932        assert_eq!(cfg.default_asset_sensitivity, AssetSensitivity::Public);
933    }
934
935    // ── default_idle_timeout_secs (spec-075-orchestration-node-control-parity, #6021) ──
936
937    #[test]
938    fn orchestration_config_default_idle_timeout_secs_is_none() {
939        let cfg = OrchestrationConfig::default();
940        assert_eq!(cfg.default_idle_timeout_secs, None);
941    }
942
943    #[test]
944    fn orchestration_config_idle_timeout_secs_toml_roundtrip() {
945        let toml_in = "enabled = true\ndefault_idle_timeout_secs = 60\n";
946        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
947        assert_eq!(cfg.default_idle_timeout_secs, Some(60));
948        let serialized = toml::to_string(&cfg).expect("serialize");
949        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
950        assert_eq!(cfg2.default_idle_timeout_secs, Some(60));
951    }
952
953    #[test]
954    fn orchestration_config_missing_idle_timeout_secs_migrates_to_none() {
955        // Pre-feature config (no key present) — #[serde(default)] must yield None,
956        // matching what a config persisted before this field existed deserializes to.
957        let toml_in = "enabled = true\n";
958        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
959        assert_eq!(cfg.default_idle_timeout_secs, None);
960    }
961}