Skip to main content

cgn_core/
config.rs

1//! Cognitora configuration.
2//!
3//! A single TOML document describes every binary. Each daemon reads only the
4//! sections it needs; unknown keys are tolerated for forward compat.
5//!
6//! Lookup order:
7//!   1. Path passed on the command line.
8//!   2. `$CGN_CONFIG`.
9//!   3. `/etc/cognitora/cognitora.toml`.
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::Result;
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Config {
22    pub cluster: ClusterConfig,
23    pub router: RouterConfig,
24    pub agent: AgentConfig,
25    pub engine: EngineConfig,
26    pub kv: KvConfig,
27    pub security: SecurityConfig,
28    pub metrics: MetricsConfig,
29    pub auth: AuthConfig,
30    pub models: HashMap<String, ModelConfig>,
31}
32
33// ---------------------------------------------------------------------------
34// Cluster
35// ---------------------------------------------------------------------------
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct ClusterConfig {
40    pub name: String,
41    pub state_backend: StateBackend,
42    pub etcd_endpoints: Vec<String>,
43    pub gossip_seeds: Vec<String>,
44}
45impl Default for ClusterConfig {
46    fn default() -> Self {
47        Self {
48            name: "cognitora".into(),
49            state_backend: StateBackend::Etcd,
50            etcd_endpoints: vec![],
51            gossip_seeds: vec![],
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum StateBackend {
59    Etcd,
60    Gossip,
61}
62
63// ---------------------------------------------------------------------------
64// Router (incorporates the OpenAI HTTP gateway)
65// ---------------------------------------------------------------------------
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct RouterConfig {
70    /// OpenAI-compatible HTTP/SSE listener.
71    pub listen_http: String,
72    /// gRPC admin/control surface.
73    pub listen_grpc: String,
74    /// Plain-HTTP admin (Prometheus scrape, pprof, /healthz).
75    pub listen_admin: String,
76    pub node_id: String,
77    pub score_weights: ScoreWeights,
78    pub admission: AdmissionConfig,
79    pub rate_limit: RateLimitConfig,
80    pub cascade: CascadeConfig,
81    pub disagg: DisaggConfig,
82    pub federation: FederationConfig,
83    pub autoscaler: AutoscalerConfig,
84}
85impl Default for RouterConfig {
86    fn default() -> Self {
87        Self {
88            listen_http: format!("0.0.0.0:{}", crate::ports::ROUTER_HTTP),
89            listen_grpc: format!("0.0.0.0:{}", crate::ports::ROUTER_GRPC),
90            listen_admin: format!("0.0.0.0:{}", crate::ports::ROUTER_ADMIN),
91            node_id: default_node_id("router"),
92            score_weights: ScoreWeights::default(),
93            admission: AdmissionConfig::default(),
94            rate_limit: RateLimitConfig::default(),
95            cascade: CascadeConfig::default(),
96            disagg: DisaggConfig::default(),
97            federation: FederationConfig::default(),
98            autoscaler: AutoscalerConfig::default(),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(default)]
105pub struct ScoreWeights {
106    pub kv: f32,
107    pub load: f32,
108    pub power: f32,
109    pub capacity: f32,
110}
111impl Default for ScoreWeights {
112    fn default() -> Self {
113        Self {
114            kv: 0.55,
115            load: 0.25,
116            power: 0.10,
117            capacity: 0.10,
118        }
119    }
120}
121impl ScoreWeights {
122    /// Validate that weights sum to 1.0 (within tolerance).
123    pub fn validate(&self) -> Result<()> {
124        let sum = self.kv + self.load + self.power + self.capacity;
125        if (sum - 1.0).abs() > 0.01 {
126            return Err(crate::Error::Config(format!(
127                "router.score_weights must sum to 1.0 (got {sum:.3})"
128            )));
129        }
130        Ok(())
131    }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(default)]
136pub struct AdmissionConfig {
137    pub max_queue: u32,
138    #[serde(with = "humantime_serde")]
139    pub ttft_slo: Duration,
140    pub max_concurrent_per_replica: u32,
141}
142impl Default for AdmissionConfig {
143    fn default() -> Self {
144        Self {
145            max_queue: 1024,
146            ttft_slo: Duration::from_millis(800),
147            max_concurrent_per_replica: 16,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(default)]
154pub struct RateLimitConfig {
155    pub rps: u32,
156    pub burst: u32,
157    pub redis_url: Option<String>,
158}
159impl Default for RateLimitConfig {
160    fn default() -> Self {
161        Self {
162            rps: 50,
163            burst: 200,
164            redis_url: None,
165        }
166    }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(default)]
171pub struct CascadeConfig {
172    /// Enable model cascade (SLM -> mid -> LLM).
173    pub enabled: bool,
174    /// Confidence threshold (logprob avg) below which to escalate.
175    pub confidence_threshold: f32,
176}
177impl Default for CascadeConfig {
178    fn default() -> Self {
179        Self {
180            enabled: false,
181            confidence_threshold: -1.5,
182        }
183    }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(default)]
188pub struct DisaggConfig {
189    /// Enable prefill/decode disaggregation.
190    pub enabled: bool,
191    /// Prompt-length threshold under which prefill is colocated.
192    pub colocate_below_tokens: u32,
193}
194impl Default for DisaggConfig {
195    fn default() -> Self {
196        Self {
197            enabled: false,
198            colocate_below_tokens: 256,
199        }
200    }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(default)]
205#[derive(Default)]
206pub struct FederationConfig {
207    /// Forward to peer Cognitora clusters when no local node serves a model.
208    pub enabled: bool,
209    /// Peer router gRPC endpoints (mTLS).
210    pub peers: Vec<String>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(default)]
215pub struct AutoscalerConfig {
216    /// Energy-aware autoscaler. Watches `cgn-metrics` and drains the
217    /// highest-watt nodes when the cluster is idle.
218    pub enabled: bool,
219    /// Idle threshold: drain a node whose 5m util is below this %.
220    pub idle_util_pct: f32,
221    /// Wattage above this threshold makes a node a drain candidate.
222    pub high_watt_threshold: f32,
223    /// Per-tenant deadline propagation (rejects requests whose
224    /// deadline cannot be met given the current queue).
225    pub deadline_admission: bool,
226}
227impl Default for AutoscalerConfig {
228    fn default() -> Self {
229        Self {
230            enabled: false,
231            idle_util_pct: 15.0,
232            high_watt_threshold: 350.0,
233            deadline_admission: false,
234        }
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Agent
240// ---------------------------------------------------------------------------
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(default)]
244pub struct AgentConfig {
245    pub listen: String,
246    pub role: NodeRoleCfg,
247    pub node_id: String,
248    pub kv_uds: PathBuf,
249    pub gpu_index: Option<u32>,
250
251    // Legacy aliases for the engine block. If `[engine]` is unset we fall
252    // back to these fields so older configs keep working.
253    #[serde(default)]
254    pub vllm_url: Option<String>,
255    #[serde(default)]
256    pub vllm_cmd: Option<Vec<String>>,
257}
258impl Default for AgentConfig {
259    fn default() -> Self {
260        Self {
261            listen: format!("0.0.0.0:{}", crate::ports::AGENT_GRPC),
262            role: NodeRoleCfg::Both,
263            node_id: default_node_id("agent"),
264            kv_uds: PathBuf::from("/run/cognitora/kv.sock"),
265            gpu_index: None,
266            vllm_url: None,
267            vllm_cmd: None,
268        }
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Engine (vLLM, llama.cpp, or any OpenAI-compatible HTTP server)
274// ---------------------------------------------------------------------------
275
276/// Inference engine driver.
277///
278/// Cognitora's `cgn-agent` proxies to an OpenAI-compatible HTTP server. This
279/// block describes which engine to spawn and how. Four kinds are supported:
280///
281/// * `vllm` — the agent spawns `vllm serve <model> ...` (GPU).
282/// * `sglang` — the agent spawns `python -m sglang.launch_server ...` (GPU).
283///   SGLang offers RadixAttention prefix caching and structured-output
284///   acceleration; from the router's perspective it speaks the same OpenAI
285///   surface as vLLM and is fully interchangeable.
286/// * `llama_cpp` — the agent spawns `python -m llama_cpp.server` or a
287///   standalone `llama-server` binary (CPU or GPU offload).
288/// * `openai_compat` — the agent does not spawn anything; it just proxies
289///   to `engine.url`. Use this when the engine is managed by
290///   systemd / Kubernetes / a sidecar.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(default)]
293pub struct EngineConfig {
294    pub kind: EngineKind,
295    /// HTTP base URL where the engine exposes the OpenAI surface.
296    pub url: String,
297    /// Engine-side KV offload / cross-worker connector. Picks the
298    /// `--kv-transfer-config` JSON for vLLM and the
299    /// `--enable-hierarchical-cache` flag set for SGLang. See [`KvOffload`].
300    pub kv_offload: KvOffload,
301    /// vLLM-specific knobs (used when `kind = "vllm"`).
302    pub vllm: VllmEngineConfig,
303    /// SGLang-specific knobs (used when `kind = "sglang"`).
304    pub sglang: SglangEngineConfig,
305    /// llama.cpp-specific knobs (used when `kind = "llama_cpp"`).
306    pub llama_cpp: LlamaCppEngineConfig,
307}
308impl Default for EngineConfig {
309    fn default() -> Self {
310        Self {
311            kind: EngineKind::Vllm,
312            url: format!("http://127.0.0.1:{}", crate::ports::VLLM_HTTP),
313            kv_offload: KvOffload::None,
314            vllm: VllmEngineConfig::default(),
315            sglang: SglangEngineConfig::default(),
316            llama_cpp: LlamaCppEngineConfig::default(),
317        }
318    }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum EngineKind {
324    Vllm,
325    Sglang,
326    LlamaCpp,
327    OpenaiCompat,
328}
329
330/// Engine-side KV connector / offload backend.
331///
332/// Selects which `--kv-transfer-config` (vLLM) or hierarchical-cache flag
333/// set (SGLang) the agent injects when spawning the engine. The router
334/// is unaware of this dial — it only sees prefix-overlap signals via
335/// `cgn-kvcached` either way.
336///
337/// Compatibility:
338///
339/// | Engine        | `none` | `nixl` | `lmcache` | `hicache` | `kvbm` |
340/// |---------------|--------|--------|-----------|-----------|--------|
341/// | `vllm`        | yes    | yes    | yes       | no        | yes    |
342/// | `sglang`      | yes    | yes    | no        | yes       | no     |
343/// | `llama_cpp`   | yes    | no     | no        | no        | no     |
344/// | `openai_compat` | yes  | no     | no        | no        | no     |
345///
346/// In disaggregated topologies (`[agent].role = "prefill"` or `"decode"`)
347/// the renderer automatically composes the offload backend with NIXL so
348/// blocks produced on the prefill GPU stream to the decode GPU. See
349/// `cgn-agent::engine::spawn` for the full table.
350#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "snake_case")]
352pub enum KvOffload {
353    /// No engine-side connector. Default. Use this for dev loops or
354    /// when KV state is purely engine-internal.
355    #[default]
356    None,
357    /// NIXL only — sufficient for prefill→decode handoff in disagg
358    /// without any extra offload backend.
359    Nixl,
360    /// LMCache (`LMCacheConnectorV1`). Adds CPU/SSD/Redis/Mooncake KV
361    /// reuse on top of vLLM. In disagg, automatically wraps with a
362    /// `PdConnector(LMCache + NIXL)` MultiConnector.
363    Lmcache,
364    /// SGLang Hierarchical Cache (`--enable-hierarchical-cache`).
365    /// SGLang-only. Stacks on top of RadixAttention.
366    Hicache,
367    /// NVIDIA Dynamo KVBM (`DynamoConnector` from
368    /// `kvbm.vllm_integration.connector`). Requires the `kvbm` Python
369    /// package on the engine host.
370    Kvbm,
371}
372
373impl KvOffload {
374    /// Lower-case canonical name (matches the TOML serialization).
375    pub fn as_str(self) -> &'static str {
376        match self {
377            Self::None => "none",
378            Self::Nixl => "nixl",
379            Self::Lmcache => "lmcache",
380            Self::Hicache => "hicache",
381            Self::Kvbm => "kvbm",
382        }
383    }
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
387#[serde(default)]
388pub struct VllmEngineConfig {
389    /// Path or PATH-name of the `vllm` CLI. Default: `vllm`.
390    pub binary: String,
391    /// Arguments appended after the auto-rendered `serve <model> --tp <N>
392    /// --max-model-len <M>` flags.
393    pub extra_args: Vec<String>,
394}
395impl Default for VllmEngineConfig {
396    fn default() -> Self {
397        Self {
398            binary: "vllm".into(),
399            extra_args: vec!["--enable-chunked-prefill".into()],
400        }
401    }
402}
403
404/// SGLang launch knobs. SGLang is invoked as `python -m sglang.launch_server`
405/// and exposes an OpenAI-compatible HTTP surface on `host:port`. `engine.url`
406/// must point at this surface.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(default)]
409pub struct SglangEngineConfig {
410    /// Path or PATH-name of the python interpreter (or the `sglang` CLI).
411    /// Default: `python`.
412    pub binary: String,
413    /// Host the launcher binds to. Mapped to `--host`.
414    pub host: String,
415    /// Port the launcher binds to. Mapped to `--port`.
416    pub port: u16,
417    /// Default context window when [models.\*].max_model_len is unset.
418    /// Mapped to `--context-length`.
419    pub context_length: u32,
420    /// Mem fraction for SGLang's RadixAttention KV pool. Mapped to
421    /// `--mem-fraction-static`. Defaults to `0.85`.
422    pub mem_fraction_static: f32,
423    /// Arguments appended after the auto-rendered base flags.
424    pub extra_args: Vec<String>,
425}
426impl Default for SglangEngineConfig {
427    fn default() -> Self {
428        Self {
429            binary: "python".into(),
430            host: "127.0.0.1".into(),
431            port: crate::ports::VLLM_HTTP,
432            context_length: 4096,
433            mem_fraction_static: 0.85,
434            extra_args: vec![],
435        }
436    }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
440#[serde(default)]
441pub struct LlamaCppEngineConfig {
442    /// Path or PATH-name of the python interpreter (mode = "python_server")
443    /// or the standalone server binary (mode = "binary"). Default: `python`.
444    pub binary: String,
445    /// "python_server" → invoked as `<binary> -m llama_cpp.server …`.
446    /// "binary"        → invoked as `<binary> --model … --host … --port …`.
447    pub mode: LlamaCppMode,
448    pub host: String,
449    pub port: u16,
450    /// Context window. Mapped to `--n_ctx`.
451    pub n_ctx: u32,
452    /// CPU thread count. Mapped to `--n_threads`.
453    pub n_threads: u32,
454    /// GPU layer offload count. -1 = "offload everything to GPU", 0 = "CPU
455    /// only". Mapped to `--n_gpu_layers`.
456    pub n_gpu_layers: i32,
457    /// Arguments appended after the auto-rendered base flags.
458    pub extra_args: Vec<String>,
459}
460impl Default for LlamaCppEngineConfig {
461    fn default() -> Self {
462        Self {
463            binary: "python".into(),
464            mode: LlamaCppMode::PythonServer,
465            host: "127.0.0.1".into(),
466            port: crate::ports::VLLM_HTTP,
467            n_ctx: 4096,
468            n_threads: 4,
469            n_gpu_layers: 0,
470            extra_args: vec![],
471        }
472    }
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
476#[serde(rename_all = "snake_case")]
477pub enum LlamaCppMode {
478    PythonServer,
479    Binary,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "lowercase")]
484pub enum NodeRoleCfg {
485    Decode,
486    Prefill,
487    Both,
488}
489
490// ---------------------------------------------------------------------------
491// KV cache
492// ---------------------------------------------------------------------------
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
495#[serde(default)]
496pub struct KvConfig {
497    pub listen: String,
498    pub uds: PathBuf,
499    pub ram_gib: u32,
500    pub ssd_dir: PathBuf,
501    pub ssd_gib: u32,
502    pub index_dir: PathBuf,
503    pub transport: KvTransport,
504    pub quic_listen: String,
505    pub block_size_tokens: u32,
506}
507impl Default for KvConfig {
508    fn default() -> Self {
509        Self {
510            listen: format!("0.0.0.0:{}", crate::ports::KV_GRPC),
511            uds: PathBuf::from("/run/cognitora/kv.sock"),
512            ram_gib: 32,
513            ssd_dir: PathBuf::from("/var/lib/cognitora/kv"),
514            ssd_gib: 1024,
515            index_dir: PathBuf::from("/var/lib/cognitora/index"),
516            transport: KvTransport::Quic,
517            quic_listen: format!("0.0.0.0:{}", crate::ports::KV_QUIC),
518            block_size_tokens: 16,
519        }
520    }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "lowercase")]
525pub enum KvTransport {
526    Quic,
527    Rdma,
528}
529
530// ---------------------------------------------------------------------------
531// Security / TLS
532// ---------------------------------------------------------------------------
533
534#[derive(Debug, Clone, Default, Serialize, Deserialize)]
535#[serde(default)]
536pub struct SecurityConfig {
537    pub ca_file: Option<PathBuf>,
538    pub cert_file: Option<PathBuf>,
539    pub key_file: Option<PathBuf>,
540    pub require_mtls: bool,
541}
542
543// ---------------------------------------------------------------------------
544// Auth
545// ---------------------------------------------------------------------------
546
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
548#[serde(default)]
549pub struct AuthConfig {
550    /// Enable authentication on the OpenAI surface. Off by default to ease
551    /// localhost development; turn on in production.
552    pub enabled: bool,
553    pub oidc_issuer: Option<String>,
554    pub oidc_audience: Option<String>,
555    pub api_keys_file: Option<PathBuf>,
556}
557
558// ---------------------------------------------------------------------------
559// Metrics
560// ---------------------------------------------------------------------------
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563#[serde(default)]
564pub struct MetricsConfig {
565    pub listen: String,
566    pub redfish_url: Option<String>,
567    pub redfish_user: Option<String>,
568    pub redfish_password: Option<String>,
569    pub ipmi_fallback: bool,
570    #[serde(with = "humantime_serde")]
571    pub scrape_interval: Duration,
572}
573impl Default for MetricsConfig {
574    fn default() -> Self {
575        Self {
576            listen: format!("0.0.0.0:{}", crate::ports::METRICS_HTTP),
577            redfish_url: None,
578            redfish_user: None,
579            redfish_password: None,
580            ipmi_fallback: false,
581            scrape_interval: Duration::from_secs(15),
582        }
583    }
584}
585
586// ---------------------------------------------------------------------------
587// Models
588// ---------------------------------------------------------------------------
589
590#[derive(Debug, Clone, Serialize, Deserialize)]
591#[serde(default)]
592pub struct ModelConfig {
593    pub cascade: Vec<String>,
594    pub prefill_replicas: u32,
595    pub decode_replicas: u32,
596    pub tp: u32,
597    pub max_model_len: Option<u32>,
598    pub extra_args: Vec<String>,
599    /// Filesystem path to the model weights. Required for `engine.kind =
600    /// "llama_cpp"` (a `.gguf` file). Optional for `vllm` (which resolves
601    /// the model name as a HuggingFace repo id).
602    pub path: Option<PathBuf>,
603}
604impl Default for ModelConfig {
605    fn default() -> Self {
606        Self {
607            cascade: vec![],
608            prefill_replicas: 1,
609            decode_replicas: 2,
610            tp: 1,
611            max_model_len: None,
612            extra_args: vec![],
613            path: None,
614        }
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Loading
620// ---------------------------------------------------------------------------
621
622impl Config {
623    /// Load from a path. Missing file yields a `Config::default()`.
624    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
625        let path = path.as_ref();
626        if !path.exists() {
627            tracing::warn!(path = %path.display(), "config file not found, using defaults");
628            return Ok(Self::default());
629        }
630        let data = std::fs::read_to_string(path)?;
631        let cfg: Self = toml::from_str(&data).map_err(|e| crate::Error::Config(e.to_string()))?;
632        cfg.validate()?;
633        Ok(cfg)
634    }
635
636    /// Resolve the config path according to the documented lookup order.
637    pub fn locate(arg: Option<&Path>) -> PathBuf {
638        if let Some(p) = arg {
639            return p.to_path_buf();
640        }
641        if let Ok(env) = std::env::var("CGN_CONFIG") {
642            return PathBuf::from(env);
643        }
644        PathBuf::from(crate::DEFAULT_CONFIG_PATH)
645    }
646
647    fn validate(&self) -> Result<()> {
648        self.router.score_weights.validate()?;
649        Ok(())
650    }
651}
652
653fn default_node_id(role: &str) -> String {
654    format!(
655        "{}-{}-{}",
656        hostname_or(role),
657        role,
658        &uuid::Uuid::new_v4().simple().to_string()[..8]
659    )
660}
661
662fn hostname_or(default: &str) -> String {
663    if let Ok(h) = std::env::var("HOSTNAME") {
664        if !h.is_empty() {
665            return h;
666        }
667    }
668    if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
669        let trimmed = s.trim();
670        if !trimmed.is_empty() {
671            return trimmed.to_string();
672        }
673    }
674    default.to_string()
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use tempfile::TempDir;
681
682    #[test]
683    fn missing_file_yields_default() {
684        let cfg = Config::load("/no/such/path/cognitora.toml").unwrap();
685        assert_eq!(cfg.cluster.name, "cognitora");
686        assert_eq!(cfg.router.score_weights.kv, 0.55);
687    }
688
689    #[test]
690    fn parses_minimal_toml() {
691        let dir = TempDir::new().unwrap();
692        let p = dir.path().join("cognitora.toml");
693        std::fs::write(
694            &p,
695            r#"
696[cluster]
697name = "prod-eu"
698
699[router.score_weights]
700kv = 0.6
701load = 0.2
702power = 0.1
703capacity = 0.1
704        "#,
705        )
706        .unwrap();
707        let cfg = Config::load(&p).unwrap();
708        assert_eq!(cfg.cluster.name, "prod-eu");
709        assert!((cfg.router.score_weights.kv - 0.6).abs() < 1e-6);
710    }
711
712    #[test]
713    fn weights_must_sum_to_one() {
714        let dir = TempDir::new().unwrap();
715        let p = dir.path().join("c.toml");
716        std::fs::write(
717            &p,
718            r#"
719[router.score_weights]
720kv = 0.9
721load = 0.2
722power = 0.1
723capacity = 0.1
724        "#,
725        )
726        .unwrap();
727        assert!(Config::load(&p).is_err());
728    }
729}