//! Search space intersection utilities for multivariate TPE sampling. //! //! This module provides utilities for computing the intersection of search spaces //! across completed trials, which is necessary for multivariate TPE sampling where //! we need to identify parameters that appear in all trials. //! //! It also provides utilities for decomposing the search space into independent //! groups based on parameter co-occurrence patterns, which enables more efficient //! multivariate modeling when parameters naturally partition into independent subsets. use std::collections::{HashMap, HashSet, VecDeque}; use crate::distribution::Distribution; use crate::parameter::ParamId; use crate::sampler::CompletedTrial; /// Computes the intersection of search spaces across completed trials. /// /// In multivariate TPE sampling, we need to model the joint distribution of parameters. /// However, trials may have different sets of parameters due to dynamic search spaces /// (e.g., conditional parameters). The intersection search space contains only those /// parameters that appear in ALL completed trials, allowing us to fit a joint KDE. /// /// # Example /// /// ```ignore /// use std::collections::HashMap; /// use optimizer::distribution::{Distribution, FloatDistribution}; /// use optimizer::param::ParamValue; /// use optimizer::sampler::CompletedTrial; /// use optimizer::sampler::tpe::IntersectionSearchSpace; /// /// // Create two trials with overlapping parameters /// let dist_x = Distribution::Float(FloatDistribution { /// low: 0.0, high: 1.0, log_scale: false, step: None, /// }); /// let dist_y = Distribution::Float(FloatDistribution { /// low: 0.0, high: 1.0, log_scale: false, step: None, /// }); /// /// let mut params1 = HashMap::new(); /// params1.insert("x".to_string(), ParamValue::Float(0.5)); /// params1.insert("y".to_string(), ParamValue::Float(0.3)); /// let mut dists1 = HashMap::new(); /// dists1.insert("x".to_string(), dist_x.clone()); /// dists1.insert("y".to_string(), dist_y.clone()); /// let trial1 = CompletedTrial::new(0, params1, dists1, 1.0); /// /// let mut params2 = HashMap::new(); /// params2.insert("x".to_string(), ParamValue::Float(0.7)); /// // Note: trial2 doesn't have "y" /// let mut dists2 = HashMap::new(); /// dists2.insert("x".to_string(), dist_x.clone()); /// let trial2 = CompletedTrial::new(1, params2, dists2, 0.5); /// /// let trials = vec![trial1, trial2]; /// let intersection = IntersectionSearchSpace::calculate(&trials); /// /// // Only "x" appears in both trials /// assert!(intersection.contains_key("x")); /// assert!(!intersection.contains_key("y")); /// ``` #[derive(Debug, Clone, Default)] pub struct IntersectionSearchSpace; impl IntersectionSearchSpace { /// Calculates the intersection of search spaces across all completed trials. /// /// This method identifies parameters that appear in ALL completed trials and returns /// a mapping from parameter names to their distributions. If any parameter has /// different distributions across trials (e.g., different bounds), the first /// encountered distribution is used. /// /// # Arguments /// /// * `trials` - A slice of completed trials to compute the intersection from. /// /// # Returns /// /// A `HashMap` mapping parameter names to their distributions, containing only /// parameters that appear in every trial. Returns an empty map if `trials` is empty. /// /// # Notes /// /// - Parameters must appear in ALL trials to be included in the intersection. /// - If a parameter has different distributions in different trials, the distribution /// from the first trial containing that parameter is used. /// - For dynamic search spaces where not all parameters are sampled in every trial, /// this helps identify the "stable" set of parameters that can be modeled jointly. #[must_use] pub fn calculate(trials: &[CompletedTrial]) -> HashMap { if trials.is_empty() { return HashMap::new(); } // Get parameter ids from the first trial as the initial candidate set let first_trial = &trials[0]; let mut candidate_params: HashSet = first_trial.distributions.keys().copied().collect(); // Intersect with parameter sets from all other trials for trial in trials.iter().skip(1) { let trial_params: HashSet = trial.distributions.keys().copied().collect(); candidate_params.retain(|param| trial_params.contains(param)); } // Build the result map using distributions from the first trial // that contains each parameter let mut result = HashMap::new(); for param_id in candidate_params { // Find the first trial that has this parameter and use its distribution for trial in trials { if let Some(dist) = trial.distributions.get(¶m_id) { result.insert(param_id, dist.clone()); break; } } } result } } /// Decomposes the search space into independent parameter groups based on co-occurrence. /// /// In multivariate TPE, we model the joint distribution of parameters. However, when /// certain parameters never appear together (e.g., they are in different branches of /// a conditional), modeling them jointly is wasteful and can hurt performance. /// /// `GroupDecomposedSearchSpace` analyzes trial history to identify groups of parameters /// that always appear together. By building a co-occurrence graph (where edges connect /// parameters that appear in the same trial) and finding its connected components, we /// can partition parameters into independent groups that can be sampled separately. /// /// # Example /// /// ```ignore /// use std::collections::HashMap; /// use optimizer::distribution::{Distribution, FloatDistribution}; /// use optimizer::param::ParamValue; /// use optimizer::sampler::CompletedTrial; /// use optimizer::sampler::tpe::GroupDecomposedSearchSpace; /// /// // Trials with two independent parameter groups: /// // Group 1: "x", "y" always appear together /// // Group 2: "a", "b" always appear together /// // But "x"/"y" never appear with "a"/"b" /// /// let dist = Distribution::Float(FloatDistribution { /// low: 0.0, high: 1.0, log_scale: false, step: None, /// }); /// /// let mut params1 = HashMap::new(); /// params1.insert("x".to_string(), ParamValue::Float(0.1)); /// params1.insert("y".to_string(), ParamValue::Float(0.2)); /// let mut dists1 = HashMap::new(); /// dists1.insert("x".to_string(), dist.clone()); /// dists1.insert("y".to_string(), dist.clone()); /// let trial1 = CompletedTrial::new(0, params1, dists1, 1.0); /// /// let mut params2 = HashMap::new(); /// params2.insert("a".to_string(), ParamValue::Float(0.3)); /// params2.insert("b".to_string(), ParamValue::Float(0.4)); /// let mut dists2 = HashMap::new(); /// dists2.insert("a".to_string(), dist.clone()); /// dists2.insert("b".to_string(), dist.clone()); /// let trial2 = CompletedTrial::new(1, params2, dists2, 0.5); /// /// let trials = vec![trial1, trial2]; /// let groups = GroupDecomposedSearchSpace::calculate(&trials); /// /// // Should produce two groups: {"x", "y"} and {"a", "b"} /// assert_eq!(groups.len(), 2); /// ``` #[derive(Debug, Clone, Default)] pub struct GroupDecomposedSearchSpace; impl GroupDecomposedSearchSpace { /// Calculates parameter groups based on co-occurrence in completed trials. /// /// This method builds a co-occurrence graph where: /// - Each parameter is a node /// - An edge exists between two parameters if they appear in the same trial /// /// Then finds connected components to partition parameters into independent groups. /// /// # Arguments /// /// * `trials` - A slice of completed trials to analyze. /// /// # Returns /// /// A `Vec>` where each `HashSet` represents an independent group /// of parameters that co-occur. Parameters within the same group have appeared /// together in at least one trial (directly or transitively). Parameters in /// different groups have never appeared in the same trial. /// /// Returns an empty vector if `trials` is empty. /// /// # Panics /// /// This function does not panic under normal circumstances. The internal /// `.expect()` calls are guarded by the algorithm's invariants. /// /// # Notes /// /// - Single-parameter groups are included in the output. /// - The order of groups in the output vector is not guaranteed. /// - This is useful for `MultivariateTpeSampler` when `group=true` to sample /// independent parameter groups separately. #[must_use] pub fn calculate(trials: &[CompletedTrial]) -> Vec> { if trials.is_empty() { return Vec::new(); } // Collect all unique parameter ids let mut all_params: HashSet = HashSet::new(); for trial in trials { for ¶m_id in trial.distributions.keys() { all_params.insert(param_id); } } if all_params.is_empty() { return Vec::new(); } // Build adjacency list for co-occurrence graph // Two parameters are adjacent if they appear in the same trial let mut adjacency: HashMap> = HashMap::new(); for ¶m in &all_params { adjacency.insert(param, HashSet::new()); } for trial in trials { let trial_params: Vec = trial.distributions.keys().copied().collect(); // Connect all pairs of parameters in this trial for (i, ¶m1) in trial_params.iter().enumerate() { for ¶m2 in trial_params.iter().skip(i + 1) { adjacency .get_mut(¶m1) .expect("param should exist in adjacency map") .insert(param2); adjacency .get_mut(¶m2) .expect("param should exist in adjacency map") .insert(param1); } } } // Find connected components using BFS let mut visited: HashSet = HashSet::new(); let mut groups: Vec> = Vec::new(); for ¶m in &all_params { if visited.contains(¶m) { continue; } // BFS to find all parameters in this component let mut component: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); queue.push_back(param); visited.insert(param); while let Some(current) = queue.pop_front() { component.insert(current); if let Some(neighbors) = adjacency.get(¤t) { for &neighbor in neighbors { if !visited.contains(&neighbor) { visited.insert(neighbor); queue.push_back(neighbor); } } } } groups.push(component); } groups } } #[cfg(test)] mod tests { use super::*; use crate::distribution::{CategoricalDistribution, FloatDistribution, IntDistribution}; use crate::param::ParamValue; use crate::parameter::ParamId; fn create_trial( id: u64, params: Vec<(ParamId, ParamValue, Distribution)>, value: f64, ) -> CompletedTrial { let mut param_map = HashMap::new(); let mut dist_map = HashMap::new(); for (param_id, pv, dist) in params { param_map.insert(param_id, pv); dist_map.insert(param_id, dist); } CompletedTrial::new(id, param_map, dist_map, HashMap::new(), value) } #[test] fn test_empty_trials() { let trials: Vec = vec![]; let result = IntersectionSearchSpace::calculate(&trials); assert!(result.is_empty()); } #[test] fn test_single_trial() { let x_id = ParamId::new(); let y_id = ParamId::new(); let dist_x = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_y = Distribution::Int(IntDistribution { low: 1, high: 10, log_scale: false, step: None, }); let trial = create_trial( 0, vec![ (x_id, ParamValue::Float(0.5), dist_x.clone()), (y_id, ParamValue::Int(5), dist_y.clone()), ], 1.0, ); let result = IntersectionSearchSpace::calculate(&[trial]); assert_eq!(result.len(), 2); assert!(result.contains_key(&x_id)); assert!(result.contains_key(&y_id)); assert_eq!(result.get(&x_id), Some(&dist_x)); assert_eq!(result.get(&y_id), Some(&dist_y)); } #[test] fn test_all_trials_same_params() { let x_id = ParamId::new(); let y_id = ParamId::new(); let dist_x = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_y = Distribution::Float(FloatDistribution { low: -1.0, high: 1.0, log_scale: false, step: None, }); let trials: Vec = (0..5) .map(|i| { #[allow(clippy::cast_precision_loss)] let val = i as f64 * 0.1; create_trial( i, vec![ (x_id, ParamValue::Float(val), dist_x.clone()), (y_id, ParamValue::Float(val - 0.5), dist_y.clone()), ], val * val, ) }) .collect(); let result = IntersectionSearchSpace::calculate(&trials); assert_eq!(result.len(), 2); assert!(result.contains_key(&x_id)); assert!(result.contains_key(&y_id)); } #[test] fn test_partial_overlap() { let x_id = ParamId::new(); let y_id = ParamId::new(); let z_id = ParamId::new(); let dist_x = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_y = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_z = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); // Trial 1: x, y let trial1 = create_trial( 0, vec![ (x_id, ParamValue::Float(0.5), dist_x.clone()), (y_id, ParamValue::Float(0.3), dist_y.clone()), ], 1.0, ); // Trial 2: x, z (no y) let trial2 = create_trial( 1, vec![ (x_id, ParamValue::Float(0.7), dist_x.clone()), (z_id, ParamValue::Float(0.2), dist_z.clone()), ], 0.5, ); // Trial 3: x, y, z (has all) let trial3 = create_trial( 2, vec![ (x_id, ParamValue::Float(0.6), dist_x.clone()), (y_id, ParamValue::Float(0.4), dist_y.clone()), (z_id, ParamValue::Float(0.1), dist_z.clone()), ], 0.8, ); let result = IntersectionSearchSpace::calculate(&[trial1, trial2, trial3]); // Only x appears in all three trials assert_eq!(result.len(), 1); assert!(result.contains_key(&x_id)); assert!(!result.contains_key(&y_id)); assert!(!result.contains_key(&z_id)); } #[test] fn test_no_common_params() { let x_id = ParamId::new(); let y_id = ParamId::new(); let dist_x = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_y = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); // Trial 1: only x let trial1 = create_trial(0, vec![(x_id, ParamValue::Float(0.5), dist_x.clone())], 1.0); // Trial 2: only y let trial2 = create_trial(1, vec![(y_id, ParamValue::Float(0.3), dist_y.clone())], 0.5); let result = IntersectionSearchSpace::calculate(&[trial1, trial2]); // No common parameters assert!(result.is_empty()); } #[test] fn test_mixed_distribution_types() { let lr_id = ParamId::new(); let n_layers_id = ParamId::new(); let optimizer_id = ParamId::new(); let dist_float = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_int = Distribution::Int(IntDistribution { low: 1, high: 100, log_scale: false, step: None, }); let dist_cat = Distribution::Categorical(CategoricalDistribution { n_choices: 3 }); let trial1 = create_trial( 0, vec![ (lr_id, ParamValue::Float(0.01), dist_float.clone()), (n_layers_id, ParamValue::Int(3), dist_int.clone()), (optimizer_id, ParamValue::Categorical(0), dist_cat.clone()), ], 1.0, ); let trial2 = create_trial( 1, vec![ (lr_id, ParamValue::Float(0.001), dist_float.clone()), (n_layers_id, ParamValue::Int(5), dist_int.clone()), (optimizer_id, ParamValue::Categorical(1), dist_cat.clone()), ], 0.8, ); let result = IntersectionSearchSpace::calculate(&[trial1, trial2]); assert_eq!(result.len(), 3); assert!(matches!(result.get(&lr_id), Some(Distribution::Float(_)))); assert!(matches!( result.get(&n_layers_id), Some(Distribution::Int(_)) )); assert!(matches!( result.get(&optimizer_id), Some(Distribution::Categorical(_)) )); } #[test] fn test_distribution_from_first_trial() { let x_id = ParamId::new(); // Test that when distributions differ, the first trial's distribution is used let dist_x_v1 = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_x_v2 = Distribution::Float(FloatDistribution { low: 0.0, high: 10.0, // Different upper bound log_scale: false, step: None, }); let trial1 = create_trial( 0, vec![(x_id, ParamValue::Float(0.5), dist_x_v1.clone())], 1.0, ); let trial2 = create_trial( 1, vec![(x_id, ParamValue::Float(5.0), dist_x_v2.clone())], 0.5, ); let result = IntersectionSearchSpace::calculate(&[trial1, trial2]); assert_eq!(result.len(), 1); // Should use the distribution from the first trial assert_eq!(result.get(&x_id), Some(&dist_x_v1)); } #[test] fn test_many_trials_with_conditional_params() { let lr_id = ParamId::new(); let use_dropout_id = ParamId::new(); let dropout_rate_id = ParamId::new(); // Simulate a scenario with conditional parameters let dist_lr = Distribution::Float(FloatDistribution { low: 1e-5, high: 1e-1, log_scale: true, step: None, }); let dist_dropout = Distribution::Categorical(CategoricalDistribution { n_choices: 2 }); let dist_dropout_rate = Distribution::Float(FloatDistribution { low: 0.0, high: 0.5, log_scale: false, step: None, }); // Trial 1: use_dropout=true, has dropout_rate let trial1 = create_trial( 0, vec![ (lr_id, ParamValue::Float(0.01), dist_lr.clone()), ( use_dropout_id, ParamValue::Categorical(1), dist_dropout.clone(), ), ( dropout_rate_id, ParamValue::Float(0.2), dist_dropout_rate.clone(), ), ], 1.0, ); // Trial 2: use_dropout=false, no dropout_rate let trial2 = create_trial( 1, vec![ (lr_id, ParamValue::Float(0.001), dist_lr.clone()), ( use_dropout_id, ParamValue::Categorical(0), dist_dropout.clone(), ), ], 0.8, ); // Trial 3: use_dropout=true, has dropout_rate let trial3 = create_trial( 2, vec![ (lr_id, ParamValue::Float(0.005), dist_lr.clone()), ( use_dropout_id, ParamValue::Categorical(1), dist_dropout.clone(), ), ( dropout_rate_id, ParamValue::Float(0.3), dist_dropout_rate.clone(), ), ], 0.9, ); let result = IntersectionSearchSpace::calculate(&[trial1, trial2, trial3]); // Only lr and use_dropout appear in all trials assert_eq!(result.len(), 2); assert!(result.contains_key(&lr_id)); assert!(result.contains_key(&use_dropout_id)); assert!(!result.contains_key(&dropout_rate_id)); // Not in trial2 } // ==================== GroupDecomposedSearchSpace Tests ==================== #[test] fn test_group_empty_trials() { let trials: Vec = vec![]; let groups = GroupDecomposedSearchSpace::calculate(&trials); assert!(groups.is_empty()); } #[test] fn test_group_single_trial_single_param() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let x_id = ParamId::new(); let trial = create_trial(0, vec![(x_id, ParamValue::Float(0.5), dist)], 1.0); let groups = GroupDecomposedSearchSpace::calculate(&[trial]); assert_eq!(groups.len(), 1); assert!(groups[0].contains(&x_id)); assert_eq!(groups[0].len(), 1); } #[test] fn test_group_single_trial_multiple_params() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let x_id = ParamId::new(); let y_id = ParamId::new(); let z_id = ParamId::new(); let trial = create_trial( 0, vec![ (x_id, ParamValue::Float(0.5), dist.clone()), (y_id, ParamValue::Float(0.3), dist.clone()), (z_id, ParamValue::Float(0.7), dist), ], 1.0, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial]); // All params appear together, so one group assert_eq!(groups.len(), 1); assert_eq!(groups[0].len(), 3); assert!(groups[0].contains(&x_id)); assert!(groups[0].contains(&y_id)); assert!(groups[0].contains(&z_id)); } #[test] fn test_group_two_independent_groups() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let x_id = ParamId::new(); let y_id = ParamId::new(); let a_id = ParamId::new(); let b_id = ParamId::new(); // Trial 1: x, y (group 1) let trial1 = create_trial( 0, vec![ (x_id, ParamValue::Float(0.1), dist.clone()), (y_id, ParamValue::Float(0.2), dist.clone()), ], 1.0, ); // Trial 2: a, b (group 2 - never appears with x or y) let trial2 = create_trial( 1, vec![ (a_id, ParamValue::Float(0.3), dist.clone()), (b_id, ParamValue::Float(0.4), dist), ], 0.5, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2]); // Should have 2 independent groups assert_eq!(groups.len(), 2); // Find which group has x/y and which has a/b let group_xy = groups.iter().find(|g| g.contains(&x_id)); let group_ab = groups.iter().find(|g| g.contains(&a_id)); assert!(group_xy.is_some()); assert!(group_ab.is_some()); let group_xy = group_xy.expect("group with x should exist"); let group_ab = group_ab.expect("group with a should exist"); assert_eq!(group_xy.len(), 2); assert!(group_xy.contains(&x_id)); assert!(group_xy.contains(&y_id)); assert_eq!(group_ab.len(), 2); assert!(group_ab.contains(&a_id)); assert!(group_ab.contains(&b_id)); } #[test] fn test_group_transitive_connection() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let x_id = ParamId::new(); let y_id = ParamId::new(); let z_id = ParamId::new(); // Trial 1: x, y let trial1 = create_trial( 0, vec![ (x_id, ParamValue::Float(0.1), dist.clone()), (y_id, ParamValue::Float(0.2), dist.clone()), ], 1.0, ); // Trial 2: y, z (y connects x and z transitively) let trial2 = create_trial( 1, vec![ (y_id, ParamValue::Float(0.3), dist.clone()), (z_id, ParamValue::Float(0.4), dist), ], 0.5, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2]); // All should be in one group due to transitive connection via y assert_eq!(groups.len(), 1); assert_eq!(groups[0].len(), 3); assert!(groups[0].contains(&x_id)); assert!(groups[0].contains(&y_id)); assert!(groups[0].contains(&z_id)); } #[test] fn test_group_chain_connection() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let a_id = ParamId::new(); let b_id = ParamId::new(); let c_id = ParamId::new(); let d_id = ParamId::new(); // Create a chain: a-b, b-c, c-d let trial1 = create_trial( 0, vec![ (a_id, ParamValue::Float(0.1), dist.clone()), (b_id, ParamValue::Float(0.2), dist.clone()), ], 1.0, ); let trial2 = create_trial( 1, vec![ (b_id, ParamValue::Float(0.3), dist.clone()), (c_id, ParamValue::Float(0.4), dist.clone()), ], 0.5, ); let trial3 = create_trial( 2, vec![ (c_id, ParamValue::Float(0.5), dist.clone()), (d_id, ParamValue::Float(0.6), dist), ], 0.3, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2, trial3]); // All should be connected in one group assert_eq!(groups.len(), 1); assert_eq!(groups[0].len(), 4); assert!(groups[0].contains(&a_id)); assert!(groups[0].contains(&b_id)); assert!(groups[0].contains(&c_id)); assert!(groups[0].contains(&d_id)); } #[test] fn test_group_multiple_isolated_params() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let x_id = ParamId::new(); let y_id = ParamId::new(); let z_id = ParamId::new(); // Each trial has exactly one parameter (all isolated) let trial1 = create_trial(0, vec![(x_id, ParamValue::Float(0.1), dist.clone())], 1.0); let trial2 = create_trial(1, vec![(y_id, ParamValue::Float(0.2), dist.clone())], 0.5); let trial3 = create_trial(2, vec![(z_id, ParamValue::Float(0.3), dist)], 0.3); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2, trial3]); // Each param should be its own group assert_eq!(groups.len(), 3); for group in &groups { assert_eq!(group.len(), 1); } // Verify all params are covered let all_params: HashSet = groups.iter().flatten().copied().collect(); assert!(all_params.contains(&x_id)); assert!(all_params.contains(&y_id)); assert!(all_params.contains(&z_id)); } #[test] fn test_group_complex_scenario() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let x_id = ParamId::new(); let y_id = ParamId::new(); let z_id = ParamId::new(); let a_id = ParamId::new(); let b_id = ParamId::new(); let w_id = ParamId::new(); // Group 1: x, y, z connected // Group 2: a, b connected // w is isolated let trial1 = create_trial( 0, vec![ (x_id, ParamValue::Float(0.1), dist.clone()), (y_id, ParamValue::Float(0.2), dist.clone()), ], 1.0, ); let trial2 = create_trial( 1, vec![ (y_id, ParamValue::Float(0.3), dist.clone()), (z_id, ParamValue::Float(0.4), dist.clone()), ], 0.5, ); let trial3 = create_trial( 2, vec![ (a_id, ParamValue::Float(0.5), dist.clone()), (b_id, ParamValue::Float(0.6), dist.clone()), ], 0.3, ); let trial4 = create_trial(3, vec![(w_id, ParamValue::Float(0.7), dist)], 0.2); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2, trial3, trial4]); // Should have 3 groups: {x,y,z}, {a,b}, {w} assert_eq!(groups.len(), 3); let group_xyz = groups.iter().find(|g| g.contains(&x_id)); let group_ab = groups.iter().find(|g| g.contains(&a_id)); let group_w = groups.iter().find(|g| g.contains(&w_id)); assert!(group_xyz.is_some()); assert!(group_ab.is_some()); assert!(group_w.is_some()); let group_xyz = group_xyz.expect("group with x should exist"); assert_eq!(group_xyz.len(), 3); assert!(group_xyz.contains(&x_id)); assert!(group_xyz.contains(&y_id)); assert!(group_xyz.contains(&z_id)); let group_ab = group_ab.expect("group with a should exist"); assert_eq!(group_ab.len(), 2); assert!(group_ab.contains(&a_id)); assert!(group_ab.contains(&b_id)); let group_w = group_w.expect("group with w should exist"); assert_eq!(group_w.len(), 1); assert!(group_w.contains(&w_id)); } #[test] fn test_group_all_params_same_trial() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let a_id = ParamId::new(); let b_id = ParamId::new(); let c_id = ParamId::new(); let d_id = ParamId::new(); // All params in single trial let trial = create_trial( 0, vec![ (a_id, ParamValue::Float(0.1), dist.clone()), (b_id, ParamValue::Float(0.2), dist.clone()), (c_id, ParamValue::Float(0.3), dist.clone()), (d_id, ParamValue::Float(0.4), dist), ], 1.0, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial]); // All in one group assert_eq!(groups.len(), 1); assert_eq!(groups[0].len(), 4); } #[test] fn test_group_with_mixed_distribution_types() { let lr_id = ParamId::new(); let n_layers_id = ParamId::new(); let optimizer_id = ParamId::new(); let dist_float = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let dist_int = Distribution::Int(IntDistribution { low: 1, high: 10, log_scale: false, step: None, }); let dist_cat = Distribution::Categorical(CategoricalDistribution { n_choices: 3 }); // Group 1: learning_rate (float), n_layers (int) // Group 2: optimizer (categorical) let trial1 = create_trial( 0, vec![ (lr_id, ParamValue::Float(0.01), dist_float.clone()), (n_layers_id, ParamValue::Int(3), dist_int.clone()), ], 1.0, ); let trial2 = create_trial( 1, vec![(optimizer_id, ParamValue::Categorical(1), dist_cat)], 0.5, ); let trial3 = create_trial( 2, vec![ (lr_id, ParamValue::Float(0.001), dist_float), (n_layers_id, ParamValue::Int(5), dist_int), ], 0.8, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2, trial3]); // Should have 2 groups: {learning_rate, n_layers} and {optimizer} assert_eq!(groups.len(), 2); let group_lr = groups.iter().find(|g| g.contains(&lr_id)); let group_opt = groups.iter().find(|g| g.contains(&optimizer_id)); assert!(group_lr.is_some()); assert!(group_opt.is_some()); let group_lr = group_lr.expect("group with learning_rate should exist"); assert_eq!(group_lr.len(), 2); assert!(group_lr.contains(&lr_id)); assert!(group_lr.contains(&n_layers_id)); let group_opt = group_opt.expect("group with optimizer should exist"); assert_eq!(group_opt.len(), 1); assert!(group_opt.contains(&optimizer_id)); } #[test] fn test_group_star_topology() { let dist = Distribution::Float(FloatDistribution { low: 0.0, high: 1.0, log_scale: false, step: None, }); let center_id = ParamId::new(); let a_id = ParamId::new(); let b_id = ParamId::new(); let c_id = ParamId::new(); // Star topology: center connects to all others let trial1 = create_trial( 0, vec![ (center_id, ParamValue::Float(0.1), dist.clone()), (a_id, ParamValue::Float(0.2), dist.clone()), ], 1.0, ); let trial2 = create_trial( 1, vec![ (center_id, ParamValue::Float(0.3), dist.clone()), (b_id, ParamValue::Float(0.4), dist.clone()), ], 0.5, ); let trial3 = create_trial( 2, vec![ (center_id, ParamValue::Float(0.5), dist.clone()), (c_id, ParamValue::Float(0.6), dist), ], 0.3, ); let groups = GroupDecomposedSearchSpace::calculate(&[trial1, trial2, trial3]); // All connected via center assert_eq!(groups.len(), 1); assert_eq!(groups[0].len(), 4); assert!(groups[0].contains(¢er_id)); assert!(groups[0].contains(&a_id)); assert!(groups[0].contains(&b_id)); assert!(groups[0].contains(&c_id)); } }