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 #[allow(clippy::too_many_lines)]
65 pub fn validate(&self) -> Result<(), ConfigError> {
66 if self.memory.history_limit > 10_000 {
67 return Err(ConfigError::Validation(format!(
68 "history_limit must be <= 10000, got {}",
69 self.memory.history_limit
70 )));
71 }
72 if self.memory.context_budget_tokens > 1_000_000 {
73 return Err(ConfigError::Validation(format!(
74 "context_budget_tokens must be <= 1000000, got {}",
75 self.memory.context_budget_tokens
76 )));
77 }
78 if self.agent.max_tool_iterations > 100 {
79 return Err(ConfigError::Validation(format!(
80 "max_tool_iterations must be <= 100, got {}",
81 self.agent.max_tool_iterations
82 )));
83 }
84 if self.a2a.rate_limit == 0 {
85 return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
86 }
87 if self.gateway.rate_limit == 0 {
88 return Err(ConfigError::Validation(
89 "gateway.rate_limit must be > 0".into(),
90 ));
91 }
92 if self.gateway.max_body_size > 10_485_760 {
93 return Err(ConfigError::Validation(format!(
94 "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
95 self.gateway.max_body_size
96 )));
97 }
98 if self.memory.token_safety_margin <= 0.0 {
99 return Err(ConfigError::Validation(format!(
100 "token_safety_margin must be > 0.0, got {}",
101 self.memory.token_safety_margin
102 )));
103 }
104 if self.memory.tool_call_cutoff == 0 {
105 return Err(ConfigError::Validation(
106 "tool_call_cutoff must be >= 1".into(),
107 ));
108 }
109 if let crate::memory::CompressionStrategy::Proactive {
110 threshold_tokens,
111 max_summary_tokens,
112 } = &self.memory.compression.strategy
113 {
114 if *threshold_tokens < 1_000 {
115 return Err(ConfigError::Validation(format!(
116 "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
117 )));
118 }
119 if *max_summary_tokens < 128 {
120 return Err(ConfigError::Validation(format!(
121 "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
122 )));
123 }
124 }
125 if !self.memory.soft_compaction_threshold.is_finite()
126 || self.memory.soft_compaction_threshold <= 0.0
127 || self.memory.soft_compaction_threshold >= 1.0
128 {
129 return Err(ConfigError::Validation(format!(
130 "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
131 self.memory.soft_compaction_threshold
132 )));
133 }
134 if !self.memory.hard_compaction_threshold.is_finite()
135 || self.memory.hard_compaction_threshold <= 0.0
136 || self.memory.hard_compaction_threshold >= 1.0
137 {
138 return Err(ConfigError::Validation(format!(
139 "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
140 self.memory.hard_compaction_threshold
141 )));
142 }
143 if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
144 return Err(ConfigError::Validation(format!(
145 "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
146 self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
147 )));
148 }
149 if self.memory.graph.temporal_decay_rate < 0.0
150 || self.memory.graph.temporal_decay_rate > 10.0
151 {
152 return Err(ConfigError::Validation(format!(
153 "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
154 self.memory.graph.temporal_decay_rate
155 )));
156 }
157 if self.memory.compression.probe.enabled {
158 let probe = &self.memory.compression.probe;
159 if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
160 return Err(ConfigError::Validation(format!(
161 "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
162 probe.threshold
163 )));
164 }
165 if !probe.hard_fail_threshold.is_finite()
166 || probe.hard_fail_threshold < 0.0
167 || probe.hard_fail_threshold >= 1.0
168 {
169 return Err(ConfigError::Validation(format!(
170 "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
171 probe.hard_fail_threshold
172 )));
173 }
174 if probe.hard_fail_threshold >= probe.threshold {
175 return Err(ConfigError::Validation(format!(
176 "memory.compression.probe.hard_fail_threshold ({}) must be less than \
177 memory.compression.probe.threshold ({})",
178 probe.hard_fail_threshold, probe.threshold
179 )));
180 }
181 if probe.max_questions < 1 {
182 return Err(ConfigError::Validation(
183 "memory.compression.probe.max_questions must be >= 1".into(),
184 ));
185 }
186 if probe.timeout_secs < 1 {
187 return Err(ConfigError::Validation(
188 "memory.compression.probe.timeout_secs must be >= 1".into(),
189 ));
190 }
191 }
192 {
194 use std::collections::HashSet;
195 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
196 for s in &self.mcp.servers {
197 if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
199 return Err(ConfigError::Validation(format!(
200 "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
201 s.id
202 )));
203 }
204 if s.oauth.as_ref().is_some_and(|o| o.enabled) {
206 let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
207 if !seen_oauth_vault_keys.insert(key.clone()) {
208 return Err(ConfigError::Validation(format!(
209 "MCP server '{}' has vault key collision ('{key}'): another server \
210 with the same normalized ID already uses this key",
211 s.id
212 )));
213 }
214 }
215 }
216 }
217
218 self.experiments
219 .validate()
220 .map_err(ConfigError::Validation)?;
221
222 if self.orchestration.plan_cache.enabled {
223 self.orchestration
224 .plan_cache
225 .validate()
226 .map_err(ConfigError::Validation)?;
227 }
228
229 let ct = self.orchestration.completeness_threshold;
230 if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
231 return Err(ConfigError::Validation(format!(
232 "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
233 )));
234 }
235
236 if self.orchestration.cascade_chain_threshold == 1 {
238 return Err(ConfigError::Validation(
239 "orchestration.cascade_chain_threshold=1 aborts on every failure; \
240 use 0 to disable linear-chain cascade abort instead"
241 .into(),
242 ));
243 }
244
245 let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
246 if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
247 return Err(ConfigError::Validation(format!(
248 "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
249 )));
250 }
251
252 if self.orchestration.lineage_ttl_secs == 0 {
253 return Err(ConfigError::Validation(
254 "orchestration.lineage_ttl_secs must be > 0; \
255 set cascade_chain_threshold=0 to disable lineage tracking instead"
256 .into(),
257 ));
258 }
259
260 if self.agent.focus.compression_interval == 0 {
262 return Err(ConfigError::Validation(
263 "agent.focus.compression_interval must be >= 1".into(),
264 ));
265 }
266 if self.agent.focus.min_messages_per_focus == 0 {
267 return Err(ConfigError::Validation(
268 "agent.focus.min_messages_per_focus must be >= 1".into(),
269 ));
270 }
271 if self.agent.focus.auto_consolidate_min_window == 0 {
272 return Err(ConfigError::Validation(
273 "agent.focus.auto_consolidate_min_window must be >= 1 \
274 (set focus.enabled = false to disable auto-consolidation)"
275 .into(),
276 ));
277 }
278
279 if self.memory.sidequest.interval_turns == 0 {
281 return Err(ConfigError::Validation(
282 "memory.sidequest.interval_turns must be >= 1".into(),
283 ));
284 }
285 if !self.memory.sidequest.max_eviction_ratio.is_finite()
286 || self.memory.sidequest.max_eviction_ratio <= 0.0
287 || self.memory.sidequest.max_eviction_ratio > 1.0
288 {
289 return Err(ConfigError::Validation(format!(
290 "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
291 self.memory.sidequest.max_eviction_ratio
292 )));
293 }
294
295 let sct = self.llm.semantic_cache_threshold;
296 if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
297 return Err(ConfigError::Validation(format!(
298 "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
299 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
300 )));
301 }
302
303 if self.skills.evaluation.enabled {
305 let weight_sum = self.skills.evaluation.weight_correctness
306 + self.skills.evaluation.weight_reusability
307 + self.skills.evaluation.weight_specificity;
308 if (weight_sum - 1.0_f32).abs() > 1e-3 {
309 return Err(ConfigError::Validation(format!(
310 "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
311 )));
312 }
313 }
314
315 self.validate_provider_names()?;
316
317 if self.mcp.output_schema_hint_bytes < 64 {
318 return Err(ConfigError::Validation(format!(
319 "mcp.output_schema_hint_bytes must be >= 64, got {}; \
320 use forward_output_schema = false to disable forwarding",
321 self.mcp.output_schema_hint_bytes
322 )));
323 }
324
325 Ok(())
326 }
327
328 #[allow(clippy::too_many_lines)]
329 fn validate_provider_names(&self) -> Result<(), ConfigError> {
330 use std::collections::HashSet;
331 let known: HashSet<String> = self
332 .llm
333 .providers
334 .iter()
335 .map(super::providers::ProviderEntry::effective_name)
336 .collect();
337
338 let fields: &[(&str, &crate::providers::ProviderName)] = &[
339 (
340 "memory.tiers.scene_provider",
341 &self.memory.tiers.scene_provider,
342 ),
343 (
344 "memory.compression.compress_provider",
345 &self.memory.compression.compress_provider,
346 ),
347 (
348 "memory.consolidation.consolidation_provider",
349 &self.memory.consolidation.consolidation_provider,
350 ),
351 (
352 "memory.admission.admission_provider",
353 &self.memory.admission.admission_provider,
354 ),
355 (
356 "memory.admission.goal_utility_provider",
357 &self.memory.admission.goal_utility_provider,
358 ),
359 (
360 "memory.store_routing.routing_classifier_provider",
361 &self.memory.store_routing.routing_classifier_provider,
362 ),
363 (
364 "skills.learning.feedback_provider",
365 &self.skills.learning.feedback_provider,
366 ),
367 (
368 "skills.learning.arise_trace_provider",
369 &self.skills.learning.arise_trace_provider,
370 ),
371 (
372 "skills.learning.stem_provider",
373 &self.skills.learning.stem_provider,
374 ),
375 (
376 "skills.learning.erl_extract_provider",
377 &self.skills.learning.erl_extract_provider,
378 ),
379 (
380 "mcp.pruning.pruning_provider",
381 &self.mcp.pruning.pruning_provider,
382 ),
383 (
384 "mcp.tool_discovery.embedding_provider",
385 &self.mcp.tool_discovery.embedding_provider,
386 ),
387 (
388 "security.response_verification.verifier_provider",
389 &self.security.response_verification.verifier_provider,
390 ),
391 (
392 "orchestration.planner_provider",
393 &self.orchestration.planner_provider,
394 ),
395 (
396 "orchestration.verify_provider",
397 &self.orchestration.verify_provider,
398 ),
399 (
400 "orchestration.tool_provider",
401 &self.orchestration.tool_provider,
402 ),
403 (
404 "skills.evaluation.provider",
405 &self.skills.evaluation.provider,
406 ),
407 (
408 "skills.proactive_exploration.provider",
409 &self.skills.proactive_exploration.provider,
410 ),
411 (
412 "memory.compression_spectrum.promotion_provider",
413 &self.memory.compression_spectrum.promotion_provider,
414 ),
415 ];
416
417 for (field, name) in fields {
418 if !name.is_empty() && !known.contains(name.as_str()) {
419 return Err(ConfigError::Validation(format!(
420 "{field} = {:?} does not match any [[llm.providers]] entry",
421 name.as_str()
422 )));
423 }
424 }
425
426 if let Some(triage) = self
427 .llm
428 .complexity_routing
429 .as_ref()
430 .and_then(|cr| cr.triage_provider.as_ref())
431 .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
432 {
433 return Err(ConfigError::Validation(format!(
434 "llm.complexity_routing.triage_provider = {:?} does not match any \
435 [[llm.providers]] entry",
436 triage.as_str()
437 )));
438 }
439
440 if let Some(embed) = self
441 .llm
442 .router
443 .as_ref()
444 .and_then(|r| r.bandit.as_ref())
445 .map(|b| &b.embedding_provider)
446 .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
447 {
448 return Err(ConfigError::Validation(format!(
449 "llm.router.bandit.embedding_provider = {:?} does not match any \
450 [[llm.providers]] entry",
451 embed.as_str()
452 )));
453 }
454
455 Ok(())
456 }
457
458 fn normalize_legacy_runtime_defaults(&mut self) {
459 use crate::defaults::{
460 default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
461 is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
462 is_legacy_default_sqlite_path,
463 };
464
465 if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
466 self.memory.sqlite_path = default_sqlite_path();
467 }
468
469 for skill_path in &mut self.skills.paths {
470 if is_legacy_default_skills_path(skill_path) {
471 *skill_path = default_skills_dir();
472 }
473 }
474
475 if is_legacy_default_debug_dir(&self.debug.output_dir) {
476 self.debug.output_dir = default_debug_dir();
477 }
478
479 if is_legacy_default_log_file(&self.logging.file) {
480 self.logging.file = default_log_file_path();
481 }
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 fn config_with_sct(threshold: f32) -> Config {
490 let mut cfg = Config::default();
491 cfg.llm.semantic_cache_threshold = threshold;
492 cfg
493 }
494
495 #[test]
496 fn semantic_cache_threshold_valid_zero() {
497 assert!(config_with_sct(0.0).validate().is_ok());
498 }
499
500 #[test]
501 fn semantic_cache_threshold_valid_mid() {
502 assert!(config_with_sct(0.5).validate().is_ok());
503 }
504
505 #[test]
506 fn semantic_cache_threshold_valid_one() {
507 assert!(config_with_sct(1.0).validate().is_ok());
508 }
509
510 #[test]
511 fn semantic_cache_threshold_invalid_negative() {
512 let err = config_with_sct(-0.1).validate().unwrap_err();
513 assert!(
514 err.to_string().contains("semantic_cache_threshold"),
515 "unexpected error: {err}"
516 );
517 }
518
519 #[test]
520 fn semantic_cache_threshold_invalid_above_one() {
521 let err = config_with_sct(1.1).validate().unwrap_err();
522 assert!(
523 err.to_string().contains("semantic_cache_threshold"),
524 "unexpected error: {err}"
525 );
526 }
527
528 #[test]
529 fn semantic_cache_threshold_invalid_nan() {
530 let err = config_with_sct(f32::NAN).validate().unwrap_err();
531 assert!(
532 err.to_string().contains("semantic_cache_threshold"),
533 "unexpected error: {err}"
534 );
535 }
536
537 #[test]
538 fn semantic_cache_threshold_invalid_infinity() {
539 let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
540 assert!(
541 err.to_string().contains("semantic_cache_threshold"),
542 "unexpected error: {err}"
543 );
544 }
545
546 #[test]
547 fn semantic_cache_threshold_invalid_neg_infinity() {
548 let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
549 assert!(
550 err.to_string().contains("semantic_cache_threshold"),
551 "unexpected error: {err}"
552 );
553 }
554
555 fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
556 let mut cfg = Config::default();
557 cfg.memory.compression.probe.enabled = enabled;
558 cfg.memory.compression.probe.threshold = threshold;
559 cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
560 cfg
561 }
562
563 #[test]
564 fn probe_disabled_skips_validation() {
565 let cfg = probe_config(false, 0.0, 1.0);
567 assert!(cfg.validate().is_ok());
568 }
569
570 #[test]
571 fn probe_valid_thresholds() {
572 let cfg = probe_config(true, 0.6, 0.35);
573 assert!(cfg.validate().is_ok());
574 }
575
576 #[test]
577 fn probe_threshold_zero_invalid() {
578 let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
579 assert!(
580 err.to_string().contains("probe.threshold"),
581 "unexpected error: {err}"
582 );
583 }
584
585 #[test]
586 fn probe_hard_fail_threshold_above_one_invalid() {
587 let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
588 assert!(
589 err.to_string().contains("probe.hard_fail_threshold"),
590 "unexpected error: {err}"
591 );
592 }
593
594 #[test]
595 fn probe_hard_fail_gte_threshold_invalid() {
596 let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
597 assert!(
598 err.to_string().contains("probe.hard_fail_threshold"),
599 "unexpected error: {err}"
600 );
601 }
602
603 fn config_with_completeness_threshold(ct: f32) -> Config {
604 let mut cfg = Config::default();
605 cfg.orchestration.completeness_threshold = ct;
606 cfg
607 }
608
609 #[test]
610 fn completeness_threshold_valid_zero() {
611 assert!(config_with_completeness_threshold(0.0).validate().is_ok());
612 }
613
614 #[test]
615 fn completeness_threshold_valid_default() {
616 assert!(config_with_completeness_threshold(0.7).validate().is_ok());
617 }
618
619 #[test]
620 fn completeness_threshold_valid_one() {
621 assert!(config_with_completeness_threshold(1.0).validate().is_ok());
622 }
623
624 #[test]
625 fn completeness_threshold_invalid_negative() {
626 let err = config_with_completeness_threshold(-0.1)
627 .validate()
628 .unwrap_err();
629 assert!(
630 err.to_string().contains("completeness_threshold"),
631 "unexpected error: {err}"
632 );
633 }
634
635 #[test]
636 fn completeness_threshold_invalid_above_one() {
637 let err = config_with_completeness_threshold(1.1)
638 .validate()
639 .unwrap_err();
640 assert!(
641 err.to_string().contains("completeness_threshold"),
642 "unexpected error: {err}"
643 );
644 }
645
646 #[test]
647 fn completeness_threshold_invalid_nan() {
648 let err = config_with_completeness_threshold(f32::NAN)
649 .validate()
650 .unwrap_err();
651 assert!(
652 err.to_string().contains("completeness_threshold"),
653 "unexpected error: {err}"
654 );
655 }
656
657 #[test]
658 fn completeness_threshold_invalid_infinity() {
659 let err = config_with_completeness_threshold(f32::INFINITY)
660 .validate()
661 .unwrap_err();
662 assert!(
663 err.to_string().contains("completeness_threshold"),
664 "unexpected error: {err}"
665 );
666 }
667
668 fn config_with_provider(name: &str) -> Config {
669 let mut cfg = Config::default();
670 cfg.llm.providers.push(crate::providers::ProviderEntry {
671 provider_type: crate::providers::ProviderKind::Ollama,
672 name: Some(name.into()),
673 ..Default::default()
674 });
675 cfg
676 }
677
678 #[test]
679 fn validate_provider_names_all_empty_ok() {
680 let cfg = Config::default();
681 assert!(cfg.validate_provider_names().is_ok());
682 }
683
684 #[test]
685 fn validate_provider_names_matching_provider_ok() {
686 let mut cfg = config_with_provider("fast");
687 cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
688 assert!(cfg.validate_provider_names().is_ok());
689 }
690
691 #[test]
692 fn validate_provider_names_unknown_provider_err() {
693 let mut cfg = config_with_provider("fast");
694 cfg.memory.admission.admission_provider =
695 crate::providers::ProviderName::new("nonexistent");
696 let err = cfg.validate_provider_names().unwrap_err();
697 let msg = err.to_string();
698 assert!(
699 msg.contains("admission_provider") && msg.contains("nonexistent"),
700 "unexpected error: {msg}"
701 );
702 }
703
704 #[test]
705 fn validate_provider_names_triage_provider_none_ok() {
706 let mut cfg = config_with_provider("fast");
707 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
708 triage_provider: None,
709 ..Default::default()
710 });
711 assert!(cfg.validate_provider_names().is_ok());
712 }
713
714 #[test]
715 fn validate_provider_names_triage_provider_matching_ok() {
716 let mut cfg = config_with_provider("fast");
717 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
718 triage_provider: Some(crate::providers::ProviderName::new("fast")),
719 ..Default::default()
720 });
721 assert!(cfg.validate_provider_names().is_ok());
722 }
723
724 #[test]
725 fn validate_provider_names_triage_provider_unknown_err() {
726 let mut cfg = config_with_provider("fast");
727 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
728 triage_provider: Some(crate::providers::ProviderName::new("ghost")),
729 ..Default::default()
730 });
731 let err = cfg.validate_provider_names().unwrap_err();
732 let msg = err.to_string();
733 assert!(
734 msg.contains("triage_provider") && msg.contains("ghost"),
735 "unexpected error: {msg}"
736 );
737 }
738
739 #[test]
742 fn toml_float_fields_deserialise_correctly() {
743 let toml = r"
744[llm.router.reputation]
745enabled = true
746decay_factor = 0.95
747weight = 0.3
748
749[llm.router.bandit]
750enabled = false
751cost_weight = 0.3
752alpha = 1.0
753decay_factor = 0.99
754
755[skills]
756disambiguation_threshold = 0.25
757cosine_weight = 0.7
758";
759 let wrapped = format!(
761 "{}\n{}",
762 toml,
763 r"[memory.semantic]
764mmr_lambda = 0.7
765"
766 );
767 let router: crate::providers::RouterConfig = toml::from_str(
769 r"[reputation]
770enabled = true
771decay_factor = 0.95
772weight = 0.3
773",
774 )
775 .expect("RouterConfig with float fields must deserialise");
776 assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
777
778 let bandit: crate::providers::BanditConfig =
779 toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
780 .expect("BanditConfig with float fields must deserialise");
781 assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
782
783 let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
784 .expect("SemanticConfig with float fields must deserialise");
785 assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
786
787 let skills: crate::features::SkillsConfig =
788 toml::from_str("disambiguation_threshold = 0.25\n")
789 .expect("SkillsConfig with float fields must deserialise");
790 assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
791
792 let _ = wrapped; }
794
795 #[test]
796 fn focus_auto_consolidate_min_window_zero_rejected() {
797 let mut cfg = Config::default();
798 cfg.agent.focus.auto_consolidate_min_window = 0;
799 let err = cfg.validate().unwrap_err().to_string();
800 assert!(
801 err.contains("auto_consolidate_min_window"),
802 "expected auto_consolidate_min_window in error, got: {err}"
803 );
804 }
805
806 #[test]
807 fn focus_auto_consolidate_min_window_one_accepted() {
808 let mut cfg = Config::default();
809 cfg.agent.focus.auto_consolidate_min_window = 1;
810 assert!(cfg.validate().is_ok());
811 }
812}