Skip to main content

qualia_core_db/inference/lab/
config_space.rs

1//! Typed configuration space for the optimization lab.
2//!
3//! Each optimization strategy exposes tunable parameters. The lab defines these
4//! as a `ConfigurationSpace` (analogous to SMAC3's `ConfigurationSpace`).
5//! Configurations are serialized to CBOR and hashed with FNV-1a for deduplication.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11/// One parameter definition in the configuration space.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub enum ParameterDef {
14    /// Integer in `[lo, hi]` (inclusive).
15    Int { lo: i64, hi: i64 },
16    /// Float in `[lo, hi]`.
17    Float { lo: f64, hi: f64 },
18    /// Boolean on/off.
19    Bool,
20    /// Categorical choice from a fixed set of strings.
21    Categorical { choices: Vec<String> },
22}
23
24impl ParameterDef {
25    /// Clamp a raw value into this parameter's valid range.
26    pub fn clamp(&self, raw: &ParameterValue) -> ParameterValue {
27        match (self, raw) {
28            (ParameterDef::Int { lo, hi }, ParameterValue::Int(v)) => {
29                ParameterValue::Int((*v).clamp(*lo, *hi))
30            }
31            (ParameterDef::Float { lo, hi }, ParameterValue::Float(v)) => {
32                ParameterValue::Float(v.clamp(*lo, *hi))
33            }
34            (ParameterDef::Bool, ParameterValue::Bool(v)) => ParameterValue::Bool(*v),
35            (ParameterDef::Categorical { choices }, ParameterValue::String(v)) => {
36                if choices.iter().any(|c| c == v) {
37                    ParameterValue::String(v.clone())
38                } else {
39                    ParameterValue::String(choices.first().cloned().unwrap_or_default())
40                }
41            }
42            _ => raw.clone(),
43        }
44    }
45
46    /// Check if a value is valid for this parameter.
47    pub fn is_valid(&self, val: &ParameterValue) -> bool {
48        match (self, val) {
49            (ParameterDef::Int { lo, hi }, ParameterValue::Int(v)) => v >= lo && v <= hi,
50            (ParameterDef::Float { lo, hi }, ParameterValue::Float(v)) => v >= lo && v <= hi,
51            (ParameterDef::Bool, ParameterValue::Bool(_)) => true,
52            (ParameterDef::Categorical { choices }, ParameterValue::String(v)) => {
53                choices.iter().any(|c| c == v)
54            }
55            _ => false,
56        }
57    }
58}
59
60/// A concrete parameter value.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub enum ParameterValue {
63    Int(i64),
64    Float(f64),
65    Bool(bool),
66    String(String),
67}
68
69impl ParameterValue {
70    /// Convert to a normalized `[0, 1]` float for Sobol / search purposes.
71    pub fn normalize(&self, def: &ParameterDef) -> f64 {
72        match (def, self) {
73            (ParameterDef::Int { lo, hi }, ParameterValue::Int(v)) => {
74                if hi == lo {
75                    0.5
76                } else {
77                    (*v as f64 - *lo as f64) / (*hi as f64 - *lo as f64)
78                }
79            }
80            (ParameterDef::Float { lo, hi }, ParameterValue::Float(v)) => {
81                if hi == lo {
82                    0.5
83                } else {
84                    (v - lo) / (hi - lo)
85                }
86            }
87            (ParameterDef::Bool, ParameterValue::Bool(v)) => {
88                if *v {
89                    1.0
90                } else {
91                    0.0
92                }
93            }
94            (ParameterDef::Categorical { choices }, ParameterValue::String(v)) => {
95                let idx = choices.iter().position(|c| c == v).unwrap_or(0);
96                if choices.len() <= 1 {
97                    0.5
98                } else {
99                    idx as f64 / (choices.len() - 1) as f64
100                }
101            }
102            _ => 0.5,
103        }
104    }
105
106    /// Denormalize a `[0, 1]` float back to a concrete value.
107    pub fn denormalize(t: f64, def: &ParameterDef) -> ParameterValue {
108        let t = t.clamp(0.0, 1.0);
109        match def {
110            ParameterDef::Int { lo, hi } => {
111                let v = lo + ((t * (*hi - *lo) as f64).round() as i64);
112                ParameterValue::Int(v.clamp(*lo, *hi))
113            }
114            ParameterDef::Float { lo, hi } => ParameterValue::Float(lo + t * (hi - lo)),
115            ParameterDef::Bool => ParameterValue::Bool(t >= 0.5),
116            ParameterDef::Categorical { choices } => {
117                if choices.is_empty() {
118                    return ParameterValue::String(String::new());
119                }
120                let idx = if choices.len() == 1 {
121                    0
122                } else {
123                    ((t * (choices.len() - 1) as f64).round() as usize).min(choices.len() - 1)
124                };
125                ParameterValue::String(choices[idx].clone())
126            }
127        }
128    }
129}
130
131/// A configuration space: named parameters with definitions.
132#[derive(Debug, Clone, Serialize, Deserialize, Default)]
133pub struct ConfigurationSpace {
134    pub name: String,
135    pub params: BTreeMap<String, ParameterDef>,
136}
137
138impl ConfigurationSpace {
139    pub fn new(name: impl Into<String>) -> Self {
140        Self {
141            name: name.into(),
142            params: BTreeMap::new(),
143        }
144    }
145
146    pub fn with(mut self, name: impl Into<String>, def: ParameterDef) -> Self {
147        self.params.insert(name.into(), def);
148        self
149    }
150
151    /// Number of dimensions in the search space.
152    pub fn dims(&self) -> usize {
153        self.params.len()
154    }
155
156    /// Build a default `Configuration` with all parameters at their lower bound
157    /// (Int: lo, Float: lo, Bool: false, Categorical: first choice).
158    pub fn default_config(&self) -> Configuration {
159        let values: BTreeMap<String, ParameterValue> = self
160            .params
161            .iter()
162            .map(|(name, def)| {
163                let val = match def {
164                    ParameterDef::Int { lo, .. } => ParameterValue::Int(*lo),
165                    ParameterDef::Float { lo, .. } => ParameterValue::Float(*lo),
166                    ParameterDef::Bool => ParameterValue::Bool(false),
167                    ParameterDef::Categorical { choices } => {
168                        ParameterValue::String(choices.first().cloned().unwrap_or_default())
169                    }
170                };
171                (name.clone(), val)
172            })
173            .collect();
174        Configuration {
175            space_name: self.name.clone(),
176            values,
177        }
178    }
179
180    /// Build a `Configuration` from a map of values, clamping each to its def.
181    pub fn build_config(&self, values: BTreeMap<String, ParameterValue>) -> Configuration {
182        let clamped = values
183            .iter()
184            .map(|(k, v)| {
185                let cv = self
186                    .params
187                    .get(k)
188                    .map(|d| d.clamp(v))
189                    .unwrap_or_else(|| v.clone());
190                (k.clone(), cv)
191            })
192            .collect();
193        Configuration {
194            space_name: self.name.clone(),
195            values: clamped,
196        }
197    }
198
199    /// Build a `Configuration` from a normalized `[0,1]^d` vector (Sobol output).
200    pub fn build_from_normalized(&self, t: &[f64]) -> Configuration {
201        let mut values = BTreeMap::new();
202        for (i, (name, def)) in self.params.iter().enumerate() {
203            let t_i = t.get(i).copied().unwrap_or(0.5);
204            values.insert(name.clone(), ParameterValue::denormalize(t_i, def));
205        }
206        Configuration {
207            space_name: self.name.clone(),
208            values,
209        }
210    }
211
212    /// Normalize a configuration back to `[0,1]^d`.
213    pub fn normalize_config(&self, cfg: &Configuration) -> Vec<f64> {
214        self.params
215            .iter()
216            .map(|(name, def)| {
217                cfg.values
218                    .get(name)
219                    .map(|v| v.normalize(def))
220                    .unwrap_or(0.5)
221            })
222            .collect()
223    }
224}
225
226/// A concrete configuration: one point in the space.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct Configuration {
229    pub space_name: String,
230    pub values: BTreeMap<String, ParameterValue>,
231}
232
233impl Configuration {
234    /// Serialize to CBOR bytes.
235    pub fn to_cbor(&self) -> Vec<u8> {
236        let mut buf = Vec::new();
237        let _ = ciborium::into_writer(self, &mut buf);
238        buf
239    }
240
241    /// Deserialize from CBOR bytes.
242    pub fn from_cbor(data: &[u8]) -> Result<Self, String> {
243        ciborium::from_reader(data).map_err(|e| e.to_string())
244    }
245
246    /// FNV-1a hash of the CBOR serialization (dedup key).
247    pub fn hash(&self) -> u64 {
248        let data = self.to_cbor();
249        let mut h: u64 = 0xcbf29ce484222325;
250        for &b in &data {
251            h ^= b as u64;
252            h = h.wrapping_mul(0x100000001b3);
253        }
254        h
255    }
256
257    /// Get a parameter value.
258    pub fn get(&self, name: &str) -> Option<&ParameterValue> {
259        self.values.get(name)
260    }
261
262    /// Get an integer parameter.
263    pub fn get_int(&self, name: &str) -> Option<i64> {
264        match self.values.get(name) {
265            Some(ParameterValue::Int(v)) => Some(*v),
266            _ => None,
267        }
268    }
269
270    /// Get a float parameter.
271    pub fn get_float(&self, name: &str) -> Option<f64> {
272        match self.values.get(name) {
273            Some(ParameterValue::Float(v)) => Some(*v),
274            _ => None,
275        }
276    }
277
278    /// Get a bool parameter.
279    pub fn get_bool(&self, name: &str) -> Option<bool> {
280        match self.values.get(name) {
281            Some(ParameterValue::Bool(v)) => Some(*v),
282            _ => None,
283        }
284    }
285
286    /// Get a string parameter.
287    pub fn get_string(&self, name: &str) -> Option<&str> {
288        match self.values.get(name) {
289            Some(ParameterValue::String(v)) => Some(v),
290            _ => None,
291        }
292    }
293
294    /// JSON representation for logging.
295    pub fn to_json(&self) -> String {
296        serde_json::to_string(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn config_space_roundtrip() {
306        let space = ConfigurationSpace::new("test")
307            .with("ngram", ParameterDef::Int { lo: 1, hi: 3 })
308            .with("bias", ParameterDef::Float { lo: 0.5, hi: 5.0 })
309            .with("enabled", ParameterDef::Bool)
310            .with(
311                "mode",
312                ParameterDef::Categorical {
313                    choices: vec!["fast".into(), "slow".into()],
314                },
315            );
316        assert_eq!(space.dims(), 4);
317
318        // BTreeMap iterates in alphabetical key order: bias, enabled, mode, ngram.
319        let cfg = space.build_from_normalized(&[0.5, 1.0, 0.0, 0.0]);
320        // bias: t=0.5 → 0.5 + 0.5*(5.0-0.5) = 2.75
321        assert!((cfg.get_float("bias").unwrap() - 2.75).abs() < 0.01);
322        // enabled: t=1.0 → true
323        assert_eq!(cfg.get_bool("enabled"), Some(true));
324        // mode: t=0.0 → "fast" (first choice)
325        assert_eq!(cfg.get_string("mode"), Some("fast"));
326        // ngram: t=0.0 → lo=1
327        assert_eq!(cfg.get_int("ngram"), Some(1));
328    }
329
330    #[test]
331    fn config_hash_dedup() {
332        let space = ConfigurationSpace::new("test").with("x", ParameterDef::Int { lo: 0, hi: 10 });
333        let a = space.build_from_normalized(&[0.5]);
334        let b = space.build_from_normalized(&[0.5]);
335        let c = space.build_from_normalized(&[0.0]);
336        assert_eq!(a.hash(), b.hash());
337        assert_ne!(a.hash(), c.hash());
338    }
339
340    #[test]
341    fn config_cbor_roundtrip() {
342        let space = ConfigurationSpace::new("test")
343            .with("x", ParameterDef::Int { lo: 0, hi: 10 })
344            .with("y", ParameterDef::Bool);
345        let cfg = space.build_from_normalized(&[0.3, 0.6]);
346        let cbor = cfg.to_cbor();
347        let back = Configuration::from_cbor(&cbor).unwrap();
348        assert_eq!(back.get_int("x"), cfg.get_int("x"));
349        assert_eq!(back.get_bool("y"), cfg.get_bool("y"));
350    }
351
352    #[test]
353    fn clamp_works() {
354        let def = ParameterDef::Int { lo: 1, hi: 5 };
355        assert_eq!(def.clamp(&ParameterValue::Int(10)), ParameterValue::Int(5));
356        assert_eq!(def.clamp(&ParameterValue::Int(0)), ParameterValue::Int(1));
357    }
358}