1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fmt;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum Scale {
18 Linear,
20 Log,
25 ReverseLog,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "dim_type")]
35#[non_exhaustive]
36pub enum SearchDimension {
37 Float {
39 name: String,
42 low: f64,
44 high: f64,
46 scale: Scale,
48 default: Option<f64>,
51 },
52
53 Int {
55 name: String,
58 low: i64,
60 high: i64,
62 scale: Scale,
64 },
65
66 Categorical {
68 name: String,
71 choices: Vec<serde_json::Value>,
74 },
75
76 Conditional {
78 name: String,
81 parent: String,
85 parent_values: Vec<serde_json::Value>,
88 dimension: Box<SearchDimension>,
90 },
91}
92
93impl SearchDimension {
94 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct SearchSpace {
173 pub dimensions: Vec<SearchDimension>,
177 pub frozen: HashMap<String, serde_json::Value>,
182}
183
184impl SearchSpace {
185 pub fn new() -> Self {
187 Self::default()
188 }
189
190 pub fn add(&mut self, dim: SearchDimension) {
194 self.dimensions.push(dim);
195 }
196
197 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 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 pub fn active_dimensions(&self) -> &[SearchDimension] {
227 &self.dimensions
228 }
229
230 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 pub fn is_empty(&self) -> bool {
250 self.dimensions.is_empty()
251 }
252
253 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
274fn 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
319pub trait Searchable {
322 fn search_space() -> SearchSpace;
327 fn from_sample(params: &HashMap<String, serde_json::Value>) -> crate::error::Result<Self>
334 where
335 Self: Sized;
336 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}