Skip to main content

khive_runtime/
engine_config_peers.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use super::ConfigError;
6use crate::config::{parse_embedding_model_alias, sanitize_key};
7
8/// One ordered peer. Its canonical name is also its existing provider/index identity.
9#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
10#[serde(deny_unknown_fields)]
11pub struct EngineConfig {
12    pub name: String,
13    #[serde(default = "unit_weight")]
14    pub weight: f64,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub dims: Option<i64>,
17}
18
19impl EngineConfig {
20    pub(crate) fn check_dimensions(&self, actual: usize) -> Result<(), ConfigError> {
21        if let Some(expected) = self.dims {
22            if usize::try_from(expected).ok() != Some(actual) {
23                return Err(ConfigError::EngineDimensionMismatch {
24                    name: self.name.clone(),
25                    expected,
26                    actual,
27                });
28            }
29        }
30        Ok(())
31    }
32}
33
34fn unit_weight() -> f64 {
35    1.0
36}
37
38pub(crate) fn canonical_engine_name(name: &str) -> String {
39    parse_embedding_model_alias(name)
40        .map(|model| model.to_string())
41        .unwrap_or_else(|| name.to_owned())
42}
43
44pub(crate) fn validate_peer_engines(engines: &[EngineConfig]) -> Result<(), ConfigError> {
45    let mut names = HashSet::new();
46    let mut canonical_names = HashSet::new();
47    let mut keys = HashMap::new();
48    for engine in engines {
49        if engine.name.trim().is_empty() {
50            return Err(ConfigError::InvalidEngineName {
51                name: engine.name.clone(),
52            });
53        }
54        if !names.insert(&engine.name) {
55            return Err(ConfigError::DuplicateName {
56                name: engine.name.clone(),
57            });
58        }
59        let canonical = canonical_engine_name(&engine.name);
60        if !canonical_names.insert(canonical.clone()) {
61            return Err(ConfigError::AliasCollision {
62                name: engine.name.clone(),
63                canonical,
64            });
65        }
66        let key = sanitize_key(&canonical);
67        if let Some(other) = keys.insert(key.clone(), canonical.clone()) {
68            return Err(ConfigError::EngineKeyCollision {
69                name: canonical,
70                other,
71                key,
72            });
73        }
74        if !engine.weight.is_finite() || engine.weight <= 0.0 {
75            return Err(ConfigError::InvalidEngineWeight {
76                name: engine.name.clone(),
77                value: engine.weight,
78            });
79        }
80        if let Some(value) = engine.dims {
81            if value <= 0 || value > i64::from(u32::MAX) {
82                return Err(ConfigError::InvalidEngineDimensions {
83                    name: engine.name.clone(),
84                    value,
85                });
86            }
87        }
88    }
89    if let Some(engine) = engines.iter().find(|engine| engine.weight != 1.0) {
90        return Err(ConfigError::UnsupportedEngineWeight {
91            name: engine.name.clone(),
92        });
93    }
94    Ok(())
95}
96
97#[derive(Deserialize)]
98#[serde(deny_unknown_fields)]
99struct LegacyEngine {
100    name: String,
101    model: String,
102    #[serde(default)]
103    default: bool,
104    fusion_weight: Option<f64>,
105    dims: Option<i64>,
106}
107
108/// Convert only legacy input. Canonical deserialization remains a closed schema.
109pub(super) fn normalize_engine_input(
110    document: &mut toml::Value,
111    path: &std::path::Path,
112) -> Result<bool, ConfigError> {
113    let Some(value) = document.get_mut("engines") else {
114        return Ok(false);
115    };
116    let Some(entries) = value.as_array_mut() else {
117        // Preserve the typed deserializer's ordinary type diagnostic.
118        return Ok(true);
119    };
120    let legacy = entries.iter().any(|entry| {
121        ["model", "default", "fusion_weight"]
122            .iter()
123            .any(|key| entry.get(key).is_some())
124    });
125    if !legacy {
126        return Ok(true);
127    }
128    let mut parsed = Vec::with_capacity(entries.len());
129    let mut labels = HashSet::new();
130    let mut providers = HashSet::new();
131    for entry in entries.iter() {
132        if entry.get("weight").is_some() || entry.get("model").is_none() {
133            return Err(ConfigError::EngineKeyConflict {
134                name: entry
135                    .get("name")
136                    .and_then(toml::Value::as_str)
137                    .unwrap_or("<missing>")
138                    .to_owned(),
139            });
140        }
141        let old: LegacyEngine = entry
142            .clone()
143            .try_into()
144            .map_err(|source| ConfigError::Parse {
145                path: path.to_path_buf(),
146                source,
147            })?;
148        if !labels.insert(old.name.clone()) {
149            return Err(ConfigError::DuplicateName { name: old.name });
150        }
151        let model =
152            parse_embedding_model_alias(&old.model).ok_or_else(|| ConfigError::UnknownModel {
153                name: old.name.clone(),
154                model: old.model.clone(),
155            })?;
156        let name = model.to_string();
157        if !providers.insert(name.clone()) {
158            return Err(ConfigError::AliasCollision {
159                name: old.name,
160                canonical: name,
161            });
162        }
163        parsed.push((
164            old.default,
165            EngineConfig {
166                name,
167                weight: old.fusion_weight.unwrap_or(1.0),
168                dims: old.dims,
169            },
170        ));
171    }
172    let found = parsed.iter().filter(|(default, _)| *default).count();
173    if found != 1 {
174        return Err(ConfigError::DefaultCount { found });
175    }
176    let first = parsed
177        .iter()
178        .position(|(default, _)| *default)
179        .expect("one default");
180    let primary = parsed.remove(first);
181    parsed.insert(0, primary);
182    *entries = parsed
183        .into_iter()
184        .map(|(_, engine)| {
185            let mut table = toml::map::Map::new();
186            table.insert("name".into(), toml::Value::String(engine.name));
187            table.insert("weight".into(), toml::Value::Float(engine.weight));
188            if let Some(dims) = engine.dims {
189                table.insert("dims".into(), toml::Value::Integer(dims));
190            }
191            toml::Value::Table(table)
192        })
193        .collect();
194    tracing::warn!("legacy engine configuration converted to ordered peers; migrate model/default/fusion_weight to name/weight");
195    Ok(true)
196}