Files
rust-optimizer/src/sampler/grid.rs
T
Manuel Raimann 1cd18c16f9 fix: address review findings in grid sampler, parallel optimization, and CSV export
- Rename stale "GridSearchSampler" panic message to "GridSampler"
- Assert concurrency > 0 in optimize_parallel to prevent deadlock
- Fix inaccurate comment in CSV export (empty for all non-complete trials, not just pruned)
2026-02-12 17:57:29 +01:00

1190 lines
36 KiB
Rust

//! Grid search sampler — exhaustive evaluation of discretized parameter spaces.
//!
//! [`GridSampler`] divides each parameter range into a fixed number of
//! evenly spaced points (or uses the explicit step size when defined) and
//! evaluates them sequentially. This guarantees complete coverage of the
//! search grid at the cost of scaling exponentially with the number of
//! parameters.
//!
//! # When to use
//!
//! - **Small, discrete spaces** — when you have a handful of categorical or
//! integer parameters and want to evaluate every combination.
//! - **Reproducibility** — grid search is fully deterministic with no random
//! component.
//! - **Benchmarking** — compare grid search results against adaptive samplers
//! to measure their benefit.
//!
//! Avoid grid search for high-dimensional or large continuous spaces;
//! prefer [`TpeSampler`](super::tpe::TpeSampler) or
//! [`RandomSampler`](super::random::RandomSampler) instead.
//!
//! # Configuration
//!
//! | Option | Default | Description |
//! |---|---|---|
//! | `n_points_per_param` | 10 | Points per continuous parameter (ignored when `step` is set) |
//!
//! # Example
//!
//! ```
//! use optimizer::prelude::*;
//! use optimizer::sampler::grid::GridSampler;
//!
//! let sampler = GridSampler::builder().n_points_per_param(5).build();
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
//! ```
use std::collections::HashMap;
use parking_lot::Mutex;
use crate::distribution::{
CategoricalDistribution, Distribution, FloatDistribution, IntDistribution,
};
use crate::param::ParamValue;
use crate::sampler::{CompletedTrial, Sampler};
/// Generates grid points for an integer distribution.
///
/// # Behavior
///
/// - If `step` is `Some(s)`: generates points at `low, low+s, low+2*s, ...` up to `high`.
/// - If `step` is `None` and `log_scale` is `false`: generates `n_points` evenly spaced
/// integers from `low` to `high`.
/// - If `step` is `None` and `log_scale` is `true`: generates `n_points` evenly spaced
/// in log space, rounded to integers.
///
/// All grid points are clamped to `[low, high]` bounds and deduplicated.
///
/// # Arguments
///
/// * `dist` - The integer distribution defining bounds, step, and log scale.
/// * `n_points` - Number of points to generate when auto-discretizing (ignored if step is set).
///
/// # Returns
///
/// A vector of unique grid points in ascending order.
#[must_use]
pub fn generate_int_grid_points(dist: &IntDistribution, n_points: usize) -> Vec<i64> {
let low = dist.low;
let high = dist.high;
if low > high {
return vec![];
}
if low == high {
return vec![low];
}
let points: Vec<i64> = if let Some(step) = dist.step {
// Generate points at low, low+step, low+2*step, ... up to high
if step <= 0 {
return vec![low];
}
let mut result = Vec::new();
let mut current = low;
while current <= high {
result.push(current);
current = current.saturating_add(step);
// Prevent infinite loop if saturating_add doesn't change the value
if result.last() == Some(&current) {
break;
}
}
result
} else if dist.log_scale {
// Generate n_points evenly spaced in log space, rounded to integers
// For log scale, low must be positive
if low <= 0 {
// Fall back to linear for non-positive values
generate_linear_int_points(low, high, n_points)
} else {
generate_log_int_points(low, high, n_points)
}
} else {
// Generate n_points evenly spaced integers from low to high
generate_linear_int_points(low, high, n_points)
};
// Clamp all points to [low, high] and deduplicate
let mut clamped: Vec<i64> = points.into_iter().map(|p| p.clamp(low, high)).collect();
// Remove duplicates while preserving order
clamped.sort_unstable();
clamped.dedup();
clamped
}
/// Generates evenly spaced integers from low to high (linear scale).
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn generate_linear_int_points(low: i64, high: i64, n_points: usize) -> Vec<i64> {
if n_points == 0 {
return vec![];
}
if n_points == 1 {
return vec![low];
}
let range = high - low;
let mut result = Vec::with_capacity(n_points);
for i in 0..n_points {
// Calculate position as a fraction of the range
let fraction = i as f64 / (n_points - 1) as f64;
let value = low as f64 + fraction * range as f64;
result.push(value.round() as i64);
}
result
}
/// Generates evenly spaced integers in log space.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn generate_log_int_points(low: i64, high: i64, n_points: usize) -> Vec<i64> {
debug_assert!(low > 0, "log scale requires positive low bound");
if n_points == 0 {
return vec![];
}
if n_points == 1 {
return vec![low];
}
let log_low = (low as f64).ln();
let log_high = (high as f64).ln();
let mut result = Vec::with_capacity(n_points);
for i in 0..n_points {
let fraction = i as f64 / (n_points - 1) as f64;
let log_value = log_low + fraction * (log_high - log_low);
let value = log_value.exp().round() as i64;
result.push(value);
}
result
}
/// Generates grid points for a float distribution.
///
/// # Behavior
///
/// - If `step` is `Some(s)`: generates points at `low, low+s, low+2*s, ...` up to `high`.
/// Step size overrides `log_scale` behavior.
/// - If `step` is `None` and `log_scale` is `false`: generates `n_points` evenly spaced
/// floats from `low` to `high`.
/// - If `step` is `None` and `log_scale` is `true`: generates `n_points` evenly spaced
/// in log space.
///
/// All grid points are within `[low, high]` bounds.
///
/// # Arguments
///
/// * `dist` - The float distribution defining bounds, step, and log scale.
/// * `n_points` - Number of points to generate when auto-discretizing (ignored if step is set).
///
/// # Returns
///
/// A vector of grid points in ascending order.
#[must_use]
pub fn generate_float_grid_points(dist: &FloatDistribution, n_points: usize) -> Vec<f64> {
let low = dist.low;
let high = dist.high;
if low > high {
return vec![];
}
if (low - high).abs() < f64::EPSILON {
return vec![low];
}
let points: Vec<f64> = if let Some(step) = dist.step {
// Step overrides log_scale - generate points at low, low+step, low+2*step, ... up to high
if step <= 0.0 {
return vec![low];
}
let mut result = Vec::new();
let mut current = low;
while current <= high + f64::EPSILON {
result.push(current.clamp(low, high));
current += step;
// Prevent infinite loop from floating point issues
if result.len() > 1_000_000 {
break;
}
}
result
} else if dist.log_scale {
// Generate n_points evenly spaced in log space
// For log scale, low must be positive
if low <= 0.0 {
// Fall back to linear for non-positive values
generate_linear_float_points(low, high, n_points)
} else {
generate_log_float_points(low, high, n_points)
}
} else {
// Generate n_points evenly spaced floats from low to high
generate_linear_float_points(low, high, n_points)
};
// Clamp all points to [low, high] bounds
points.into_iter().map(|p| p.clamp(low, high)).collect()
}
/// Generates evenly spaced floats from low to high (linear scale).
#[allow(clippy::cast_precision_loss)]
fn generate_linear_float_points(low: f64, high: f64, n_points: usize) -> Vec<f64> {
if n_points == 0 {
return vec![];
}
if n_points == 1 {
return vec![low];
}
let range = high - low;
let mut result = Vec::with_capacity(n_points);
for i in 0..n_points {
let fraction = i as f64 / (n_points - 1) as f64;
let value = low + fraction * range;
result.push(value);
}
result
}
/// Generates evenly spaced floats in log space.
#[allow(clippy::cast_precision_loss)]
fn generate_log_float_points(low: f64, high: f64, n_points: usize) -> Vec<f64> {
debug_assert!(low > 0.0, "log scale requires positive low bound");
if n_points == 0 {
return vec![];
}
if n_points == 1 {
return vec![low];
}
let log_low = low.ln();
let log_high = high.ln();
let mut result = Vec::with_capacity(n_points);
for i in 0..n_points {
let fraction = i as f64 / (n_points - 1) as f64;
let log_value = log_low + fraction * (log_high - log_low);
let value = log_value.exp();
result.push(value);
}
result
}
/// Generates grid points for a categorical distribution.
///
/// Returns a vector containing all choice indices `[0, 1, 2, ..., n_choices-1]`.
/// All choices are included in the grid for exhaustive evaluation.
///
/// # Arguments
///
/// * `dist` - The categorical distribution defining the number of choices.
///
/// # Returns
///
/// A vector of all choice indices from 0 to `n_choices - 1`.
///
/// # Examples
///
/// ```ignore
/// use optimizer::sampler::grid::generate_categorical_grid_points;
///
/// // CategoricalDistribution is internal; this shows intended usage
/// let dist = CategoricalDistribution { n_choices: 3 };
/// let points = generate_categorical_grid_points(&dist);
/// assert_eq!(points, vec![0, 1, 2]);
/// ```
#[must_use]
pub fn generate_categorical_grid_points(dist: &CategoricalDistribution) -> Vec<usize> {
(0..dist.n_choices).collect()
}
/// Cached grid points for a distribution.
#[derive(Debug, Clone)]
struct CachedGrid {
/// The generated grid points as `ParamValue` instances.
points: Vec<ParamValue>,
/// Current index into the grid.
current_index: usize,
}
/// Internal state for tracking grid position per distribution.
#[derive(Debug, Default)]
struct GridState {
/// Cached grids for each distribution, keyed by distribution identifier.
grids: HashMap<String, CachedGrid>,
}
/// Exhaustive grid search sampler.
///
/// Divide each parameter range into evenly spaced points and evaluate them
/// sequentially. When a parameter has an explicit `step` size, the step grid
/// is used instead of auto-discretization.
///
/// Grid state is tracked **per distribution key** (bounds + step + log-scale).
/// Parameters with identical distributions share the same grid counter, so
/// use distinct ranges when multiple parameters span the same domain.
///
/// # Grid exhaustion
///
/// When all grid points for a distribution have been sampled, the next
/// `sample()` call for that distribution **panics**. Use
/// [`is_exhausted()`](Self::is_exhausted) to check before sampling, or
/// set `n_points_per_param` high enough to cover the planned number of
/// trials.
///
/// # Thread safety
///
/// `GridSearchSampler` is `Send + Sync` and uses internal locking for
/// safe concurrent access.
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSampler;
///
/// // Default: 10 points per parameter
/// let sampler = GridSampler::new();
///
/// // Custom grid density
/// let sampler = GridSampler::builder().n_points_per_param(20).build();
/// ```
pub struct GridSampler {
/// Number of grid points per parameter (used when auto-discretizing).
n_points_per_param: usize,
/// Thread-safe internal state for tracking grid positions.
state: Mutex<GridState>,
}
impl GridSampler {
/// Creates a new grid search sampler with default settings.
///
/// Default settings:
/// - `n_points_per_param`: 10 (each continuous parameter is discretized to 10 points)
#[must_use]
pub fn new() -> Self {
Self {
n_points_per_param: 10,
state: Mutex::new(GridState::default()),
}
}
/// Creates a builder for configuring a `GridSearchSampler`.
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSampler;
///
/// let sampler = GridSampler::builder().n_points_per_param(20).build();
/// ```
#[must_use]
pub fn builder() -> GridSearchSamplerBuilder {
GridSearchSamplerBuilder::new()
}
}
impl Default for GridSampler {
fn default() -> Self {
Self::new()
}
}
impl GridSampler {
/// Returns `true` if all grid points for all tracked distributions have been sampled.
///
/// A distribution is considered exhausted when its `current_index` equals the number
/// of grid points. This method returns `true` only when **all** tracked distributions
/// are exhausted.
///
/// Note that a newly created sampler with no distributions sampled yet will return
/// `true` (vacuously exhausted). After the first `sample()` call, the distribution
/// is tracked and `is_exhausted()` will return `false` until all its points are used.
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSampler;
///
/// let sampler = GridSampler::new();
/// // Initially exhausted (no distributions tracked yet)
/// assert!(sampler.is_exhausted());
/// ```
#[must_use]
pub fn is_exhausted(&self) -> bool {
let state = self.state.lock();
// If no distributions are tracked yet, consider it vacuously exhausted
if state.grids.is_empty() {
return true;
}
// All distributions must be exhausted
state
.grids
.values()
.all(|grid| grid.current_index >= grid.points.len())
}
/// Returns the total number of grid points across all tracked distributions.
///
/// This method sums the number of grid points for each distribution that has been
/// sampled at least once. Before any `sample()` calls, this returns 0.
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSampler;
///
/// let sampler = GridSampler::new();
/// // No distributions tracked yet
/// assert_eq!(sampler.grid_size(), 0);
/// ```
#[must_use]
pub fn grid_size(&self) -> usize {
let state = self.state.lock();
state.grids.values().map(|grid| grid.points.len()).sum()
}
}
/// Builder for configuring a [`GridSampler`].
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSearchSamplerBuilder;
///
/// let sampler = GridSearchSamplerBuilder::new()
/// .n_points_per_param(20)
/// .build();
/// ```
#[derive(Debug, Clone)]
pub struct GridSearchSamplerBuilder {
n_points_per_param: usize,
}
impl GridSearchSamplerBuilder {
/// Creates a new builder with default settings.
///
/// Default settings:
/// - `n_points_per_param`: 10
#[must_use]
pub fn new() -> Self {
Self {
n_points_per_param: 10,
}
}
/// Sets the number of grid points per parameter for auto-discretization.
///
/// This value is used when a parameter distribution doesn't have an explicit
/// step size. The parameter range is divided into `n` evenly spaced points.
///
/// # Arguments
///
/// * `n` - Number of points per parameter.
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSearchSamplerBuilder;
///
/// let sampler = GridSearchSamplerBuilder::new()
/// .n_points_per_param(20)
/// .build();
/// ```
#[must_use]
pub fn n_points_per_param(mut self, n: usize) -> Self {
self.n_points_per_param = n;
self
}
/// Builds the configured [`GridSampler`].
///
/// # Examples
///
/// ```
/// use optimizer::sampler::grid::GridSearchSamplerBuilder;
///
/// let sampler = GridSearchSamplerBuilder::new()
/// .n_points_per_param(20)
/// .build();
/// ```
#[must_use]
pub fn build(self) -> GridSampler {
GridSampler {
n_points_per_param: self.n_points_per_param,
state: Mutex::new(GridState::default()),
}
}
}
impl Default for GridSearchSamplerBuilder {
fn default() -> Self {
Self::new()
}
}
/// Creates a unique identifier string from a distribution.
///
/// This is used as a key in the internal state to track grid position per distribution.
fn distribution_key(dist: &Distribution) -> String {
match dist {
Distribution::Float(d) => {
format!(
"float:{}:{}:{}:{}",
d.low,
d.high,
d.log_scale,
d.step.map_or("none".to_string(), |s| s.to_string())
)
}
Distribution::Int(d) => {
format!(
"int:{}:{}:{}:{}",
d.low,
d.high,
d.log_scale,
d.step.map_or("none".to_string(), |s| s.to_string())
)
}
Distribution::Categorical(d) => {
format!("cat:{}", d.n_choices)
}
}
}
impl Sampler for GridSampler {
fn sample(
&self,
distribution: &Distribution,
_trial_id: u64,
_history: &[CompletedTrial],
) -> ParamValue {
let mut state = self.state.lock();
let key = distribution_key(distribution);
// Get or create the cached grid for this distribution
let cached = state.grids.entry(key).or_insert_with(|| {
let points = match distribution {
Distribution::Float(d) => generate_float_grid_points(d, self.n_points_per_param)
.into_iter()
.map(ParamValue::Float)
.collect(),
Distribution::Int(d) => generate_int_grid_points(d, self.n_points_per_param)
.into_iter()
.map(ParamValue::Int)
.collect(),
Distribution::Categorical(d) => generate_categorical_grid_points(d)
.into_iter()
.map(ParamValue::Categorical)
.collect(),
};
CachedGrid {
points,
current_index: 0,
}
});
// Check if all points have been exhausted
assert!(
cached.current_index < cached.points.len(),
"GridSampler: all grid points exhausted"
);
// Get the current grid point and advance the index
let value = cached.points[cached.current_index].clone();
cached.current_index += 1;
value
}
}
#[cfg(test)]
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
mod tests {
use super::*;
// ==================== Int Distribution Tests ====================
#[test]
fn test_int_grid_with_step() {
let dist = IntDistribution {
low: 0,
high: 10,
log_scale: false,
step: Some(2),
};
let points = generate_int_grid_points(&dist, 10);
// Should generate: 0, 2, 4, 6, 8, 10
assert_eq!(points, vec![0, 2, 4, 6, 8, 10]);
}
#[test]
fn test_int_grid_with_step_not_exact_multiple() {
let dist = IntDistribution {
low: 0,
high: 9,
log_scale: false,
step: Some(2),
};
let points = generate_int_grid_points(&dist, 10);
// Should generate: 0, 2, 4, 6, 8 (stops at 8 because next step would be 10 > 9)
assert_eq!(points, vec![0, 2, 4, 6, 8]);
}
#[test]
fn test_int_grid_without_step_linear() {
let dist = IntDistribution {
low: 0,
high: 100,
log_scale: false,
step: None,
};
let points = generate_int_grid_points(&dist, 5);
// Should generate 5 evenly spaced points: 0, 25, 50, 75, 100
assert_eq!(points, vec![0, 25, 50, 75, 100]);
}
#[test]
fn test_int_grid_without_step_linear_10_points() {
let dist = IntDistribution {
low: 0,
high: 9,
log_scale: false,
step: None,
};
let points = generate_int_grid_points(&dist, 10);
// Should generate all integers from 0 to 9
assert_eq!(points, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
#[test]
fn test_int_grid_with_log_scale() {
let dist = IntDistribution {
low: 1,
high: 1000,
log_scale: true,
step: None,
};
let points = generate_int_grid_points(&dist, 4);
// Log scale: should cover range exponentially
// Points should be roughly: 1, 10, 100, 1000
assert_eq!(points.len(), 4);
assert_eq!(points[0], 1);
assert_eq!(*points.last().unwrap(), 1000);
// Middle points should be between bounds
for p in &points {
assert!(*p >= 1 && *p <= 1000);
}
}
#[test]
fn test_int_grid_log_scale_non_positive_fallback() {
// Log scale with non-positive low should fall back to linear
let dist = IntDistribution {
low: 0,
high: 100,
log_scale: true,
step: None,
};
let points = generate_int_grid_points(&dist, 5);
// Falls back to linear: 0, 25, 50, 75, 100
assert_eq!(points, vec![0, 25, 50, 75, 100]);
}
#[test]
fn test_int_grid_single_point() {
let dist = IntDistribution {
low: 5,
high: 5,
log_scale: false,
step: None,
};
let points = generate_int_grid_points(&dist, 10);
assert_eq!(points, vec![5]);
}
#[test]
fn test_int_grid_invalid_bounds() {
let dist = IntDistribution {
low: 10,
high: 5,
log_scale: false,
step: None,
};
let points = generate_int_grid_points(&dist, 10);
assert!(points.is_empty());
}
// ==================== Float Distribution Tests ====================
#[test]
fn test_float_grid_with_step() {
let dist = FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: Some(0.25),
};
let points = generate_float_grid_points(&dist, 10);
// Should generate: 0.0, 0.25, 0.5, 0.75, 1.0
assert_eq!(points.len(), 5);
assert!((points[0] - 0.0).abs() < f64::EPSILON);
assert!((points[1] - 0.25).abs() < f64::EPSILON);
assert!((points[2] - 0.5).abs() < f64::EPSILON);
assert!((points[3] - 0.75).abs() < f64::EPSILON);
assert!((points[4] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_float_grid_step_overrides_log_scale() {
// Step should override log_scale
let dist = FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: true, // This should be ignored
step: Some(0.5),
};
let points = generate_float_grid_points(&dist, 10);
// Should generate: 0.0, 0.5, 1.0 (step overrides log_scale)
assert_eq!(points.len(), 3);
assert!((points[0] - 0.0).abs() < f64::EPSILON);
assert!((points[1] - 0.5).abs() < f64::EPSILON);
assert!((points[2] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_float_grid_without_step_linear() {
let dist = FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
};
let points = generate_float_grid_points(&dist, 5);
// Should generate 5 evenly spaced points: 0.0, 0.25, 0.5, 0.75, 1.0
assert_eq!(points.len(), 5);
assert!((points[0] - 0.0).abs() < f64::EPSILON);
assert!((points[1] - 0.25).abs() < f64::EPSILON);
assert!((points[2] - 0.5).abs() < f64::EPSILON);
assert!((points[3] - 0.75).abs() < f64::EPSILON);
assert!((points[4] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_float_grid_with_log_scale() {
let dist = FloatDistribution {
low: 1e-4,
high: 1.0,
log_scale: true,
step: None,
};
let points = generate_float_grid_points(&dist, 5);
// Log scale: should cover range exponentially
// Points should be roughly: 1e-4, 1e-3, 1e-2, 1e-1, 1.0
assert_eq!(points.len(), 5);
assert!((points[0] - 1e-4).abs() < 1e-10);
assert!((points[4] - 1.0).abs() < 1e-10);
// Middle points should be between bounds
for p in &points {
assert!(*p >= 1e-4 && *p <= 1.0);
}
// In log scale, ratio between consecutive points should be roughly equal
let ratio1 = points[1] / points[0];
let ratio2 = points[2] / points[1];
assert!((ratio1 - ratio2).abs() / ratio1 < 0.01);
}
#[test]
fn test_float_grid_log_scale_non_positive_fallback() {
// Log scale with non-positive low should fall back to linear
let dist = FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: true,
step: None,
};
let points = generate_float_grid_points(&dist, 5);
// Falls back to linear: 0.0, 0.25, 0.5, 0.75, 1.0
assert_eq!(points.len(), 5);
assert!((points[0] - 0.0).abs() < f64::EPSILON);
assert!((points[4] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_float_grid_single_point() {
let dist = FloatDistribution {
low: 0.5,
high: 0.5,
log_scale: false,
step: None,
};
let points = generate_float_grid_points(&dist, 10);
assert_eq!(points.len(), 1);
assert!((points[0] - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_float_grid_invalid_bounds() {
let dist = FloatDistribution {
low: 1.0,
high: 0.0,
log_scale: false,
step: None,
};
let points = generate_float_grid_points(&dist, 10);
assert!(points.is_empty());
}
// ==================== Categorical Distribution Tests ====================
#[test]
fn test_categorical_grid() {
let dist = CategoricalDistribution { n_choices: 5 };
let points = generate_categorical_grid_points(&dist);
assert_eq!(points, vec![0, 1, 2, 3, 4]);
}
#[test]
fn test_categorical_grid_single_choice() {
let dist = CategoricalDistribution { n_choices: 1 };
let points = generate_categorical_grid_points(&dist);
assert_eq!(points, vec![0]);
}
#[test]
fn test_categorical_grid_empty() {
let dist = CategoricalDistribution { n_choices: 0 };
let points = generate_categorical_grid_points(&dist);
assert!(points.is_empty());
}
// ==================== Sampler Exhaustion Tests ====================
#[test]
fn test_sampler_exhausts_after_expected_samples() {
let sampler = GridSampler::new();
let dist = Distribution::Categorical(CategoricalDistribution { n_choices: 3 });
// Sample all 3 points
for _ in 0..3 {
let _ = sampler.sample(&dist, 0, &[]);
}
// Check exhaustion
assert!(sampler.is_exhausted());
}
#[test]
fn test_sampler_exhaustion_with_int_distribution() {
let sampler = GridSampler::builder().n_points_per_param(5).build();
let dist = Distribution::Int(IntDistribution {
low: 0,
high: 100,
log_scale: false,
step: None,
});
// Sample all 5 points
for _ in 0..5 {
let _ = sampler.sample(&dist, 0, &[]);
}
assert!(sampler.is_exhausted());
assert_eq!(sampler.grid_size(), 5);
}
#[test]
#[should_panic(expected = "GridSampler: all grid points exhausted")]
fn test_sampler_panics_after_exhaustion() {
let sampler = GridSampler::new();
let dist = Distribution::Categorical(CategoricalDistribution { n_choices: 2 });
// Sample all 2 points
sampler.sample(&dist, 0, &[]);
sampler.sample(&dist, 0, &[]);
// This should panic
sampler.sample(&dist, 0, &[]);
}
// ==================== is_exhausted() Tests ====================
#[test]
fn test_is_exhausted_before_sampling() {
let sampler = GridSampler::new();
// Newly created sampler is vacuously exhausted (no distributions tracked)
assert!(sampler.is_exhausted());
}
#[test]
fn test_is_exhausted_during_sampling() {
let sampler = GridSampler::new();
let dist = Distribution::Categorical(CategoricalDistribution { n_choices: 3 });
// After first sample, not exhausted
sampler.sample(&dist, 0, &[]);
assert!(!sampler.is_exhausted());
// After second sample, still not exhausted
sampler.sample(&dist, 0, &[]);
assert!(!sampler.is_exhausted());
// After third sample, exhausted
sampler.sample(&dist, 0, &[]);
assert!(sampler.is_exhausted());
}
#[test]
fn test_is_exhausted_multiple_distributions() {
let sampler = GridSampler::new();
// Use different n_choices so they have different distribution keys
let dist1 = Distribution::Categorical(CategoricalDistribution { n_choices: 2 });
let dist2 = Distribution::Categorical(CategoricalDistribution { n_choices: 3 });
// Exhaust first distribution
sampler.sample(&dist1, 0, &[]);
sampler.sample(&dist1, 0, &[]);
// Not exhausted yet because dist2 is not exhausted
sampler.sample(&dist2, 0, &[]);
assert!(!sampler.is_exhausted());
// Continue sampling dist2
sampler.sample(&dist2, 0, &[]);
assert!(!sampler.is_exhausted());
// Exhaust second distribution
sampler.sample(&dist2, 0, &[]);
assert!(sampler.is_exhausted());
}
// ==================== Builder Pattern Tests ====================
#[test]
fn test_builder_default() {
let sampler = GridSampler::builder().build();
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
// Default is 10 points per param
for _ in 0..10 {
let _ = sampler.sample(&dist, 0, &[]);
}
assert!(sampler.is_exhausted());
}
#[test]
fn test_builder_custom_n_points() {
let sampler = GridSampler::builder().n_points_per_param(3).build();
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
// Should have 3 points
for _ in 0..3 {
let _ = sampler.sample(&dist, 0, &[]);
}
assert!(sampler.is_exhausted());
assert_eq!(sampler.grid_size(), 3);
}
#[test]
fn test_new_default() {
let sampler = GridSampler::new();
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
// Default is 10 points per param
for _ in 0..10 {
let _ = sampler.sample(&dist, 0, &[]);
}
assert!(sampler.is_exhausted());
}
// ==================== Reproducibility Tests ====================
#[test]
fn test_reproducibility_same_grid_order() {
// Two samplers with the same configuration should produce the same grid order
let sampler1 = GridSampler::builder().n_points_per_param(5).build();
let sampler2 = GridSampler::builder().n_points_per_param(5).build();
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
// Both should produce the same sequence
for _ in 0..5 {
let v1 = sampler1.sample(&dist, 0, &[]);
let v2 = sampler2.sample(&dist, 0, &[]);
assert_eq!(v1, v2);
}
}
#[test]
fn test_reproducibility_int_distribution() {
let sampler1 = GridSampler::new();
let sampler2 = GridSampler::new();
let dist = Distribution::Int(IntDistribution {
low: 0,
high: 10,
log_scale: false,
step: Some(2),
});
// Both should produce: 0, 2, 4, 6, 8, 10
let expected = vec![0, 2, 4, 6, 8, 10];
for exp in &expected {
let v1 = sampler1.sample(&dist, 0, &[]);
let v2 = sampler2.sample(&dist, 0, &[]);
assert_eq!(v1, ParamValue::Int(*exp));
assert_eq!(v2, ParamValue::Int(*exp));
}
}
#[test]
fn test_reproducibility_categorical() {
let sampler1 = GridSampler::new();
let sampler2 = GridSampler::new();
let dist = Distribution::Categorical(CategoricalDistribution { n_choices: 4 });
// Both should produce: 0, 1, 2, 3
for i in 0..4 {
let v1 = sampler1.sample(&dist, 0, &[]);
let v2 = sampler2.sample(&dist, 0, &[]);
assert_eq!(v1, ParamValue::Categorical(i));
assert_eq!(v2, ParamValue::Categorical(i));
}
}
// ==================== Grid Size Tests ====================
#[test]
fn test_grid_size_empty() {
let sampler = GridSampler::new();
assert_eq!(sampler.grid_size(), 0);
}
#[test]
fn test_grid_size_single_distribution() {
let sampler = GridSampler::builder().n_points_per_param(5).build();
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
// Before sampling
assert_eq!(sampler.grid_size(), 0);
// After first sample, grid is created
sampler.sample(&dist, 0, &[]);
assert_eq!(sampler.grid_size(), 5);
}
#[test]
fn test_grid_size_multiple_distributions() {
let sampler = GridSampler::builder().n_points_per_param(3).build();
let dist1 = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
let dist2 = Distribution::Categorical(CategoricalDistribution { n_choices: 5 });
// Sample from first distribution
sampler.sample(&dist1, 0, &[]);
assert_eq!(sampler.grid_size(), 3);
// Sample from second distribution
sampler.sample(&dist2, 0, &[]);
assert_eq!(sampler.grid_size(), 3 + 5);
}
// ==================== Edge Cases ====================
#[test]
fn test_int_step_larger_than_range() {
let dist = IntDistribution {
low: 0,
high: 5,
log_scale: false,
step: Some(10),
};
let points = generate_int_grid_points(&dist, 10);
// Only the starting point should be generated
assert_eq!(points, vec![0]);
}
#[test]
fn test_float_step_larger_than_range() {
let dist = FloatDistribution {
low: 0.0,
high: 0.5,
log_scale: false,
step: Some(1.0),
};
let points = generate_float_grid_points(&dist, 10);
// Only the starting point should be generated
assert_eq!(points.len(), 1);
assert!((points[0] - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_n_points_one() {
let dist = IntDistribution {
low: 0,
high: 100,
log_scale: false,
step: None,
};
let points = generate_int_grid_points(&dist, 1);
// Should return just the low bound
assert_eq!(points, vec![0]);
}
#[test]
fn test_n_points_zero() {
let dist = IntDistribution {
low: 0,
high: 100,
log_scale: false,
step: None,
};
let points = generate_int_grid_points(&dist, 0);
assert!(points.is_empty());
}
}