1use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10 pub fn load(path: &Path) -> Result<Self, ConfigError> {
18 let mut config = if path.exists() {
19 let content = std::fs::read_to_string(path)?;
20 toml::from_str::<Self>(&content)?
21 } else {
22 Self::default()
23 };
24
25 config.apply_env_overrides();
26 config.normalize_legacy_runtime_defaults();
27 Ok(config)
28 }
29
30 pub fn dump_defaults() -> Result<String, crate::error::ConfigError> {
53 let defaults = Self::default();
54 toml::to_string_pretty(&defaults).map_err(|e| {
55 crate::error::ConfigError::Validation(format!("failed to serialize defaults: {e}"))
56 })
57 }
58
59 pub fn validate(&self) -> Result<(), ConfigError> {
65 self.validate_scalar_bounds()?;
66 self.validate_memory_compression()?;
67 self.validate_memory_probe_and_graph()?;
68 self.validate_mcp_servers()?;
69 self.experiments
70 .validate()
71 .map_err(ConfigError::Validation)?;
72 if self.orchestration.plan_cache.enabled {
73 self.orchestration
74 .plan_cache
75 .validate()
76 .map_err(ConfigError::Validation)?;
77 }
78 self.validate_orchestration()?;
79 self.validate_focus_and_sidequest()?;
80 self.validate_llm_and_skills()?;
81 self.validate_provider_names()?;
82 self.validate_mcp_misc()?;
83 Ok(())
84 }
85
86 fn validate_scalar_bounds(&self) -> Result<(), ConfigError> {
88 if self.memory.history_limit > 10_000 {
89 return Err(ConfigError::Validation(format!(
90 "history_limit must be <= 10000, got {}",
91 self.memory.history_limit
92 )));
93 }
94 if self.memory.context_budget_tokens > 1_000_000 {
95 return Err(ConfigError::Validation(format!(
96 "context_budget_tokens must be <= 1000000, got {}",
97 self.memory.context_budget_tokens
98 )));
99 }
100 if self.agent.max_tool_iterations > 100 {
101 return Err(ConfigError::Validation(format!(
102 "max_tool_iterations must be <= 100, got {}",
103 self.agent.max_tool_iterations
104 )));
105 }
106 if self.a2a.rate_limit == 0 {
107 return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
108 }
109 if self.gateway.rate_limit == 0 {
110 return Err(ConfigError::Validation(
111 "gateway.rate_limit must be > 0".into(),
112 ));
113 }
114 if self.gateway.max_body_size > 10_485_760 {
115 return Err(ConfigError::Validation(format!(
116 "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
117 self.gateway.max_body_size
118 )));
119 }
120 if self.memory.token_safety_margin <= 0.0 {
121 return Err(ConfigError::Validation(format!(
122 "token_safety_margin must be > 0.0, got {}",
123 self.memory.token_safety_margin
124 )));
125 }
126 if self.memory.tool_call_cutoff == 0 {
127 return Err(ConfigError::Validation(
128 "tool_call_cutoff must be >= 1".into(),
129 ));
130 }
131 Ok(())
132 }
133
134 fn validate_memory_compression(&self) -> Result<(), ConfigError> {
136 if let crate::memory::CompressionStrategy::Proactive {
137 threshold_tokens,
138 max_summary_tokens,
139 } = &self.memory.compression.strategy
140 {
141 if *threshold_tokens < 1_000 {
142 return Err(ConfigError::Validation(format!(
143 "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
144 )));
145 }
146 if *max_summary_tokens < 128 {
147 return Err(ConfigError::Validation(format!(
148 "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
149 )));
150 }
151 }
152 if !self.memory.soft_compaction_threshold.is_finite()
153 || self.memory.soft_compaction_threshold <= 0.0
154 || self.memory.soft_compaction_threshold >= 1.0
155 {
156 return Err(ConfigError::Validation(format!(
157 "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
158 self.memory.soft_compaction_threshold
159 )));
160 }
161 if !self.memory.hard_compaction_threshold.is_finite()
162 || self.memory.hard_compaction_threshold <= 0.0
163 || self.memory.hard_compaction_threshold >= 1.0
164 {
165 return Err(ConfigError::Validation(format!(
166 "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
167 self.memory.hard_compaction_threshold
168 )));
169 }
170 if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
171 return Err(ConfigError::Validation(format!(
172 "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
173 self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
174 )));
175 }
176 Ok(())
177 }
178
179 fn validate_memory_probe_and_graph(&self) -> Result<(), ConfigError> {
181 if self.memory.graph.temporal_decay_rate < 0.0
182 || self.memory.graph.temporal_decay_rate > 10.0
183 {
184 return Err(ConfigError::Validation(format!(
185 "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
186 self.memory.graph.temporal_decay_rate
187 )));
188 }
189 if self.memory.compression.probe.enabled {
190 let probe = &self.memory.compression.probe;
191 if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
192 return Err(ConfigError::Validation(format!(
193 "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
194 probe.threshold
195 )));
196 }
197 if !probe.hard_fail_threshold.is_finite()
198 || probe.hard_fail_threshold < 0.0
199 || probe.hard_fail_threshold >= 1.0
200 {
201 return Err(ConfigError::Validation(format!(
202 "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
203 probe.hard_fail_threshold
204 )));
205 }
206 if probe.hard_fail_threshold >= probe.threshold {
207 return Err(ConfigError::Validation(format!(
208 "memory.compression.probe.hard_fail_threshold ({}) must be less than \
209 memory.compression.probe.threshold ({})",
210 probe.hard_fail_threshold, probe.threshold
211 )));
212 }
213 if probe.max_questions < 1 {
214 return Err(ConfigError::Validation(
215 "memory.compression.probe.max_questions must be >= 1".into(),
216 ));
217 }
218 if probe.timeout_secs < 1 {
219 return Err(ConfigError::Validation(
220 "memory.compression.probe.timeout_secs must be >= 1".into(),
221 ));
222 }
223 }
224 Ok(())
225 }
226
227 fn validate_mcp_servers(&self) -> Result<(), ConfigError> {
229 use std::collections::HashSet;
230 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
231 for s in &self.mcp.servers {
232 if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
234 return Err(ConfigError::Validation(format!(
235 "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
236 s.id
237 )));
238 }
239 if s.oauth.as_ref().is_some_and(|o| o.enabled) {
241 let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
242 if !seen_oauth_vault_keys.insert(key.clone()) {
243 return Err(ConfigError::Validation(format!(
244 "MCP server '{}' has vault key collision ('{key}'): another server \
245 with the same normalized ID already uses this key",
246 s.id
247 )));
248 }
249 }
250 }
251 Ok(())
252 }
253
254 fn validate_orchestration(&self) -> Result<(), ConfigError> {
256 if self.orchestration.max_parallel == 0 {
257 return Err(ConfigError::Validation(
258 "orchestration.max_parallel must be > 0".into(),
259 ));
260 }
261 if self.orchestration.max_tasks == 0 {
262 return Err(ConfigError::Validation(
263 "orchestration.max_tasks must be > 0".into(),
264 ));
265 }
266 let ct = self.orchestration.completeness_threshold;
267 if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
268 return Err(ConfigError::Validation(format!(
269 "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
270 )));
271 }
272 if self.orchestration.cascade_chain_threshold == 1 {
274 return Err(ConfigError::Validation(
275 "orchestration.cascade_chain_threshold=1 aborts on every failure; \
276 use 0 to disable linear-chain cascade abort instead"
277 .into(),
278 ));
279 }
280 let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
281 if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
282 return Err(ConfigError::Validation(format!(
283 "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
284 )));
285 }
286 if self.orchestration.lineage_ttl_secs == 0 {
287 return Err(ConfigError::Validation(
288 "orchestration.lineage_ttl_secs must be > 0; \
289 set cascade_chain_threshold=0 to disable lineage tracking instead"
290 .into(),
291 ));
292 }
293 if self.orchestration.aggregator_timeout_secs == 0 {
294 return Err(ConfigError::Validation(
295 "orchestration.aggregator_timeout_secs must be > 0".into(),
296 ));
297 }
298 if self.orchestration.planner_timeout_secs == 0 {
299 return Err(ConfigError::Validation(
300 "orchestration.planner_timeout_secs must be > 0".into(),
301 ));
302 }
303 if self.orchestration.verifier_timeout_secs == 0 {
304 return Err(ConfigError::Validation(
305 "orchestration.verifier_timeout_secs must be > 0".into(),
306 ));
307 }
308 Ok(())
309 }
310
311 fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
313 if self.agent.focus.compression_interval == 0 {
314 return Err(ConfigError::Validation(
315 "agent.focus.compression_interval must be >= 1".into(),
316 ));
317 }
318 if self.agent.focus.min_messages_per_focus == 0 {
319 return Err(ConfigError::Validation(
320 "agent.focus.min_messages_per_focus must be >= 1".into(),
321 ));
322 }
323 if self.agent.focus.auto_consolidate_min_window == 0 {
324 return Err(ConfigError::Validation(
325 "agent.focus.auto_consolidate_min_window must be >= 1 \
326 (set focus.enabled = false to disable auto-consolidation)"
327 .into(),
328 ));
329 }
330 if self.memory.sidequest.interval_turns == 0 {
331 return Err(ConfigError::Validation(
332 "memory.sidequest.interval_turns must be >= 1".into(),
333 ));
334 }
335 if !self.memory.sidequest.max_eviction_ratio.is_finite()
336 || self.memory.sidequest.max_eviction_ratio <= 0.0
337 || self.memory.sidequest.max_eviction_ratio > 1.0
338 {
339 return Err(ConfigError::Validation(format!(
340 "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
341 self.memory.sidequest.max_eviction_ratio
342 )));
343 }
344 Ok(())
345 }
346
347 fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
349 let sct = self.llm.semantic_cache_threshold;
350 if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
351 return Err(ConfigError::Validation(format!(
352 "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
353 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
354 )));
355 }
356 if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
358 self.llm.warn_non_fast_tier_provider(
359 &self.memory.memcot.distill_provider,
360 "memory.memcot.distill_provider",
361 &self.memory.memcot.fast_tier_models,
362 );
363 }
364 self.skills
365 .learning
366 .validate()
367 .map_err(ConfigError::Validation)?;
368 if self.skills.evaluation.enabled {
370 let weight_sum = self.skills.evaluation.weight_correctness
371 + self.skills.evaluation.weight_reusability
372 + self.skills.evaluation.weight_specificity;
373 if (weight_sum - 1.0_f32).abs() > 1e-3 {
374 return Err(ConfigError::Validation(format!(
375 "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
376 )));
377 }
378 }
379 Ok(())
380 }
381
382 fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
384 if self.mcp.output_schema_hint_bytes < 64 {
385 return Err(ConfigError::Validation(format!(
386 "mcp.output_schema_hint_bytes must be >= 64, got {}; \
387 use forward_output_schema = false to disable forwarding",
388 self.mcp.output_schema_hint_bytes
389 )));
390 }
391 Ok(())
392 }
393
394 fn validate_provider_names(&self) -> Result<(), ConfigError> {
395 let known = self.known_provider_names();
396 self.validate_named_provider_refs(&known)?;
397 self.validate_optional_provider_refs(&known)?;
398 Ok(())
399 }
400
401 fn known_provider_names(&self) -> std::collections::HashSet<String> {
403 self.llm
404 .providers
405 .iter()
406 .map(super::providers::ProviderEntry::effective_name)
407 .collect()
408 }
409
410 fn validate_named_provider_refs(
415 &self,
416 known: &std::collections::HashSet<String>,
417 ) -> Result<(), ConfigError> {
418 self.validate_core_provider_refs(known)?;
419 self.validate_tool_and_quality_provider_refs(known)
420 }
421
422 fn validate_core_provider_refs(
423 &self,
424 known: &std::collections::HashSet<String>,
425 ) -> Result<(), ConfigError> {
426 let fields: &[(&str, &crate::providers::ProviderName)] = &[
427 (
428 "memory.tiers.scene_provider",
429 &self.memory.tiers.scene_provider,
430 ),
431 (
432 "memory.compression.compress_provider",
433 &self.memory.compression.compress_provider,
434 ),
435 (
436 "memory.consolidation.consolidation_provider",
437 &self.memory.consolidation.consolidation_provider,
438 ),
439 (
440 "memory.admission.admission_provider",
441 &self.memory.admission.admission_provider,
442 ),
443 (
444 "memory.admission.goal_utility_provider",
445 &self.memory.admission.goal_utility_provider,
446 ),
447 (
448 "memory.store_routing.routing_classifier_provider",
449 &self.memory.store_routing.routing_classifier_provider,
450 ),
451 (
452 "skills.learning.feedback_provider",
453 &self.skills.learning.feedback_provider,
454 ),
455 (
456 "skills.learning.arise_trace_provider",
457 &self.skills.learning.arise_trace_provider,
458 ),
459 (
460 "skills.learning.stem_provider",
461 &self.skills.learning.stem_provider,
462 ),
463 (
464 "skills.learning.erl_extract_provider",
465 &self.skills.learning.erl_extract_provider,
466 ),
467 (
468 "mcp.pruning.pruning_provider",
469 &self.mcp.pruning.pruning_provider,
470 ),
471 (
472 "mcp.tool_discovery.embedding_provider",
473 &self.mcp.tool_discovery.embedding_provider,
474 ),
475 (
476 "security.response_verification.verifier_provider",
477 &self.security.response_verification.verifier_provider,
478 ),
479 (
480 "orchestration.planner_provider",
481 &self.orchestration.planner_provider,
482 ),
483 (
484 "orchestration.verify_provider",
485 &self.orchestration.verify_provider,
486 ),
487 (
488 "orchestration.tool_provider",
489 &self.orchestration.tool_provider,
490 ),
491 (
492 "skills.evaluation.provider",
493 &self.skills.evaluation.provider,
494 ),
495 (
496 "skills.proactive_exploration.provider",
497 &self.skills.proactive_exploration.provider,
498 ),
499 (
500 "memory.compression_spectrum.promotion_provider",
501 &self.memory.compression_spectrum.promotion_provider,
502 ),
503 ];
504 Self::check_provider_refs(fields, known)
505 }
506
507 fn validate_tool_and_quality_provider_refs(
508 &self,
509 known: &std::collections::HashSet<String>,
510 ) -> Result<(), ConfigError> {
511 let fields: &[(&str, &crate::providers::ProviderName)] = &[
512 (
513 "security.shadow_sentinel.probe_provider",
514 &self.security.shadow_sentinel.probe_provider,
515 ),
516 (
517 "tools.retry.parameter_reformat_provider",
518 &self.tools.retry.parameter_reformat_provider,
519 ),
520 (
521 "tools.adversarial_policy.policy_provider",
522 &self.tools.adversarial_policy.policy_provider,
523 ),
524 (
525 "tools.speculative.pattern.rerank_provider",
526 &self.tools.speculative.pattern.rerank_provider,
527 ),
528 (
529 "tools.compression.evolution_provider",
530 &self.tools.compression.evolution_provider,
531 ),
532 ("quality.proposer_provider", &self.quality.proposer_provider),
533 ("quality.checker_provider", &self.quality.checker_provider),
534 ];
535 Self::check_provider_refs(fields, known)
536 }
537
538 fn check_provider_refs(
539 fields: &[(&str, &crate::providers::ProviderName)],
540 known: &std::collections::HashSet<String>,
541 ) -> Result<(), ConfigError> {
542 for (field, name) in fields {
543 if !name.is_empty() && !known.contains(name.as_str()) {
544 return Err(ConfigError::Validation(format!(
545 "{field} = {:?} does not match any [[llm.providers]] entry",
546 name.as_str()
547 )));
548 }
549 }
550 Ok(())
551 }
552
553 fn validate_optional_provider_refs(
555 &self,
556 known: &std::collections::HashSet<String>,
557 ) -> Result<(), ConfigError> {
558 if let Some(triage) = self
559 .llm
560 .complexity_routing
561 .as_ref()
562 .and_then(|cr| cr.triage_provider.as_ref())
563 .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
564 {
565 return Err(ConfigError::Validation(format!(
566 "llm.complexity_routing.triage_provider = {:?} does not match any \
567 [[llm.providers]] entry",
568 triage.as_str()
569 )));
570 }
571
572 if let Some(embed) = self
573 .llm
574 .router
575 .as_ref()
576 .and_then(|r| r.bandit.as_ref())
577 .map(|b| &b.embedding_provider)
578 .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
579 {
580 return Err(ConfigError::Validation(format!(
581 "llm.router.bandit.embedding_provider = {:?} does not match any \
582 [[llm.providers]] entry",
583 embed.as_str()
584 )));
585 }
586
587 Ok(())
588 }
589
590 fn normalize_legacy_runtime_defaults(&mut self) {
591 use crate::defaults::{
592 default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
593 is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
594 is_legacy_default_sqlite_path,
595 };
596
597 if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
598 self.memory.sqlite_path = default_sqlite_path();
599 }
600
601 for skill_path in &mut self.skills.paths {
602 if is_legacy_default_skills_path(skill_path) {
603 *skill_path = default_skills_dir();
604 }
605 }
606
607 if is_legacy_default_debug_dir(&self.debug.output_dir) {
608 self.debug.output_dir = default_debug_dir();
609 }
610
611 if is_legacy_default_log_file(&self.logging.file) {
612 self.logging.file = default_log_file_path();
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 fn config_with_sct(threshold: f32) -> Config {
622 let mut cfg = Config::default();
623 cfg.llm.semantic_cache_threshold = threshold;
624 cfg
625 }
626
627 #[test]
628 fn semantic_cache_threshold_valid_zero() {
629 assert!(config_with_sct(0.0).validate().is_ok());
630 }
631
632 #[test]
633 fn semantic_cache_threshold_valid_mid() {
634 assert!(config_with_sct(0.5).validate().is_ok());
635 }
636
637 #[test]
638 fn semantic_cache_threshold_valid_one() {
639 assert!(config_with_sct(1.0).validate().is_ok());
640 }
641
642 #[test]
643 fn semantic_cache_threshold_invalid_negative() {
644 let err = config_with_sct(-0.1).validate().unwrap_err();
645 assert!(
646 err.to_string().contains("semantic_cache_threshold"),
647 "unexpected error: {err}"
648 );
649 }
650
651 #[test]
652 fn semantic_cache_threshold_invalid_above_one() {
653 let err = config_with_sct(1.1).validate().unwrap_err();
654 assert!(
655 err.to_string().contains("semantic_cache_threshold"),
656 "unexpected error: {err}"
657 );
658 }
659
660 #[test]
661 fn semantic_cache_threshold_invalid_nan() {
662 let err = config_with_sct(f32::NAN).validate().unwrap_err();
663 assert!(
664 err.to_string().contains("semantic_cache_threshold"),
665 "unexpected error: {err}"
666 );
667 }
668
669 #[test]
670 fn semantic_cache_threshold_invalid_infinity() {
671 let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
672 assert!(
673 err.to_string().contains("semantic_cache_threshold"),
674 "unexpected error: {err}"
675 );
676 }
677
678 #[test]
679 fn semantic_cache_threshold_invalid_neg_infinity() {
680 let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
681 assert!(
682 err.to_string().contains("semantic_cache_threshold"),
683 "unexpected error: {err}"
684 );
685 }
686
687 fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
688 let mut cfg = Config::default();
689 cfg.memory.compression.probe.enabled = enabled;
690 cfg.memory.compression.probe.threshold = threshold;
691 cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
692 cfg
693 }
694
695 #[test]
696 fn probe_disabled_skips_validation() {
697 let cfg = probe_config(false, 0.0, 1.0);
699 assert!(cfg.validate().is_ok());
700 }
701
702 #[test]
703 fn probe_valid_thresholds() {
704 let cfg = probe_config(true, 0.6, 0.35);
705 assert!(cfg.validate().is_ok());
706 }
707
708 #[test]
709 fn probe_threshold_zero_invalid() {
710 let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
711 assert!(
712 err.to_string().contains("probe.threshold"),
713 "unexpected error: {err}"
714 );
715 }
716
717 #[test]
718 fn probe_hard_fail_threshold_above_one_invalid() {
719 let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
720 assert!(
721 err.to_string().contains("probe.hard_fail_threshold"),
722 "unexpected error: {err}"
723 );
724 }
725
726 #[test]
727 fn probe_hard_fail_gte_threshold_invalid() {
728 let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
729 assert!(
730 err.to_string().contains("probe.hard_fail_threshold"),
731 "unexpected error: {err}"
732 );
733 }
734
735 fn config_with_completeness_threshold(ct: f32) -> Config {
736 let mut cfg = Config::default();
737 cfg.orchestration.completeness_threshold = ct;
738 cfg
739 }
740
741 #[test]
742 fn completeness_threshold_valid_zero() {
743 assert!(config_with_completeness_threshold(0.0).validate().is_ok());
744 }
745
746 #[test]
747 fn completeness_threshold_valid_default() {
748 assert!(config_with_completeness_threshold(0.7).validate().is_ok());
749 }
750
751 #[test]
752 fn completeness_threshold_valid_one() {
753 assert!(config_with_completeness_threshold(1.0).validate().is_ok());
754 }
755
756 #[test]
757 fn completeness_threshold_invalid_negative() {
758 let err = config_with_completeness_threshold(-0.1)
759 .validate()
760 .unwrap_err();
761 assert!(
762 err.to_string().contains("completeness_threshold"),
763 "unexpected error: {err}"
764 );
765 }
766
767 #[test]
768 fn completeness_threshold_invalid_above_one() {
769 let err = config_with_completeness_threshold(1.1)
770 .validate()
771 .unwrap_err();
772 assert!(
773 err.to_string().contains("completeness_threshold"),
774 "unexpected error: {err}"
775 );
776 }
777
778 #[test]
779 fn completeness_threshold_invalid_nan() {
780 let err = config_with_completeness_threshold(f32::NAN)
781 .validate()
782 .unwrap_err();
783 assert!(
784 err.to_string().contains("completeness_threshold"),
785 "unexpected error: {err}"
786 );
787 }
788
789 #[test]
790 fn completeness_threshold_invalid_infinity() {
791 let err = config_with_completeness_threshold(f32::INFINITY)
792 .validate()
793 .unwrap_err();
794 assert!(
795 err.to_string().contains("completeness_threshold"),
796 "unexpected error: {err}"
797 );
798 }
799
800 fn config_with_provider(name: &str) -> Config {
801 let mut cfg = Config::default();
802 cfg.llm.providers.push(crate::providers::ProviderEntry {
803 provider_type: crate::providers::ProviderKind::Ollama,
804 name: Some(name.into()),
805 ..Default::default()
806 });
807 cfg
808 }
809
810 #[test]
811 fn validate_provider_names_all_empty_ok() {
812 let cfg = Config::default();
813 assert!(cfg.validate_provider_names().is_ok());
814 }
815
816 #[test]
817 fn validate_provider_names_matching_provider_ok() {
818 let mut cfg = config_with_provider("fast");
819 cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
820 assert!(cfg.validate_provider_names().is_ok());
821 }
822
823 #[test]
824 fn validate_provider_names_unknown_provider_err() {
825 let mut cfg = config_with_provider("fast");
826 cfg.memory.admission.admission_provider =
827 crate::providers::ProviderName::new("nonexistent");
828 let err = cfg.validate_provider_names().unwrap_err();
829 let msg = err.to_string();
830 assert!(
831 msg.contains("admission_provider") && msg.contains("nonexistent"),
832 "unexpected error: {msg}"
833 );
834 }
835
836 #[test]
837 fn validate_provider_names_triage_provider_none_ok() {
838 let mut cfg = config_with_provider("fast");
839 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
840 triage_provider: None,
841 ..Default::default()
842 });
843 assert!(cfg.validate_provider_names().is_ok());
844 }
845
846 #[test]
847 fn validate_provider_names_triage_provider_matching_ok() {
848 let mut cfg = config_with_provider("fast");
849 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
850 triage_provider: Some(crate::providers::ProviderName::new("fast")),
851 ..Default::default()
852 });
853 assert!(cfg.validate_provider_names().is_ok());
854 }
855
856 #[test]
857 fn validate_provider_names_triage_provider_unknown_err() {
858 let mut cfg = config_with_provider("fast");
859 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
860 triage_provider: Some(crate::providers::ProviderName::new("ghost")),
861 ..Default::default()
862 });
863 let err = cfg.validate_provider_names().unwrap_err();
864 let msg = err.to_string();
865 assert!(
866 msg.contains("triage_provider") && msg.contains("ghost"),
867 "unexpected error: {msg}"
868 );
869 }
870
871 #[test]
874 fn toml_float_fields_deserialise_correctly() {
875 let toml = r"
876[llm.router.reputation]
877enabled = true
878decay_factor = 0.95
879weight = 0.3
880
881[llm.router.bandit]
882enabled = false
883cost_weight = 0.3
884alpha = 1.0
885decay_factor = 0.99
886
887[skills]
888disambiguation_threshold = 0.25
889cosine_weight = 0.7
890";
891 let wrapped = format!(
893 "{}\n{}",
894 toml,
895 r"[memory.semantic]
896mmr_lambda = 0.7
897"
898 );
899 let router: crate::providers::RouterConfig = toml::from_str(
901 r"[reputation]
902enabled = true
903decay_factor = 0.95
904weight = 0.3
905",
906 )
907 .expect("RouterConfig with float fields must deserialise");
908 assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
909
910 let bandit: crate::providers::BanditConfig =
911 toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
912 .expect("BanditConfig with float fields must deserialise");
913 assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
914
915 let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
916 .expect("SemanticConfig with float fields must deserialise");
917 assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
918
919 let skills: crate::features::SkillsConfig =
920 toml::from_str("disambiguation_threshold = 0.25\n")
921 .expect("SkillsConfig with float fields must deserialise");
922 assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
923
924 let _ = wrapped; }
926
927 #[test]
928 fn validate_max_parallel_zero_rejected() {
929 let mut cfg = Config::default();
930 cfg.orchestration.max_parallel = 0;
931 let err = cfg.validate().unwrap_err().to_string();
932 assert!(
933 err.contains("max_parallel"),
934 "expected max_parallel in error, got: {err}"
935 );
936 }
937
938 #[test]
939 fn validate_max_parallel_one_accepted() {
940 let mut cfg = Config::default();
941 cfg.orchestration.max_parallel = 1;
942 assert!(cfg.validate().is_ok());
943 }
944
945 #[test]
946 fn validate_max_tasks_zero_rejected() {
947 let mut cfg = Config::default();
948 cfg.orchestration.max_tasks = 0;
949 let err = cfg.validate().unwrap_err().to_string();
950 assert!(
951 err.contains("max_tasks"),
952 "expected max_tasks in error, got: {err}"
953 );
954 }
955
956 #[test]
957 fn validate_max_tasks_one_accepted() {
958 let mut cfg = Config::default();
959 cfg.orchestration.max_tasks = 1;
960 assert!(cfg.validate().is_ok());
961 }
962
963 #[test]
964 fn validate_aggregator_timeout_zero_rejected() {
965 let mut cfg = Config::default();
966 cfg.orchestration.aggregator_timeout_secs = 0;
967 let err = cfg.validate().unwrap_err().to_string();
968 assert!(
969 err.contains("aggregator_timeout_secs"),
970 "expected aggregator_timeout_secs in error, got: {err}"
971 );
972 }
973
974 #[test]
975 fn validate_planner_timeout_zero_rejected() {
976 let mut cfg = Config::default();
977 cfg.orchestration.planner_timeout_secs = 0;
978 let err = cfg.validate().unwrap_err().to_string();
979 assert!(
980 err.contains("planner_timeout_secs"),
981 "expected planner_timeout_secs in error, got: {err}"
982 );
983 }
984
985 #[test]
986 fn validate_verifier_timeout_zero_rejected() {
987 let mut cfg = Config::default();
988 cfg.orchestration.verifier_timeout_secs = 0;
989 let err = cfg.validate().unwrap_err().to_string();
990 assert!(
991 err.contains("verifier_timeout_secs"),
992 "expected verifier_timeout_secs in error, got: {err}"
993 );
994 }
995
996 #[test]
997 fn focus_auto_consolidate_min_window_zero_rejected() {
998 let mut cfg = Config::default();
999 cfg.agent.focus.auto_consolidate_min_window = 0;
1000 let err = cfg.validate().unwrap_err().to_string();
1001 assert!(
1002 err.contains("auto_consolidate_min_window"),
1003 "expected auto_consolidate_min_window in error, got: {err}"
1004 );
1005 }
1006
1007 #[test]
1008 fn focus_auto_consolidate_min_window_one_accepted() {
1009 let mut cfg = Config::default();
1010 cfg.agent.focus.auto_consolidate_min_window = 1;
1011 assert!(cfg.validate().is_ok());
1012 }
1013}