Skip to main content

zeph_config/
loader.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10    /// Load configuration from a TOML file with env var overrides.
11    ///
12    /// Falls back to sensible defaults when the file does not exist.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the file exists but cannot be read or parsed.
17    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    /// Serialize the default configuration to a TOML string.
31    ///
32    /// Produces a pretty-printed TOML representation of [`Config::default()`].
33    /// Useful for bootstrapping a new config file or documenting available options.
34    ///
35    /// The `secrets` field is always excluded from the output because it is
36    /// populated at runtime only and must never be written to disk.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if serialization fails (unlikely — the default value is
41    /// always structurally valid).
42    ///
43    /// # Examples
44    ///
45    /// ```no_run
46    /// use zeph_config::Config;
47    ///
48    /// let toml = Config::dump_defaults().expect("serialization failed");
49    /// assert!(toml.contains("[agent]"));
50    /// assert!(toml.contains("[memory]"));
51    /// ```
52    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    /// Validate configuration values are within sane bounds.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if any value is out of range.
64    #[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        // MCP server validation
193        {
194            use std::collections::HashSet;
195            let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
196            for s in &self.mcp.servers {
197                // headers and oauth are mutually exclusive
198                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                // vault key collision detection
205                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        // Cascade chain threshold must not be 1 — that would abort on every single failure.
237        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        // Focus config validation
261        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
272        // SideQuest config validation
273        if self.memory.sidequest.interval_turns == 0 {
274            return Err(ConfigError::Validation(
275                "memory.sidequest.interval_turns must be >= 1".into(),
276            ));
277        }
278        if !self.memory.sidequest.max_eviction_ratio.is_finite()
279            || self.memory.sidequest.max_eviction_ratio <= 0.0
280            || self.memory.sidequest.max_eviction_ratio > 1.0
281        {
282            return Err(ConfigError::Validation(format!(
283                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
284                self.memory.sidequest.max_eviction_ratio
285            )));
286        }
287
288        let sct = self.llm.semantic_cache_threshold;
289        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
290            return Err(ConfigError::Validation(format!(
291                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
292                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
293            )));
294        }
295
296        self.validate_provider_names()?;
297
298        if self.mcp.output_schema_hint_bytes < 64 {
299            return Err(ConfigError::Validation(format!(
300                "mcp.output_schema_hint_bytes must be >= 64, got {}; \
301                 use forward_output_schema = false to disable forwarding",
302                self.mcp.output_schema_hint_bytes
303            )));
304        }
305
306        Ok(())
307    }
308
309    #[allow(clippy::too_many_lines)]
310    fn validate_provider_names(&self) -> Result<(), ConfigError> {
311        use std::collections::HashSet;
312        let known: HashSet<String> = self
313            .llm
314            .providers
315            .iter()
316            .map(super::providers::ProviderEntry::effective_name)
317            .collect();
318
319        let fields: &[(&str, &crate::providers::ProviderName)] = &[
320            (
321                "memory.tiers.scene_provider",
322                &self.memory.tiers.scene_provider,
323            ),
324            (
325                "memory.compression.compress_provider",
326                &self.memory.compression.compress_provider,
327            ),
328            (
329                "memory.consolidation.consolidation_provider",
330                &self.memory.consolidation.consolidation_provider,
331            ),
332            (
333                "memory.admission.admission_provider",
334                &self.memory.admission.admission_provider,
335            ),
336            (
337                "memory.admission.goal_utility_provider",
338                &self.memory.admission.goal_utility_provider,
339            ),
340            (
341                "memory.store_routing.routing_classifier_provider",
342                &self.memory.store_routing.routing_classifier_provider,
343            ),
344            (
345                "skills.learning.feedback_provider",
346                &self.skills.learning.feedback_provider,
347            ),
348            (
349                "skills.learning.arise_trace_provider",
350                &self.skills.learning.arise_trace_provider,
351            ),
352            (
353                "skills.learning.stem_provider",
354                &self.skills.learning.stem_provider,
355            ),
356            (
357                "skills.learning.erl_extract_provider",
358                &self.skills.learning.erl_extract_provider,
359            ),
360            (
361                "mcp.pruning.pruning_provider",
362                &self.mcp.pruning.pruning_provider,
363            ),
364            (
365                "mcp.tool_discovery.embedding_provider",
366                &self.mcp.tool_discovery.embedding_provider,
367            ),
368            (
369                "security.response_verification.verifier_provider",
370                &self.security.response_verification.verifier_provider,
371            ),
372            (
373                "orchestration.planner_provider",
374                &self.orchestration.planner_provider,
375            ),
376            (
377                "orchestration.verify_provider",
378                &self.orchestration.verify_provider,
379            ),
380            (
381                "orchestration.tool_provider",
382                &self.orchestration.tool_provider,
383            ),
384        ];
385
386        for (field, name) in fields {
387            if !name.is_empty() && !known.contains(name.as_str()) {
388                return Err(ConfigError::Validation(format!(
389                    "{field} = {:?} does not match any [[llm.providers]] entry",
390                    name.as_str()
391                )));
392            }
393        }
394
395        if let Some(triage) = self
396            .llm
397            .complexity_routing
398            .as_ref()
399            .and_then(|cr| cr.triage_provider.as_ref())
400            .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
401        {
402            return Err(ConfigError::Validation(format!(
403                "llm.complexity_routing.triage_provider = {:?} does not match any \
404                 [[llm.providers]] entry",
405                triage.as_str()
406            )));
407        }
408
409        if let Some(embed) = self
410            .llm
411            .router
412            .as_ref()
413            .and_then(|r| r.bandit.as_ref())
414            .map(|b| &b.embedding_provider)
415            .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
416        {
417            return Err(ConfigError::Validation(format!(
418                "llm.router.bandit.embedding_provider = {:?} does not match any \
419                 [[llm.providers]] entry",
420                embed.as_str()
421            )));
422        }
423
424        Ok(())
425    }
426
427    fn normalize_legacy_runtime_defaults(&mut self) {
428        use crate::defaults::{
429            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
430            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
431            is_legacy_default_sqlite_path,
432        };
433
434        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
435            self.memory.sqlite_path = default_sqlite_path();
436        }
437
438        for skill_path in &mut self.skills.paths {
439            if is_legacy_default_skills_path(skill_path) {
440                *skill_path = default_skills_dir();
441            }
442        }
443
444        if is_legacy_default_debug_dir(&self.debug.output_dir) {
445            self.debug.output_dir = default_debug_dir();
446        }
447
448        if is_legacy_default_log_file(&self.logging.file) {
449            self.logging.file = default_log_file_path();
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn config_with_sct(threshold: f32) -> Config {
459        let mut cfg = Config::default();
460        cfg.llm.semantic_cache_threshold = threshold;
461        cfg
462    }
463
464    #[test]
465    fn semantic_cache_threshold_valid_zero() {
466        assert!(config_with_sct(0.0).validate().is_ok());
467    }
468
469    #[test]
470    fn semantic_cache_threshold_valid_mid() {
471        assert!(config_with_sct(0.5).validate().is_ok());
472    }
473
474    #[test]
475    fn semantic_cache_threshold_valid_one() {
476        assert!(config_with_sct(1.0).validate().is_ok());
477    }
478
479    #[test]
480    fn semantic_cache_threshold_invalid_negative() {
481        let err = config_with_sct(-0.1).validate().unwrap_err();
482        assert!(
483            err.to_string().contains("semantic_cache_threshold"),
484            "unexpected error: {err}"
485        );
486    }
487
488    #[test]
489    fn semantic_cache_threshold_invalid_above_one() {
490        let err = config_with_sct(1.1).validate().unwrap_err();
491        assert!(
492            err.to_string().contains("semantic_cache_threshold"),
493            "unexpected error: {err}"
494        );
495    }
496
497    #[test]
498    fn semantic_cache_threshold_invalid_nan() {
499        let err = config_with_sct(f32::NAN).validate().unwrap_err();
500        assert!(
501            err.to_string().contains("semantic_cache_threshold"),
502            "unexpected error: {err}"
503        );
504    }
505
506    #[test]
507    fn semantic_cache_threshold_invalid_infinity() {
508        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
509        assert!(
510            err.to_string().contains("semantic_cache_threshold"),
511            "unexpected error: {err}"
512        );
513    }
514
515    #[test]
516    fn semantic_cache_threshold_invalid_neg_infinity() {
517        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
518        assert!(
519            err.to_string().contains("semantic_cache_threshold"),
520            "unexpected error: {err}"
521        );
522    }
523
524    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
525        let mut cfg = Config::default();
526        cfg.memory.compression.probe.enabled = enabled;
527        cfg.memory.compression.probe.threshold = threshold;
528        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
529        cfg
530    }
531
532    #[test]
533    fn probe_disabled_skips_validation() {
534        // Invalid thresholds when probe is disabled must not cause errors.
535        let cfg = probe_config(false, 0.0, 1.0);
536        assert!(cfg.validate().is_ok());
537    }
538
539    #[test]
540    fn probe_valid_thresholds() {
541        let cfg = probe_config(true, 0.6, 0.35);
542        assert!(cfg.validate().is_ok());
543    }
544
545    #[test]
546    fn probe_threshold_zero_invalid() {
547        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
548        assert!(
549            err.to_string().contains("probe.threshold"),
550            "unexpected error: {err}"
551        );
552    }
553
554    #[test]
555    fn probe_hard_fail_threshold_above_one_invalid() {
556        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
557        assert!(
558            err.to_string().contains("probe.hard_fail_threshold"),
559            "unexpected error: {err}"
560        );
561    }
562
563    #[test]
564    fn probe_hard_fail_gte_threshold_invalid() {
565        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
566        assert!(
567            err.to_string().contains("probe.hard_fail_threshold"),
568            "unexpected error: {err}"
569        );
570    }
571
572    fn config_with_completeness_threshold(ct: f32) -> Config {
573        let mut cfg = Config::default();
574        cfg.orchestration.completeness_threshold = ct;
575        cfg
576    }
577
578    #[test]
579    fn completeness_threshold_valid_zero() {
580        assert!(config_with_completeness_threshold(0.0).validate().is_ok());
581    }
582
583    #[test]
584    fn completeness_threshold_valid_default() {
585        assert!(config_with_completeness_threshold(0.7).validate().is_ok());
586    }
587
588    #[test]
589    fn completeness_threshold_valid_one() {
590        assert!(config_with_completeness_threshold(1.0).validate().is_ok());
591    }
592
593    #[test]
594    fn completeness_threshold_invalid_negative() {
595        let err = config_with_completeness_threshold(-0.1)
596            .validate()
597            .unwrap_err();
598        assert!(
599            err.to_string().contains("completeness_threshold"),
600            "unexpected error: {err}"
601        );
602    }
603
604    #[test]
605    fn completeness_threshold_invalid_above_one() {
606        let err = config_with_completeness_threshold(1.1)
607            .validate()
608            .unwrap_err();
609        assert!(
610            err.to_string().contains("completeness_threshold"),
611            "unexpected error: {err}"
612        );
613    }
614
615    #[test]
616    fn completeness_threshold_invalid_nan() {
617        let err = config_with_completeness_threshold(f32::NAN)
618            .validate()
619            .unwrap_err();
620        assert!(
621            err.to_string().contains("completeness_threshold"),
622            "unexpected error: {err}"
623        );
624    }
625
626    #[test]
627    fn completeness_threshold_invalid_infinity() {
628        let err = config_with_completeness_threshold(f32::INFINITY)
629            .validate()
630            .unwrap_err();
631        assert!(
632            err.to_string().contains("completeness_threshold"),
633            "unexpected error: {err}"
634        );
635    }
636
637    fn config_with_provider(name: &str) -> Config {
638        let mut cfg = Config::default();
639        cfg.llm.providers.push(crate::providers::ProviderEntry {
640            provider_type: crate::providers::ProviderKind::Ollama,
641            name: Some(name.into()),
642            ..Default::default()
643        });
644        cfg
645    }
646
647    #[test]
648    fn validate_provider_names_all_empty_ok() {
649        let cfg = Config::default();
650        assert!(cfg.validate_provider_names().is_ok());
651    }
652
653    #[test]
654    fn validate_provider_names_matching_provider_ok() {
655        let mut cfg = config_with_provider("fast");
656        cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
657        assert!(cfg.validate_provider_names().is_ok());
658    }
659
660    #[test]
661    fn validate_provider_names_unknown_provider_err() {
662        let mut cfg = config_with_provider("fast");
663        cfg.memory.admission.admission_provider =
664            crate::providers::ProviderName::new("nonexistent");
665        let err = cfg.validate_provider_names().unwrap_err();
666        let msg = err.to_string();
667        assert!(
668            msg.contains("admission_provider") && msg.contains("nonexistent"),
669            "unexpected error: {msg}"
670        );
671    }
672
673    #[test]
674    fn validate_provider_names_triage_provider_none_ok() {
675        let mut cfg = config_with_provider("fast");
676        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
677            triage_provider: None,
678            ..Default::default()
679        });
680        assert!(cfg.validate_provider_names().is_ok());
681    }
682
683    #[test]
684    fn validate_provider_names_triage_provider_matching_ok() {
685        let mut cfg = config_with_provider("fast");
686        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
687            triage_provider: Some(crate::providers::ProviderName::new("fast")),
688            ..Default::default()
689        });
690        assert!(cfg.validate_provider_names().is_ok());
691    }
692
693    #[test]
694    fn validate_provider_names_triage_provider_unknown_err() {
695        let mut cfg = config_with_provider("fast");
696        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
697            triage_provider: Some(crate::providers::ProviderName::new("ghost")),
698            ..Default::default()
699        });
700        let err = cfg.validate_provider_names().unwrap_err();
701        let msg = err.to_string();
702        assert!(
703            msg.contains("triage_provider") && msg.contains("ghost"),
704            "unexpected error: {msg}"
705        );
706    }
707
708    // Regression test for issue #2599: TOML float values must deserialise without error
709    // across all config sections that contain f32/f64 fields.
710    #[test]
711    fn toml_float_fields_deserialise_correctly() {
712        let toml = r"
713[llm.router.reputation]
714enabled = true
715decay_factor = 0.95
716weight = 0.3
717
718[llm.router.bandit]
719enabled = false
720cost_weight = 0.3
721alpha = 1.0
722decay_factor = 0.99
723
724[skills]
725disambiguation_threshold = 0.25
726cosine_weight = 0.7
727";
728        // Wrap in a full Config to exercise the nested paths.
729        let wrapped = format!(
730            "{}\n{}",
731            toml,
732            r"[memory.semantic]
733mmr_lambda = 0.7
734"
735        );
736        // We only need the sub-structs to round-trip; build minimal wrappers.
737        let router: crate::providers::RouterConfig = toml::from_str(
738            r"[reputation]
739enabled = true
740decay_factor = 0.95
741weight = 0.3
742",
743        )
744        .expect("RouterConfig with float fields must deserialise");
745        assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
746
747        let bandit: crate::providers::BanditConfig =
748            toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
749                .expect("BanditConfig with float fields must deserialise");
750        assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
751
752        let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
753            .expect("SemanticConfig with float fields must deserialise");
754        assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
755
756        let skills: crate::features::SkillsConfig =
757            toml::from_str("disambiguation_threshold = 0.25\n")
758                .expect("SkillsConfig with float fields must deserialise");
759        assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
760
761        let _ = wrapped; // silence unused-variable lint
762    }
763}