Skip to main content

somatize_core/
search.rs

1//! Search spaces for hyperparameter optimization.
2//!
3//! Defines [`SearchSpace`] (a collection of [`SearchDimension`]s) that
4//! samplers use to generate trial configurations. Dimensions can be
5//! float, int, categorical, or conditional.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fmt;
10
11/// Scale for continuous search ranges.
12///
13/// Governs how samplers interpolate between `low` and `high`: which
14/// values a grid places its points at and where random draws
15/// concentrate.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum Scale {
18    /// Uniform spacing across the range.
19    Linear,
20    /// Logarithmic spacing: samples concentrate near the low end.
21    /// The right choice for learning rates and regularization
22    /// strengths, where orders of magnitude matter more than
23    /// absolute differences.
24    Log,
25    /// Mirror image of [`Scale::Log`]: samples concentrate near the
26    /// *high* end. Useful for parameters like momentum or keep
27    /// probability, where the interesting region is close to the
28    /// upper bound.
29    ReverseLog,
30}
31
32/// A single searchable parameter dimension.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "dim_type")]
35#[non_exhaustive]
36pub enum SearchDimension {
37    /// Continuous range (f64)
38    Float {
39        /// Parameter name (prefixed with the filter label when spaces
40        /// are merged, e.g. `"SVM.C"`).
41        name: String,
42        /// Inclusive lower bound. Must be strictly less than `high`.
43        low: f64,
44        /// Inclusive upper bound.
45        high: f64,
46        /// How samplers interpolate between the bounds.
47        scale: Scale,
48        /// Value used when the dimension is not being searched;
49        /// `None` means the filter's own field default applies.
50        default: Option<f64>,
51    },
52
53    /// Integer range
54    Int {
55        /// Parameter name (prefixed with the filter label when spaces
56        /// are merged).
57        name: String,
58        /// Inclusive lower bound. Must be strictly less than `high`.
59        low: i64,
60        /// Inclusive upper bound.
61        high: i64,
62        /// How samplers interpolate between the bounds.
63        scale: Scale,
64    },
65
66    /// Discrete set of choices
67    Categorical {
68        /// Parameter name (prefixed with the filter label when spaces
69        /// are merged).
70        name: String,
71        /// The candidate values, as JSON so strings, numbers, and
72        /// booleans can mix. Must not be empty.
73        choices: Vec<serde_json::Value>,
74    },
75
76    /// Active only when parent parameter has specific values
77    Conditional {
78        /// Parameter name (prefixed with the filter label when spaces
79        /// are merged).
80        name: String,
81        /// Name of the parameter this dimension depends on. Prefixed
82        /// alongside `name` in [`SearchSpace::merge_with_prefix`], so
83        /// the link survives merging.
84        parent: String,
85        /// Parent values that activate this dimension; for any other
86        /// parent value the dimension is skipped.
87        parent_values: Vec<serde_json::Value>,
88        /// The dimension to sample when active.
89        dimension: Box<SearchDimension>,
90    },
91}
92
93impl SearchDimension {
94    /// The parameter name, whichever variant this is. This is the key
95    /// trial params are stored under and the handle [`SearchSpace::freeze`]
96    /// matches on.
97    pub fn name(&self) -> &str {
98        match self {
99            Self::Float { name, .. }
100            | Self::Int { name, .. }
101            | Self::Categorical { name, .. }
102            | Self::Conditional { name, .. } => name,
103        }
104    }
105
106    /// Validate the dimension configuration: ranges must satisfy
107    /// `low < high`, categorical `choices` must not be empty, and a
108    /// conditional is as valid as its inner dimension. The `Err`
109    /// message names the offending dimension.
110    pub fn validate(&self) -> Result<(), String> {
111        match self {
112            Self::Float {
113                low, high, name, ..
114            } => {
115                if low >= high {
116                    return Err(format!(
117                        "{name}: `low` ({low}) must be less than `high` ({high})"
118                    ));
119                }
120                Ok(())
121            }
122            Self::Int {
123                low, high, name, ..
124            } => {
125                if low >= high {
126                    return Err(format!(
127                        "{name}: `low` ({low}) must be less than `high` ({high})"
128                    ));
129                }
130                Ok(())
131            }
132            Self::Categorical { choices, name } => {
133                if choices.is_empty() {
134                    return Err(format!("{name}: `choices` must not be empty"));
135                }
136                Ok(())
137            }
138            Self::Conditional { dimension, .. } => dimension.validate(),
139        }
140    }
141}
142
143impl fmt::Display for SearchDimension {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::Float {
147                name,
148                low,
149                high,
150                scale,
151                ..
152            } => write!(f, "{name}: Float[{low}, {high}] {scale:?}"),
153            Self::Int {
154                name, low, high, ..
155            } => write!(f, "{name}: Int[{low}, {high}]"),
156            Self::Categorical { name, choices } => {
157                let labels: Vec<String> = choices.iter().map(|c| c.to_string()).collect();
158                write!(f, "{name}: Categorical[{}]", labels.join(", "))
159            }
160            Self::Conditional {
161                name,
162                parent,
163                dimension,
164                ..
165            } => write!(f, "{name}: Conditional(if {parent}) -> {dimension}"),
166        }
167    }
168}
169
170/// Aggregation of search dimensions from one or more filters.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct SearchSpace {
173    /// The dimensions samplers draw from. [`SearchSpace::freeze`]
174    /// removes a dimension from this list, so everything here is
175    /// actively searched.
176    pub dimensions: Vec<SearchDimension>,
177    /// Parameters pinned to a fixed value by [`SearchSpace::freeze`],
178    /// keyed by dimension name. The `StudyRunner` injects these into
179    /// every trial's params so a frozen parameter still reaches the
180    /// filter (and its cache key).
181    pub frozen: HashMap<String, serde_json::Value>,
182}
183
184impl SearchSpace {
185    /// An empty search space: no dimensions, nothing frozen.
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Append a dimension. No name-collision check happens here; use
191    /// [`SearchSpace::merge_with_prefix`] when combining spaces from
192    /// several filters.
193    pub fn add(&mut self, dim: SearchDimension) {
194        self.dimensions.push(dim);
195    }
196
197    /// Merge another search space with a prefix to avoid name collisions.
198    ///
199    /// Every dimension name in `other` becomes `"{prefix}.{name}"`
200    /// (conditional dimensions get their `parent` prefixed too, so the
201    /// dependency still resolves). This is how a graph aggregates each
202    /// filter's [`Searchable::search_space`] into one space keyed by
203    /// node label.
204    pub fn merge_with_prefix(&mut self, prefix: &str, other: SearchSpace) {
205        for dim in other.dimensions {
206            let prefixed = prefix_dimension(prefix, dim);
207            self.dimensions.push(prefixed);
208        }
209    }
210
211    /// Freeze a parameter to a fixed value (exclude from search).
212    ///
213    /// Removes the dimension named `name` from [`SearchSpace::dimensions`]
214    /// and records the value in [`SearchSpace::frozen`]; the value is
215    /// then injected into every trial's params by the runner. Freezing
216    /// a name with no matching dimension just records the value.
217    pub fn freeze(&mut self, name: &str, value: serde_json::Value) {
218        self.frozen.insert(name.to_string(), value);
219        self.dimensions.retain(|d| d.name() != name);
220    }
221
222    /// The dimensions still being searched. Since [`SearchSpace::freeze`]
223    /// removes frozen dimensions from the list, this is every entry of
224    /// [`SearchSpace::dimensions`] — the method exists so callers state
225    /// their intent rather than reach into the field.
226    pub fn active_dimensions(&self) -> &[SearchDimension] {
227        &self.dimensions
228    }
229
230    /// Validate all dimensions, collecting every failure rather than
231    /// stopping at the first — a study definition should surface all
232    /// its range errors in one pass. `Ok(())` when every dimension
233    /// passes [`SearchDimension::validate`].
234    pub fn validate(&self) -> Result<(), Vec<String>> {
235        let errors: Vec<String> = self
236            .dimensions
237            .iter()
238            .filter_map(|d| d.validate().err())
239            .collect();
240        if errors.is_empty() {
241            Ok(())
242        } else {
243            Err(errors)
244        }
245    }
246
247    /// `true` when there is nothing left to search. Frozen values do
248    /// not count: a space can be empty yet still carry frozen params.
249    pub fn is_empty(&self) -> bool {
250        self.dimensions.is_empty()
251    }
252
253    /// Number of searchable dimensions (frozen params excluded).
254    pub fn len(&self) -> usize {
255        self.dimensions.len()
256    }
257}
258
259impl fmt::Display for SearchSpace {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        for dim in &self.dimensions {
262            writeln!(f, "  {dim}")?;
263        }
264        if !self.frozen.is_empty() {
265            writeln!(f, "  Frozen:")?;
266            for (name, val) in &self.frozen {
267                writeln!(f, "    {name} = {val}")?;
268            }
269        }
270        Ok(())
271    }
272}
273
274/// Prefix all dimension names with a filter label.
275fn prefix_dimension(prefix: &str, dim: SearchDimension) -> SearchDimension {
276    match dim {
277        SearchDimension::Float {
278            name,
279            low,
280            high,
281            scale,
282            default,
283        } => SearchDimension::Float {
284            name: format!("{prefix}.{name}"),
285            low,
286            high,
287            scale,
288            default,
289        },
290        SearchDimension::Int {
291            name,
292            low,
293            high,
294            scale,
295        } => SearchDimension::Int {
296            name: format!("{prefix}.{name}"),
297            low,
298            high,
299            scale,
300        },
301        SearchDimension::Categorical { name, choices } => SearchDimension::Categorical {
302            name: format!("{prefix}.{name}"),
303            choices,
304        },
305        SearchDimension::Conditional {
306            name,
307            parent,
308            parent_values,
309            dimension,
310        } => SearchDimension::Conditional {
311            name: format!("{prefix}.{name}"),
312            parent: format!("{prefix}.{parent}"),
313            parent_values,
314            dimension: Box::new(prefix_dimension(prefix, *dimension)),
315        },
316    }
317}
318
319/// Trait for filters that declare their search space.
320/// Auto-generated by `#[derive(Filter)]`.
321pub trait Searchable {
322    /// The dimensions this filter exposes for search, one per field
323    /// annotated with a `#[soma(search(...))]` range. Names are the
324    /// bare field names — a graph namespaces them per node via
325    /// [`SearchSpace::merge_with_prefix`].
326    fn search_space() -> SearchSpace;
327    /// Build an instance from a sampled configuration — how a trial's
328    /// sampled point becomes a runnable filter. Params are keyed by
329    /// bare field name; in the derived impl a missing or
330    /// wrongly-typed key falls back to the type's `Default`, never an
331    /// error (the `Result` is for hand-written impls with real
332    /// construction failures).
333    fn from_sample(params: &HashMap<String, serde_json::Value>) -> crate::error::Result<Self>
334    where
335        Self: Sized;
336    /// The instance's current searchable field values, keyed by bare
337    /// field name — the inverse of [`Searchable::from_sample`], used
338    /// to record what configuration actually ran.
339    fn current_params(&self) -> HashMap<String, serde_json::Value>;
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use serde_json::json;
346
347    #[test]
348    fn float_dimension_display() {
349        let dim = SearchDimension::Float {
350            name: "lr".into(),
351            low: 0.001,
352            high: 0.1,
353            scale: Scale::Log,
354            default: None,
355        };
356        assert_eq!(dim.to_string(), "lr: Float[0.001, 0.1] Log");
357    }
358
359    #[test]
360    fn categorical_dimension_display() {
361        let dim = SearchDimension::Categorical {
362            name: "kernel".into(),
363            choices: vec![json!("linear"), json!("rbf")],
364        };
365        assert_eq!(dim.to_string(), "kernel: Categorical[\"linear\", \"rbf\"]");
366    }
367
368    #[test]
369    fn validate_rejects_inverted_range() {
370        let dim = SearchDimension::Float {
371            name: "lr".into(),
372            low: 1.0,
373            high: 0.1,
374            scale: Scale::Linear,
375            default: None,
376        };
377        assert!(dim.validate().is_err());
378    }
379
380    #[test]
381    fn validate_rejects_empty_choices() {
382        let dim = SearchDimension::Categorical {
383            name: "kernel".into(),
384            choices: vec![],
385        };
386        assert!(dim.validate().is_err());
387    }
388
389    #[test]
390    fn validate_accepts_valid_dimensions() {
391        let float = SearchDimension::Float {
392            name: "lr".into(),
393            low: 0.001,
394            high: 0.1,
395            scale: Scale::Log,
396            default: None,
397        };
398        let int = SearchDimension::Int {
399            name: "epochs".into(),
400            low: 10,
401            high: 100,
402            scale: Scale::Linear,
403        };
404        assert!(float.validate().is_ok());
405        assert!(int.validate().is_ok());
406    }
407
408    #[test]
409    fn search_space_merge_with_prefix() {
410        let mut space1 = SearchSpace::new();
411        space1.add(SearchDimension::Float {
412            name: "scale".into(),
413            low: 0.1,
414            high: 10.0,
415            scale: Scale::Log,
416            default: None,
417        });
418
419        let mut space2 = SearchSpace::new();
420        space2.add(SearchDimension::Float {
421            name: "C".into(),
422            low: 0.01,
423            high: 100.0,
424            scale: Scale::Log,
425            default: None,
426        });
427
428        let mut combined = SearchSpace::new();
429        combined.merge_with_prefix("Scaler", space1);
430        combined.merge_with_prefix("SVM", space2);
431
432        assert_eq!(combined.len(), 2);
433        assert_eq!(combined.dimensions[0].name(), "Scaler.scale");
434        assert_eq!(combined.dimensions[1].name(), "SVM.C");
435    }
436
437    #[test]
438    fn search_space_freeze() {
439        let mut space = SearchSpace::new();
440        space.add(SearchDimension::Float {
441            name: "lr".into(),
442            low: 0.001,
443            high: 0.1,
444            scale: Scale::Log,
445            default: None,
446        });
447        space.add(SearchDimension::Categorical {
448            name: "kernel".into(),
449            choices: vec![json!("rbf"), json!("linear")],
450        });
451
452        assert_eq!(space.len(), 2);
453        space.freeze("kernel", json!("rbf"));
454        assert_eq!(space.len(), 1);
455        assert_eq!(space.dimensions[0].name(), "lr");
456        assert_eq!(space.frozen["kernel"], json!("rbf"));
457    }
458
459    #[test]
460    fn search_space_validate() {
461        let mut space = SearchSpace::new();
462        space.add(SearchDimension::Float {
463            name: "good".into(),
464            low: 0.0,
465            high: 1.0,
466            scale: Scale::Linear,
467            default: None,
468        });
469        assert!(space.validate().is_ok());
470
471        space.add(SearchDimension::Float {
472            name: "bad".into(),
473            low: 10.0,
474            high: 1.0,
475            scale: Scale::Linear,
476            default: None,
477        });
478        assert!(space.validate().is_err());
479    }
480
481    #[test]
482    fn search_space_serde_roundtrip() {
483        let mut space = SearchSpace::new();
484        space.add(SearchDimension::Float {
485            name: "lr".into(),
486            low: 0.001,
487            high: 0.1,
488            scale: Scale::Log,
489            default: Some(0.01),
490        });
491        space.add(SearchDimension::Int {
492            name: "epochs".into(),
493            low: 10,
494            high: 100,
495            scale: Scale::Linear,
496        });
497        space.add(SearchDimension::Categorical {
498            name: "kernel".into(),
499            choices: vec![json!("rbf"), json!("linear")],
500        });
501
502        let json = serde_json::to_string(&space).unwrap();
503        let deserialized: SearchSpace = serde_json::from_str(&json).unwrap();
504        assert_eq!(deserialized.len(), 3);
505    }
506
507    #[test]
508    fn conditional_dimension() {
509        let dim = SearchDimension::Conditional {
510            name: "momentum".into(),
511            parent: "optimizer".into(),
512            parent_values: vec![json!("sgd")],
513            dimension: Box::new(SearchDimension::Float {
514                name: "momentum".into(),
515                low: 0.0,
516                high: 0.99,
517                scale: Scale::Linear,
518                default: None,
519            }),
520        };
521        assert!(dim.validate().is_ok());
522    }
523}