Skip to main content

recall_echo/
config.rs

1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7/// Evidence weights per provenance class, re-exported from the confidence
8/// model that owns them: `[graph.provenance]` is only their config surface.
9pub use crate::graph::confidence::ProvenanceWeights;
10
11const DEFAULT_MAX_ENTRIES: usize = 5;
12const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
13const CONFIG_FILE: &str = ".recall-echo.toml";
14
15// ── Provider enum ────────────────────────────────────────────────────────
16
17/// LLM provider for entity extraction.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Provider {
21    Anthropic,
22    Openai,
23    ClaudeCode,
24}
25
26impl Provider {
27    #[must_use]
28    pub fn default_model(&self) -> &'static str {
29        match self {
30            Provider::Anthropic => "claude-haiku-4-5-20251001",
31            Provider::Openai => "llama3.2",
32            Provider::ClaudeCode => "",
33        }
34    }
35
36    #[must_use]
37    pub fn default_api_base(&self) -> &'static str {
38        match self {
39            Provider::Anthropic => "https://api.anthropic.com/v1/messages",
40            Provider::Openai => "http://localhost:11434/v1",
41            Provider::ClaudeCode => "",
42        }
43    }
44
45    pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
46        match s.to_lowercase().as_str() {
47            "anthropic" | "claude" => Ok(Provider::Anthropic),
48            "openai" | "ollama" => Ok(Provider::Openai),
49            "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
50            other => Err(crate::error::RecallError::Config(format!(
51                "unknown provider: {other} (use 'anthropic', 'ollama', or 'claude-code')"
52            ))),
53        }
54    }
55}
56
57impl fmt::Display for Provider {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Provider::Anthropic => write!(f, "anthropic"),
61            Provider::Openai => write!(f, "openai"),
62            Provider::ClaudeCode => write!(f, "claude-code"),
63        }
64    }
65}
66
67// ── Config structs ───────────────────────────────────────────────────────
68
69#[derive(Debug, Default, Serialize, Deserialize)]
70pub struct Config {
71    #[serde(default)]
72    pub ephemeral: EphemeralConfig,
73    #[serde(default)]
74    pub llm: LlmSection,
75    #[serde(default)]
76    pub pipeline: Option<PipelineSection>,
77    #[serde(default)]
78    pub graph: Option<GraphSection>,
79    #[serde(default)]
80    pub serve: ServeSection,
81}
82
83#[derive(Debug, Serialize, Deserialize)]
84pub struct EphemeralConfig {
85    #[serde(default = "default_max_entries")]
86    pub max_entries: usize,
87}
88
89impl Default for EphemeralConfig {
90    fn default() -> Self {
91        Self {
92            max_entries: DEFAULT_MAX_ENTRIES,
93        }
94    }
95}
96
97fn default_max_entries() -> usize {
98    DEFAULT_MAX_ENTRIES
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct LlmSection {
103    #[serde(default = "default_provider")]
104    pub provider: Provider,
105    #[serde(default)]
106    pub model: String,
107    #[serde(default)]
108    pub api_base: String,
109}
110
111impl Default for LlmSection {
112    fn default() -> Self {
113        Self {
114            provider: Provider::Anthropic,
115            model: String::new(),
116            api_base: String::new(),
117        }
118    }
119}
120
121impl LlmSection {
122    /// Resolved model — uses configured value or provider default.
123    #[must_use]
124    pub fn resolved_model(&self) -> &str {
125        if self.model.is_empty() {
126            self.provider.default_model()
127        } else {
128            &self.model
129        }
130    }
131
132    /// Resolved API base — uses configured value or provider default.
133    #[must_use]
134    pub fn resolved_api_base(&self) -> &str {
135        if self.api_base.is_empty() {
136            self.provider.default_api_base()
137        } else {
138            &self.api_base
139        }
140    }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PipelineSection {
145    /// Directory containing pipeline documents (LEARNING.md, THOUGHTS.md, etc.)
146    #[serde(default)]
147    pub docs_dir: Option<String>,
148    /// Auto-sync pipeline on archive (default: false)
149    #[serde(default)]
150    pub auto_sync: Option<bool>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct GraphSection {
155    /// Connection mode: "embedded" or "server"
156    #[serde(default = "default_graph_mode")]
157    pub mode: String,
158    /// SurrealDB server URL (server mode only)
159    #[serde(default = "default_graph_url")]
160    pub url: String,
161    /// SurrealDB namespace
162    #[serde(default = "default_graph_namespace")]
163    pub namespace: String,
164    /// SurrealDB database name (typically the entity name)
165    #[serde(default)]
166    pub database: String,
167    /// SurrealDB username (typically the entity name)
168    #[serde(default)]
169    pub username: String,
170    /// Path to file containing the database password
171    #[serde(default)]
172    pub password_file: String,
173    /// Scoring weights for utility-weighted semantic search.
174    ///
175    /// Maps to the `[graph.scoring]` section of `.recall-echo.toml`. When
176    /// absent, defaults preserve the original hard-coded weights
177    /// (0.45 / 0.30 / 0.25). See `GraphScoringConfig` for details.
178    #[serde(default)]
179    pub scoring: GraphScoringConfig,
180    /// Evidence weights per provenance class.
181    ///
182    /// Maps to the `[graph.provenance]` section of `.recall-echo.toml`. When
183    /// absent, defaults are 1.0 external / 0.8 user / 0.05 self. See
184    /// [`ProvenanceWeights`] for details.
185    #[serde(default)]
186    pub provenance: ProvenanceWeights,
187}
188
189impl Default for GraphSection {
190    fn default() -> Self {
191        Self {
192            mode: default_graph_mode(),
193            url: default_graph_url(),
194            namespace: default_graph_namespace(),
195            database: String::new(),
196            username: String::new(),
197            password_file: String::new(),
198            scoring: GraphScoringConfig::default(),
199            provenance: ProvenanceWeights::default(),
200        }
201    }
202}
203
204/// Settings for the `recall-echo serve` graph daemon.
205///
206/// Maps to the `[serve]` section of `.recall-echo.toml`. The daemon is started
207/// transparently by graph commands and hooks when `[graph] mode = "embedded"`
208/// (the default); these keys only tune where it listens and how long it lives.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(default)]
211pub struct ServeSection {
212    /// Override the unix socket path. Defaults to
213    /// `$XDG_RUNTIME_DIR/recall-echo/<hash of memory dir>.sock`.
214    pub socket_path: Option<String>,
215    /// Seconds of inactivity before the daemon shuts itself down.
216    /// `0` disables idle shutdown. Default `3600`.
217    pub idle_timeout_secs: u64,
218}
219
220impl Default for ServeSection {
221    fn default() -> Self {
222        Self {
223            socket_path: None,
224            idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
225        }
226    }
227}
228
229/// Scoring weights for utility-weighted semantic search.
230///
231/// The final score for a retrieved entity is computed as a linear combination
232/// of three signals:
233///
234/// ```text
235/// score = weight_semantic * similarity
236///       + weight_hotness  * hotness
237///       + weight_utility  * utility_score
238/// ```
239///
240/// Defaults (`0.45 / 0.30 / 0.25`) match the original hard-coded values, so
241/// omitting the `[graph.scoring]` section from `.recall-echo.toml` produces
242/// identical behavior to pre-v3.9.0 recall-echo.
243///
244/// Weights are not constrained to sum to 1.0 — the scoring function does not
245/// normalize. Callers that change these should calibrate against their own
246/// retrieval outcomes; see `utility-feedback-loop-spec.md` in pulse-null.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default)]
249pub struct GraphScoringConfig {
250    /// Weight applied to cosine similarity. Default `0.45`.
251    pub weight_semantic: f64,
252    /// Weight applied to the recency/access hotness signal. Default `0.30`.
253    pub weight_hotness: f64,
254    /// Weight applied to the utility score (outcome-feedback EMA). Default `0.25`.
255    pub weight_utility: f64,
256}
257
258impl Default for GraphScoringConfig {
259    fn default() -> Self {
260        Self {
261            weight_semantic: 0.45,
262            weight_hotness: 0.30,
263            weight_utility: 0.25,
264        }
265    }
266}
267
268fn default_graph_mode() -> String {
269    "embedded".to_string()
270}
271
272fn default_graph_url() -> String {
273    "ws://localhost:8787".to_string()
274}
275
276fn default_graph_namespace() -> String {
277    "nullarc".to_string()
278}
279
280fn default_provider() -> Provider {
281    Provider::Anthropic
282}
283
284// ── Load / Save ──────────────────────────────────────────────────────────
285
286/// Config file path for a given base directory.
287#[must_use]
288pub fn config_path(base: &Path) -> std::path::PathBuf {
289    base.join(CONFIG_FILE)
290}
291
292/// Load config from .recall-echo.toml in the given directory.
293/// Returns defaults if file doesn't exist or is malformed.
294#[must_use]
295pub fn load_from_dir(dir: &Path) -> Config {
296    load(dir)
297}
298
299/// Load config from .recall-echo.toml in the base dir.
300/// Returns defaults if file doesn't exist or is malformed.
301#[must_use]
302pub fn load(base: &Path) -> Config {
303    let path = config_path(base);
304    if !path.exists() {
305        return Config::default();
306    }
307
308    let content = match fs::read_to_string(&path) {
309        Ok(c) => c,
310        Err(_) => return Config::default(),
311    };
312
313    match toml::from_str(&content) {
314        Ok(cfg) => validate(cfg),
315        Err(_) => Config::default(),
316    }
317}
318
319/// Save config to .recall-echo.toml in the base dir.
320pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
321    let path = config_path(base);
322    let content = toml::to_string_pretty(config)?;
323    fs::write(&path, content)?;
324    Ok(())
325}
326
327/// Returns true if .recall-echo.toml exists in the directory.
328#[must_use]
329pub fn exists(base: &Path) -> bool {
330    config_path(base).exists()
331}
332
333fn validate(mut cfg: Config) -> Config {
334    if !(1..=50).contains(&cfg.ephemeral.max_entries) {
335        cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
336    }
337    cfg
338}
339
340// ── Config mutation helpers ──────────────────────────────────────────────
341
342impl Config {
343    /// Set a dotted config key (e.g. "llm.provider", "ephemeral.max_entries").
344    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
345        use crate::error::RecallError;
346        match key {
347            "llm.provider" | "provider" => {
348                let provider = Provider::from_str_loose(value)?;
349                // When switching provider, reset model and api_base to defaults
350                self.llm.model = String::new();
351                self.llm.api_base = String::new();
352                self.llm.provider = provider;
353                Ok(())
354            }
355            "llm.model" | "model" => {
356                self.llm.model = value.to_string();
357                Ok(())
358            }
359            "llm.api_base" | "api_base" => {
360                self.llm.api_base = value.to_string();
361                Ok(())
362            }
363            "ephemeral.max_entries" => {
364                let n: usize = value
365                    .parse()
366                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
367                if !(1..=50).contains(&n) {
368                    return Err(RecallError::Config(
369                        "max_entries must be between 1 and 50".into(),
370                    ));
371                }
372                self.ephemeral.max_entries = n;
373                Ok(())
374            }
375            "pipeline.docs_dir" => {
376                let section = self.pipeline.get_or_insert(PipelineSection {
377                    docs_dir: None,
378                    auto_sync: None,
379                });
380                section.docs_dir = Some(value.to_string());
381                Ok(())
382            }
383            "pipeline.auto_sync" => {
384                let b: bool = value
385                    .parse()
386                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
387                let section = self.pipeline.get_or_insert(PipelineSection {
388                    docs_dir: None,
389                    auto_sync: None,
390                });
391                section.auto_sync = Some(b);
392                Ok(())
393            }
394            "serve.idle_timeout_secs" => {
395                let secs: u64 = value
396                    .parse()
397                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
398                self.serve.idle_timeout_secs = secs;
399                Ok(())
400            }
401            "serve.socket_path" => {
402                self.serve.socket_path = if value.trim().is_empty() {
403                    None
404                } else {
405                    Some(value.to_string())
406                };
407                Ok(())
408            }
409            "graph.provenance.weight_external" => {
410                self.graph_section().provenance.weight_external = parse_weight(value)?;
411                Ok(())
412            }
413            "graph.provenance.weight_user" => {
414                self.graph_section().provenance.weight_user = parse_weight(value)?;
415                Ok(())
416            }
417            "graph.provenance.weight_self" => {
418                self.graph_section().provenance.weight_self = parse_weight(value)?;
419                Ok(())
420            }
421            other => Err(RecallError::Config(format!("unknown config key: {other}"))),
422        }
423    }
424
425    /// The `[graph]` section, created at its defaults if the config has none.
426    fn graph_section(&mut self) -> &mut GraphSection {
427        self.graph.get_or_insert_with(GraphSection::default)
428    }
429}
430
431/// Parse an evidence weight: a finite, non-negative number.
432///
433/// Zero is allowed — it is how a class is switched off entirely.
434fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
435    use crate::error::RecallError;
436    let weight: f64 = value
437        .parse()
438        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
439    if !weight.is_finite() || weight < 0.0 {
440        return Err(RecallError::Config(format!(
441            "evidence weight must be finite and non-negative, got {value}"
442        )));
443    }
444    Ok(weight)
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn default_config() {
453        let cfg = Config::default();
454        assert_eq!(cfg.ephemeral.max_entries, 5);
455        assert_eq!(cfg.llm.provider, Provider::Anthropic);
456        assert!(cfg.llm.model.is_empty());
457    }
458
459    #[test]
460    fn parse_ephemeral_only() {
461        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
462        assert_eq!(cfg.ephemeral.max_entries, 10);
463        assert_eq!(cfg.llm.provider, Provider::Anthropic);
464    }
465
466    #[test]
467    fn graph_mode_defaults_to_embedded() {
468        let cfg: Config = toml::from_str("[graph]\n").unwrap();
469        assert_eq!(cfg.graph.unwrap().mode, "embedded");
470    }
471
472    #[test]
473    fn graph_mode_parses_server() {
474        let cfg: Config =
475            toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
476        let g = cfg.graph.unwrap();
477        assert_eq!(g.mode, "server");
478        assert_eq!(g.url, "ws://db.local:8787");
479    }
480
481    #[test]
482    fn serve_defaults_when_section_absent() {
483        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
484        assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
485        assert!(cfg.serve.socket_path.is_none());
486    }
487
488    #[test]
489    fn serve_section_parses_overrides() {
490        let cfg: Config = toml::from_str(
491            "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
492        )
493        .unwrap();
494        assert_eq!(cfg.serve.idle_timeout_secs, 60);
495        assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
496    }
497
498    #[test]
499    fn set_key_serve_idle_timeout() {
500        let mut cfg = Config::default();
501        cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
502        assert_eq!(cfg.serve.idle_timeout_secs, 120);
503        assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
504    }
505
506    #[test]
507    fn parse_llm_section() {
508        let cfg: Config = toml::from_str(
509            "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
510        )
511        .unwrap();
512        assert_eq!(cfg.llm.provider, Provider::Openai);
513        assert_eq!(cfg.llm.model, "llama3.1");
514        assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
515    }
516
517    #[test]
518    fn parse_claude_code_provider() {
519        let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
520        assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
521    }
522
523    #[test]
524    fn resolved_defaults() {
525        let llm = LlmSection::default();
526        assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
527        assert_eq!(
528            llm.resolved_api_base(),
529            "https://api.anthropic.com/v1/messages"
530        );
531    }
532
533    #[test]
534    fn resolved_custom_overrides_default() {
535        let llm = LlmSection {
536            provider: Provider::Openai,
537            model: "mistral-7b".into(),
538            api_base: String::new(),
539        };
540        assert_eq!(llm.resolved_model(), "mistral-7b");
541        assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
542    }
543
544    #[test]
545    fn round_trip_toml() {
546        let cfg = Config {
547            ephemeral: EphemeralConfig { max_entries: 3 },
548            llm: LlmSection {
549                provider: Provider::Openai,
550                model: "llama3.2".into(),
551                api_base: "http://localhost:11434/v1".into(),
552            },
553            pipeline: None,
554            graph: None,
555            serve: ServeSection::default(),
556        };
557        let s = toml::to_string_pretty(&cfg).unwrap();
558        let parsed: Config = toml::from_str(&s).unwrap();
559        assert_eq!(parsed.ephemeral.max_entries, 3);
560        assert_eq!(parsed.llm.provider, Provider::Openai);
561        assert_eq!(parsed.llm.model, "llama3.2");
562    }
563
564    #[test]
565    fn set_key_provider() {
566        let mut cfg = Config::default();
567        cfg.set_key("llm.provider", "ollama").unwrap();
568        assert_eq!(cfg.llm.provider, Provider::Openai);
569        assert!(cfg.llm.model.is_empty());
570    }
571
572    #[test]
573    fn set_key_model() {
574        let mut cfg = Config::default();
575        cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
576        assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
577    }
578
579    #[test]
580    fn set_key_unknown_fails() {
581        let mut cfg = Config::default();
582        assert!(cfg.set_key("nonexistent.key", "value").is_err());
583    }
584
585    #[test]
586    fn provider_from_str_loose() {
587        assert_eq!(
588            Provider::from_str_loose("ollama").unwrap(),
589            Provider::Openai
590        );
591        assert_eq!(
592            Provider::from_str_loose("claude").unwrap(),
593            Provider::Anthropic
594        );
595        assert_eq!(
596            Provider::from_str_loose("claude-code").unwrap(),
597            Provider::ClaudeCode
598        );
599        assert!(Provider::from_str_loose("unknown").is_err());
600    }
601
602    #[test]
603    fn save_and_load() {
604        let tmp = tempfile::tempdir().unwrap();
605        let cfg = Config {
606            ephemeral: EphemeralConfig { max_entries: 7 },
607            llm: LlmSection {
608                provider: Provider::ClaudeCode,
609                model: String::new(),
610                api_base: String::new(),
611            },
612            pipeline: None,
613            graph: None,
614            serve: ServeSection::default(),
615        };
616        save(tmp.path(), &cfg).unwrap();
617        let loaded = load(tmp.path());
618        assert_eq!(loaded.ephemeral.max_entries, 7);
619        assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
620    }
621
622    #[test]
623    fn load_nonexistent_file() {
624        let tmp = tempfile::tempdir().unwrap();
625        let cfg = load(tmp.path());
626        assert_eq!(cfg.ephemeral.max_entries, 5);
627    }
628
629    #[test]
630    fn validate_out_of_range() {
631        let cfg = validate(Config {
632            ephemeral: EphemeralConfig { max_entries: 100 },
633            llm: LlmSection::default(),
634            pipeline: None,
635            graph: None,
636            serve: ServeSection::default(),
637        });
638        assert_eq!(cfg.ephemeral.max_entries, 5);
639    }
640
641    #[test]
642    fn graph_scoring_defaults_match_legacy_hardcodes() {
643        let scoring = GraphScoringConfig::default();
644        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
645        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
646        assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
647    }
648
649    #[test]
650    fn graph_scoring_partial_toml_fills_defaults() {
651        let scoring: GraphScoringConfig =
652            toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
653        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
654        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
655        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
656    }
657
658    #[test]
659    fn graph_scoring_empty_section_yields_defaults() {
660        let section: GraphSection = toml::from_str("").expect("parse empty graph section");
661        let defaults = GraphScoringConfig::default();
662        assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
663        assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
664        assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
665    }
666
667    #[test]
668    fn graph_provenance_defaults_when_section_absent() {
669        let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
670        let defaults = ProvenanceWeights::default();
671        assert_eq!(section.provenance, defaults);
672        assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
673        assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
674        assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
675    }
676
677    #[test]
678    fn graph_provenance_partial_toml_fills_defaults() {
679        let cfg: Config =
680            toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
681        let provenance = cfg.graph.expect("graph section present").provenance;
682        assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
683        assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
684        assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
685    }
686
687    #[test]
688    fn set_key_provenance_weights() {
689        let mut cfg = Config::default();
690        cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
691        cfg.set_key("graph.provenance.weight_user", "0").unwrap();
692        cfg.set_key("graph.provenance.weight_external", "1.5")
693            .unwrap();
694
695        let provenance = cfg
696            .graph
697            .as_ref()
698            .expect("graph section created")
699            .provenance;
700        assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
701        assert!(provenance.weight_user.abs() < f64::EPSILON);
702        assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
703
704        assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
705        assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
706    }
707
708    #[test]
709    fn provenance_weights_round_trip_through_toml() {
710        let mut cfg = Config::default();
711        cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
712        let rendered = toml::to_string_pretty(&cfg).expect("render");
713        let parsed: Config = toml::from_str(&rendered).expect("reparse");
714        assert_eq!(
715            parsed.graph.expect("graph section survives").provenance,
716            ProvenanceWeights::default()
717        );
718    }
719
720    #[test]
721    fn graph_scoring_nested_under_graph() {
722        let cfg: Config = toml::from_str(
723            "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
724        )
725        .expect("parse nested scoring");
726        let scoring = cfg.graph.expect("graph section present").scoring;
727        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
728        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
729        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
730    }
731}