Files
rust-optimizer/tests/integration.rs
T
Manuel Raimann 5dc81fa0ab Implement Parameters API
- Add `.name()` builder method on all 5 parameter types for custom labels
- Add `CompletedTrial::get(&param)` for typed parameter access
- Add `Display` impl on `ParamValue`
- Add prelude module at `optimizer::prelude::*`
- Shadow `_with_sampler` methods on `Study<f64>` so `optimize()` auto-uses
  the configured sampler; deprecate `_with_sampler` variants
- Use runtime `Any` downcasting with `trial_factory` to avoid E0592
- Update all examples and tests to use the new API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 18:54:55 +01:00

1366 lines
39 KiB
Rust

//! Integration tests for the optimizer library.
#![allow(
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_truncation
)]
use optimizer::parameter::{BoolParam, CategoricalParam, FloatParam, IntParam, Parameter};
use optimizer::sampler::random::RandomSampler;
use optimizer::sampler::tpe::TpeSampler;
use optimizer::{Direction, Error, Study, Trial};
// =============================================================================
// Test: optimize simple quadratic function with TPE, finds near-optimal
// =============================================================================
#[test]
fn test_tpe_optimizes_quadratic_function() {
// Minimize f(x) = (x - 3)^2 where x in [-10, 10]
// Optimal: x = 3, f(3) = 0
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5) // Quick startup for test
.n_ei_candidates(24)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(-10.0, 10.0);
study
.optimize(50, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>((x - 3.0).powi(2))
})
.expect("optimization should succeed");
let best = study.best_trial().expect("should have at least one trial");
// TPE should find a value close to optimal (x ~ 3)
// We expect the best value to be small (close to 0)
assert!(
best.value < 1.0,
"TPE should find near-optimal: best value {} should be < 1.0",
best.value
);
}
#[test]
fn test_tpe_optimizes_multivariate_function() {
// Minimize f(x, y) = x^2 + y^2 where x, y in [-5, 5]
// Optimal: (0, 0), f(0, 0) = 0
let sampler = TpeSampler::builder()
.seed(123)
.n_startup_trials(10)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(-5.0, 5.0);
let y_param = FloatParam::new(-5.0, 5.0);
study
.optimize(100, |trial| {
let x = x_param.suggest(trial)?;
let y = y_param.suggest(trial)?;
Ok::<_, Error>(x * x + y * y)
})
.expect("optimization should succeed");
let best = study.best_trial().expect("should have at least one trial");
// TPE should find a reasonably good solution
assert!(
best.value < 5.0,
"TPE should find near-optimal: best value {} should be < 5.0",
best.value
);
}
#[test]
fn test_tpe_maximization() {
// Maximize f(x) = -(x - 2)^2 + 10 where x in [-10, 10]
// Optimal: x = 2, f(2) = 10
let sampler = TpeSampler::builder()
.seed(456)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Maximize, sampler);
let x_param = FloatParam::new(-10.0, 10.0);
study
.optimize(50, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(-(x - 2.0).powi(2) + 10.0)
})
.expect("optimization should succeed");
let best = study.best_trial().expect("should have at least one trial");
assert!(
best.value > 5.0,
"TPE should find reasonably good solution: best value {} should be > 5.0",
best.value
);
}
// =============================================================================
// Test: RandomSampler samples uniformly across range
// =============================================================================
#[test]
fn test_random_sampler_uniform_float_distribution() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let n_samples = 1000;
let mut samples = Vec::with_capacity(n_samples);
let x_param = FloatParam::new(0.0, 1.0);
study
.optimize(n_samples, |trial| {
let x = x_param.suggest(trial)?;
samples.push(x);
Ok::<_, Error>(x)
})
.unwrap();
// All samples should be in range
for &s in &samples {
assert!((0.0..=1.0).contains(&s), "sample {s} out of range [0, 1]");
}
// Check distribution is roughly uniform by looking at quartiles
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q1 = samples[n_samples / 4];
let q2 = samples[n_samples / 2];
let q3 = samples[3 * n_samples / 4];
assert!((q1 - 0.25).abs() < 0.1, "Q1 {q1} should be close to 0.25");
assert!(
(q2 - 0.5).abs() < 0.1,
"Q2 (median) {q2} should be close to 0.5"
);
assert!((q3 - 0.75).abs() < 0.1, "Q3 {q3} should be close to 0.75");
}
#[test]
fn test_random_sampler_uniform_int_distribution() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(123));
let n_samples = 5000;
let mut counts = [0u32; 10]; // counts for values 1-10
let n_param = IntParam::new(1, 10);
study
.optimize(n_samples, |trial| {
let n = n_param.suggest(trial)?;
assert!((1..=10).contains(&n), "sample {n} out of range [1, 10]");
counts[(n - 1) as usize] += 1;
Ok::<_, Error>(n as f64)
})
.unwrap();
let expected = n_samples as f64 / 10.0;
for (i, &count) in counts.iter().enumerate() {
let diff = (count as f64 - expected).abs() / expected;
assert!(
diff < 0.2,
"value {} appeared {} times, expected ~{}, diff = {:.1}%",
i + 1,
count,
expected,
diff * 100.0
);
}
}
#[test]
fn test_random_sampler_uniform_categorical_distribution() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(456));
let n_samples = 2000;
let mut counts = [0u32; 4];
let choices = ["a", "b", "c", "d"];
let cat_param = CategoricalParam::new(choices.to_vec());
study
.optimize(n_samples, |trial| {
let choice = cat_param.suggest(trial)?;
let idx = choices.iter().position(|&c| c == choice).unwrap();
counts[idx] += 1;
Ok::<_, Error>(idx as f64)
})
.unwrap();
let expected = n_samples as f64 / 4.0;
for (i, &count) in counts.iter().enumerate() {
let diff = (count as f64 - expected).abs() / expected;
assert!(
diff < 0.15,
"category {} appeared {} times, expected ~{}, diff = {:.1}%",
i,
count,
expected,
diff * 100.0
);
}
}
#[test]
fn test_random_sampler_reproducibility() {
let study1: Study<f64> =
Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(999));
let study2: Study<f64> =
Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(999));
let mut values1 = Vec::new();
let mut values2 = Vec::new();
let x_param1 = FloatParam::new(0.0, 100.0);
let x_param2 = FloatParam::new(0.0, 100.0);
study1
.optimize(100, |trial| {
let x = x_param1.suggest(trial)?;
values1.push(x);
Ok::<_, Error>(x)
})
.unwrap();
study2
.optimize(100, |trial| {
let x = x_param2.suggest(trial)?;
values2.push(x);
Ok::<_, Error>(x)
})
.unwrap();
for (i, (v1, v2)) in values1.iter().zip(values2.iter()).enumerate() {
assert_eq!(
v1, v2,
"values at trial {i} should be identical with same seed: {v1} vs {v2}"
);
}
}
// =============================================================================
// Test: suggest_param returns cached values on repeated calls
// =============================================================================
#[test]
fn test_suggest_float_caching() {
let param = FloatParam::new(0.0, 10.0);
let mut trial = Trial::new(0);
let x1 = param.suggest(&mut trial).unwrap();
let x2 = param.suggest(&mut trial).unwrap();
let x3 = param.suggest(&mut trial).unwrap();
assert_eq!(x1, x2, "repeated suggest should return cached value");
assert_eq!(x2, x3, "repeated suggest should return cached value");
}
#[test]
fn test_suggest_float_log_caching() {
let param = FloatParam::new(1e-5, 1e-1).log_scale();
let mut trial = Trial::new(0);
let x1 = param.suggest(&mut trial).unwrap();
let x2 = param.suggest(&mut trial).unwrap();
assert_eq!(
x1, x2,
"repeated suggest float log should return cached value"
);
}
#[test]
fn test_suggest_float_step_caching() {
let param = FloatParam::new(0.0, 1.0).step(0.1);
let mut trial = Trial::new(0);
let x1 = param.suggest(&mut trial).unwrap();
let x2 = param.suggest(&mut trial).unwrap();
assert_eq!(
x1, x2,
"repeated suggest float step should return cached value"
);
}
#[test]
fn test_suggest_int_caching() {
let param = IntParam::new(1, 100);
let mut trial = Trial::new(0);
let n1 = param.suggest(&mut trial).unwrap();
let n2 = param.suggest(&mut trial).unwrap();
assert_eq!(n1, n2, "repeated suggest int should return cached value");
}
#[test]
fn test_suggest_int_log_caching() {
let param = IntParam::new(1, 1024).log_scale();
let mut trial = Trial::new(0);
let n1 = param.suggest(&mut trial).unwrap();
let n2 = param.suggest(&mut trial).unwrap();
assert_eq!(
n1, n2,
"repeated suggest int log should return cached value"
);
}
#[test]
fn test_suggest_int_step_caching() {
let param = IntParam::new(32, 512).step(32);
let mut trial = Trial::new(0);
let n1 = param.suggest(&mut trial).unwrap();
let n2 = param.suggest(&mut trial).unwrap();
assert_eq!(
n1, n2,
"repeated suggest int step should return cached value"
);
}
#[test]
fn test_suggest_categorical_caching() {
let param = CategoricalParam::new(vec!["sgd", "adam", "rmsprop"]);
let mut trial = Trial::new(0);
let c1 = param.suggest(&mut trial).unwrap();
let c2 = param.suggest(&mut trial).unwrap();
assert_eq!(
c1, c2,
"repeated suggest categorical should return cached value"
);
}
#[test]
fn test_multiple_parameters_independent_caching() {
let x_param = FloatParam::new(0.0, 1.0);
let y_param = FloatParam::new(0.0, 1.0);
let n_param = IntParam::new(1, 10);
let opt_param = CategoricalParam::new(vec!["a", "b"]);
let mut trial = Trial::new(0);
// Suggest multiple parameters
let x = x_param.suggest(&mut trial).unwrap();
let y = y_param.suggest(&mut trial).unwrap();
let n = n_param.suggest(&mut trial).unwrap();
let opt = opt_param.suggest(&mut trial).unwrap();
// All should be cached independently
assert_eq!(x, x_param.suggest(&mut trial).unwrap());
assert_eq!(y, y_param.suggest(&mut trial).unwrap());
assert_eq!(n, n_param.suggest(&mut trial).unwrap());
assert_eq!(opt, opt_param.suggest(&mut trial).unwrap());
}
// =============================================================================
// Test: parameter conflict returns error
// =============================================================================
#[test]
fn test_parameter_conflict_same_param_different_distribution() {
// With ParamId-based API, conflict happens when the same ParamId is used
// with a different distribution. This can happen via suggest_param with
// a param that has a mismatched distribution for an already-stored id.
// Since each FloatParam::new() gets a unique id, conflicts only happen
// when the same param object is reused with different internal state,
// which is not possible with the immutable API.
// We test that different param objects don't conflict (they have different ids).
let param1 = FloatParam::new(0.0, 1.0);
let param2 = FloatParam::new(0.0, 2.0);
let mut trial = Trial::new(0);
trial.suggest_param(&param1).unwrap();
// Different param object = different id = no conflict
let result = trial.suggest_param(&param2);
assert!(result.is_ok());
}
#[test]
fn test_empty_categorical_returns_error() {
let param = CategoricalParam::<&str>::new(vec![]);
let mut trial = Trial::new(0);
let result = trial.suggest_param(&param);
assert!(matches!(result, Err(Error::EmptyChoices)));
}
// =============================================================================
// Additional integration tests
// =============================================================================
#[test]
fn test_study_basic_workflow() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(-5.0, 5.0);
study
.optimize(10, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x * x)
})
.expect("optimization should succeed");
assert_eq!(study.n_trials(), 10);
let best = study.best_trial().expect("should have best trial");
assert!(best.value >= 0.0, "x^2 should be non-negative");
}
#[test]
fn test_study_with_failures() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(-5.0, 5.0);
// Every other trial fails
let mut counter = 0;
study
.optimize(10, |trial| {
counter += 1;
if counter % 2 == 0 {
return Err::<f64, &str>("intentional failure");
}
let x = x_param.suggest(trial).map_err(|_| "param error")?;
Ok(x * x)
})
.expect("optimization should succeed with some failures");
// Only half the trials should have succeeded
assert_eq!(study.n_trials(), 5, "only 5 trials should have completed");
}
#[test]
fn test_no_completed_trials_error() {
let study: Study<f64> = Study::new(Direction::Minimize);
let result = study.best_trial();
assert!(matches!(result, Err(Error::NoCompletedTrials)));
}
#[test]
fn test_invalid_bounds_errors() {
let mut trial = Trial::new(0);
// low > high for float
let result = trial.suggest_param(&FloatParam::new(10.0, 5.0));
assert!(matches!(result, Err(Error::InvalidBounds { .. })));
// low > high for int
let result = trial.suggest_param(&IntParam::new(100, 50));
assert!(matches!(result, Err(Error::InvalidBounds { .. })));
}
#[test]
fn test_invalid_log_bounds_errors() {
let mut trial = Trial::new(0);
// low <= 0 for log float
let result = trial.suggest_param(&FloatParam::new(0.0, 1.0).log_scale());
assert!(matches!(result, Err(Error::InvalidLogBounds)));
let result = trial.suggest_param(&FloatParam::new(-1.0, 1.0).log_scale());
assert!(matches!(result, Err(Error::InvalidLogBounds)));
// low < 1 for log int
let result = trial.suggest_param(&IntParam::new(0, 100).log_scale());
assert!(matches!(result, Err(Error::InvalidLogBounds)));
}
#[test]
fn test_invalid_step_errors() {
let mut trial = Trial::new(0);
// step <= 0 for float
let result = trial.suggest_param(&FloatParam::new(0.0, 1.0).step(0.0));
assert!(matches!(result, Err(Error::InvalidStep)));
let result = trial.suggest_param(&FloatParam::new(0.0, 1.0).step(-0.1));
assert!(matches!(result, Err(Error::InvalidStep)));
// step <= 0 for int
let result = trial.suggest_param(&IntParam::new(0, 100).step(0));
assert!(matches!(result, Err(Error::InvalidStep)));
}
#[test]
fn test_tpe_with_categorical_parameter() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Maximize, sampler);
let model_param = CategoricalParam::new(vec!["linear", "quadratic", "cubic"]);
let x_param = FloatParam::new(0.0, 2.0);
// Optimization where the best choice depends on the categorical
study
.optimize(30, |trial| {
let choice = model_param.suggest(trial)?;
let x = x_param.suggest(trial)?;
// cubic model is best at x=1
let value = match choice {
"linear" => x,
"quadratic" => x * x,
"cubic" => -((x - 1.0).powi(2)) + 10.0, // peak at x=1, max value 10
_ => unreachable!(),
};
Ok::<_, Error>(value)
})
.expect("optimization should succeed");
let best = study.best_trial().expect("should have best trial");
assert!(
best.value > 5.0,
"should find good solution, got {}",
best.value
);
}
#[test]
fn test_tpe_with_integer_parameters() {
let sampler = TpeSampler::builder()
.seed(789)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let n_param = IntParam::new(1, 10);
// Minimize (n - 7)^2 where n in [1, 10]
study
.optimize(30, |trial| {
let n = n_param.suggest(trial)?;
Ok::<_, Error>(((n - 7) as f64).powi(2))
})
.expect("optimization should succeed");
let best = study.best_trial().expect("should have best trial");
assert!(
best.value < 5.0,
"should find n close to 7, best value = {}",
best.value
);
}
#[test]
fn test_callback_early_stopping() {
use std::cell::Cell;
use std::ops::ControlFlow;
let study: Study<f64> = Study::new(Direction::Minimize);
let trials_run = Cell::new(0);
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize_with_callback(
100,
|trial| {
trials_run.set(trials_run.get() + 1);
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
},
|_study, _trial| {
// Stop after 5 trials
if trials_run.get() >= 5 {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
)
.expect("optimization should succeed");
assert_eq!(study.n_trials(), 5, "should have stopped after 5 trials");
}
#[test]
fn test_study_trials_iteration() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(0.0, 1.0);
study
.optimize(5, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.unwrap();
let trials = study.trials();
assert_eq!(trials.len(), 5);
for trial in &trials {
assert!(
!trial.params.is_empty(),
"each trial should have parameters"
);
}
}
#[test]
fn test_study_direction() {
let study_min: Study<f64> = Study::new(Direction::Minimize);
assert_eq!(study_min.direction(), Direction::Minimize);
let study_max: Study<f64> = Study::new(Direction::Maximize);
assert_eq!(study_max.direction(), Direction::Maximize);
}
#[test]
fn test_trial_state() {
use optimizer::TrialState;
let trial = Trial::new(0);
assert_eq!(trial.state(), TrialState::Running);
}
#[test]
fn test_trial_params_access() {
let x_param = FloatParam::new(0.0, 1.0);
let n_param = IntParam::new(1, 10);
let mut trial = Trial::new(0);
x_param.suggest(&mut trial).unwrap();
n_param.suggest(&mut trial).unwrap();
let params = trial.params();
assert_eq!(params.len(), 2);
}
#[test]
fn test_log_scale_float_range() {
let param = FloatParam::new(1e-5, 1e-1).log_scale();
let mut trial = Trial::new(0);
let lr = param.suggest(&mut trial).unwrap();
assert!(
(1e-5..=1e-1).contains(&lr),
"log-scale value {lr} out of range"
);
}
#[test]
fn test_step_float_snaps_to_grid() {
let param = FloatParam::new(0.0, 1.0).step(0.25);
let mut trial = Trial::new(0);
let x = param.suggest(&mut trial).unwrap();
// x should be one of: 0.0, 0.25, 0.5, 0.75, 1.0
let valid_values = [0.0, 0.25, 0.5, 0.75, 1.0];
let is_valid = valid_values.iter().any(|&v| (x - v).abs() < 1e-10);
assert!(is_valid, "stepped float {x} should snap to grid");
}
#[test]
fn test_step_int_snaps_to_grid() {
let param = IntParam::new(0, 100).step(25);
let mut trial = Trial::new(0);
let n = param.suggest(&mut trial).unwrap();
// n should be one of: 0, 25, 50, 75, 100
assert!(
n % 25 == 0 && (0..=100).contains(&n),
"stepped int {n} should snap to grid"
);
}
#[test]
fn test_best_value() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize(10, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.unwrap();
let best_value = study.best_value().expect("should have best value");
let best_trial = study.best_trial().expect("should have best trial");
assert_eq!(
best_value, best_trial.value,
"best_value should match best_trial.value"
);
}
// =============================================================================
// Additional coverage tests
// =============================================================================
#[test]
fn test_study_set_sampler() {
let mut study: Study<f64> = Study::new(Direction::Minimize);
let tpe = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.build()
.unwrap();
study.set_sampler(tpe);
let x_param = FloatParam::new(-5.0, 5.0);
study
.optimize(10, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x * x)
})
.expect("optimization should succeed with new sampler");
assert_eq!(study.n_trials(), 10);
}
#[test]
fn test_study_with_i32_value_type() {
let study: Study<i32> = Study::new(Direction::Minimize);
let x_param = IntParam::new(-10, 10);
study
.optimize(10, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x.abs() as i32)
})
.expect("optimization should succeed");
assert_eq!(study.n_trials(), 10);
let best = study.best_trial().expect("should have best trial");
assert!(best.value >= 0, "absolute value should be non-negative");
}
#[test]
fn test_optimize_all_trials_fail() {
let study: Study<f64> = Study::new(Direction::Minimize);
let result = study.optimize(5, |_trial| Err::<f64, &str>("always fails"));
assert!(
matches!(result, Err(Error::NoCompletedTrials)),
"should return NoCompletedTrials when all trials fail"
);
}
#[test]
fn test_optimize_with_callback_all_trials_fail() {
use std::ops::ControlFlow;
let study: Study<f64> = Study::new(Direction::Minimize);
let result = study.optimize_with_callback(
5,
|_trial| Err::<f64, &str>("always fails"),
|_study, _trial| ControlFlow::Continue(()),
);
assert!(
matches!(result, Err(Error::NoCompletedTrials)),
"should return NoCompletedTrials when all trials fail"
);
}
#[test]
#[allow(deprecated)]
fn test_optimize_with_sampler_all_trials_fail() {
let study: Study<f64> = Study::new(Direction::Minimize);
let result = study.optimize_with_sampler(5, |_trial| Err::<f64, &str>("always fails"));
assert!(
matches!(result, Err(Error::NoCompletedTrials)),
"should return NoCompletedTrials when all trials fail"
);
}
#[test]
#[allow(deprecated)]
fn test_optimize_with_callback_sampler_all_trials_fail() {
use std::ops::ControlFlow;
let study: Study<f64> = Study::new(Direction::Minimize);
let result = study.optimize_with_callback_sampler(
5,
|_trial| Err::<f64, &str>("always fails"),
|_study, _trial| ControlFlow::Continue(()),
);
assert!(
matches!(result, Err(Error::NoCompletedTrials)),
"should return NoCompletedTrials when all trials fail"
);
}
#[test]
fn test_trial_debug_format() {
let param = FloatParam::new(0.0, 1.0);
let mut trial = Trial::new(42);
param.suggest(&mut trial).unwrap();
let debug_str = format!("{trial:?}");
assert!(debug_str.contains("Trial"));
assert!(debug_str.contains("42"));
assert!(debug_str.contains("has_sampler"));
}
#[test]
fn test_tpe_sampler_builder_default_trait() {
use optimizer::sampler::tpe::TpeSamplerBuilder;
let builder = TpeSamplerBuilder::default();
let sampler = builder.build().unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(0.0, 1.0);
study
.optimize(5, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.unwrap();
assert_eq!(study.n_trials(), 5);
}
#[test]
fn test_tpe_sampler_default_trait() {
let sampler = TpeSampler::default();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(0.0, 1.0);
study
.optimize(5, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.unwrap();
assert_eq!(study.n_trials(), 5);
}
#[test]
fn test_tpe_with_fixed_kde_bandwidth() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.kde_bandwidth(0.5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(-5.0, 5.0);
study
.optimize(20, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x * x)
})
.expect("optimization should succeed");
let best = study.best_trial().unwrap();
assert!(best.value < 10.0, "should find reasonable solution");
}
#[test]
fn test_tpe_sampler_invalid_kde_bandwidth() {
let result = TpeSampler::with_config(0.25, 10, 24, Some(-1.0), None);
assert!(matches!(result, Err(Error::InvalidBandwidth(_))));
}
#[test]
fn test_tpe_split_trials_with_two_trials() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(2)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize(5, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.expect("optimization should succeed with small history");
assert_eq!(study.n_trials(), 5);
}
#[test]
fn test_tpe_with_log_scale_int() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let batch_param = IntParam::new(1, 1024).log_scale();
study
.optimize(20, |trial| {
let batch_size = batch_param.suggest(trial)?;
Ok::<_, Error>(((batch_size as f64).log2() - 5.0).powi(2))
})
.expect("optimization should succeed");
let best = study.best_trial().unwrap();
assert!(best.value < 10.0, "should find reasonable solution");
}
#[test]
fn test_tpe_with_step_distributions() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(0.0, 10.0).step(0.5);
let n_param = IntParam::new(0, 100).step(10);
study
.optimize(20, |trial| {
let x = x_param.suggest(trial)?;
let n = n_param.suggest(trial)?;
Ok::<_, Error>((x - 5.0).powi(2) + ((n - 50) as f64).powi(2))
})
.expect("optimization should succeed");
let best = study.best_trial().unwrap();
assert!(best.value < 100.0, "should find reasonable solution");
}
#[test]
#[allow(deprecated)]
fn test_create_trial_vs_create_trial_with_sampler() {
let sampler = RandomSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
// create_trial() creates trial with sampler integration for Study<f64>
let trial1 = study.create_trial();
assert_eq!(trial1.id(), 0);
// create_trial_with_sampler() is deprecated but still works
let trial2 = study.create_trial_with_sampler();
assert_eq!(trial2.id(), 1);
// Both should work for suggesting parameters
let x_param = FloatParam::new(0.0, 1.0);
let mut trial3 = study.create_trial();
let x = x_param.suggest(&mut trial3).unwrap();
assert!((0.0..=1.0).contains(&x));
}
#[test]
fn test_manual_trial_completion() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(0.0, 10.0);
// Manually create and complete trials
let mut trial = study.create_trial();
let x = x_param.suggest(&mut trial).unwrap();
study.complete_trial(trial, x * x);
let mut trial2 = study.create_trial();
let y = x_param.suggest(&mut trial2).unwrap();
study.complete_trial(trial2, y * y);
// Manually fail a trial
let trial3 = study.create_trial();
study.fail_trial(trial3, "test failure");
// Only 2 completed trials
assert_eq!(study.n_trials(), 2);
}
#[test]
fn test_distributions_access() {
let x_param = FloatParam::new(0.0, 1.0);
let n_param = IntParam::new(1, 10);
let opt_param = CategoricalParam::new(vec!["a", "b", "c"]);
let mut trial = Trial::new(0);
x_param.suggest(&mut trial).unwrap();
n_param.suggest(&mut trial).unwrap();
opt_param.suggest(&mut trial).unwrap();
let dists = trial.distributions();
assert_eq!(dists.len(), 3);
}
#[test]
fn test_tpe_empty_good_or_bad_values_fallback() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.gamma(0.1)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(0.0, 10.0);
let y_param = FloatParam::new(0.0, 10.0);
// First optimize with one parameter
study
.optimize(10, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.unwrap();
// Now try with a different parameter - TPE won't have history for "y"
study
.optimize(5, |trial| {
let y = y_param.suggest(trial)?;
Ok::<_, Error>(y)
})
.unwrap();
assert_eq!(study.n_trials(), 15);
}
#[test]
fn test_callback_early_stopping_on_first_trial() {
use std::ops::ControlFlow;
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize_with_callback(
100,
|trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
},
|_study, _trial| {
// Stop immediately after first trial
ControlFlow::Break(())
},
)
.expect("optimization should succeed");
assert_eq!(study.n_trials(), 1, "should have stopped after 1 trial");
}
#[test]
fn test_callback_sampler_early_stopping() {
use std::ops::ControlFlow;
let sampler = RandomSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize_with_callback(
100,
|trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
},
|study, _trial| {
if study.n_trials() >= 3 {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
)
.expect("optimization should succeed");
assert_eq!(study.n_trials(), 3);
}
#[test]
fn test_int_bounds_with_low_equals_high() {
let mut trial = Trial::new(0);
// When low == high, should return that exact value
let n_param = IntParam::new(5, 5);
let n = n_param.suggest(&mut trial).unwrap();
assert_eq!(n, 5);
let x_param = FloatParam::new(3.0, 3.0);
let x = x_param.suggest(&mut trial).unwrap();
assert_eq!(x, 3.0);
}
#[test]
fn test_best_trial_with_nan_values() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize(5, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>(x)
})
.unwrap();
let best = study.best_trial();
assert!(best.is_ok());
}
// =============================================================================
// Tests for BoolParam
// =============================================================================
#[test]
fn test_suggest_bool_caching() {
let param = BoolParam::new();
let mut trial = Trial::new(0);
let b1 = param.suggest(&mut trial).unwrap();
let b2 = param.suggest(&mut trial).unwrap();
assert_eq!(b1, b2, "repeated suggest bool should return cached value");
}
#[test]
fn test_suggest_bool_multiple_parameters() {
let dropout_param = BoolParam::new();
let batchnorm_param = BoolParam::new();
let skip_param = BoolParam::new();
let mut trial = Trial::new(0);
let a = dropout_param.suggest(&mut trial).unwrap();
let b = batchnorm_param.suggest(&mut trial).unwrap();
let c = skip_param.suggest(&mut trial).unwrap();
// All should be cached independently
assert_eq!(a, dropout_param.suggest(&mut trial).unwrap());
assert_eq!(b, batchnorm_param.suggest(&mut trial).unwrap());
assert_eq!(c, skip_param.suggest(&mut trial).unwrap());
}
#[test]
fn test_suggest_bool_in_optimization() {
let study: Study<f64> = Study::new(Direction::Minimize);
let use_feature_param = BoolParam::new();
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize(10, |trial| {
let use_feature = use_feature_param.suggest(trial)?;
let x = x_param.suggest(trial)?;
let value = if use_feature { x } else { x * 2.0 };
Ok::<_, Error>(value)
})
.unwrap();
assert_eq!(study.n_trials(), 10);
}
#[test]
fn test_suggest_bool_with_tpe() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let use_large_param = BoolParam::new();
let x_param = FloatParam::new(0.0, 10.0);
study
.optimize(20, |trial| {
let use_large = use_large_param.suggest(trial)?;
let x = x_param.suggest(trial)?;
// The value depends on use_large flag
let base = if use_large { x * 2.0 } else { x };
Ok::<_, Error>(base)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(best.value < 10.0);
}
// =============================================================================
// Tests for FloatParam and IntParam ranges
// =============================================================================
#[test]
fn test_float_param_exclusive_range() {
let param = FloatParam::new(0.0, 1.0);
let mut trial = Trial::new(0);
let x = param.suggest(&mut trial).unwrap();
assert!((0.0..=1.0).contains(&x), "value {x} out of range 0.0..1.0");
}
#[test]
fn test_float_param_inclusive_range() {
let param = FloatParam::new(0.0, 1.0);
let mut trial = Trial::new(0);
let x = param.suggest(&mut trial).unwrap();
assert!((0.0..=1.0).contains(&x), "value {x} out of range 0.0..=1.0");
}
#[test]
fn test_int_param_range() {
let param = IntParam::new(1, 10);
let mut trial = Trial::new(0);
let n = param.suggest(&mut trial).unwrap();
assert!((1..=10).contains(&n), "value {n} out of range 1..=10");
}
#[test]
fn test_param_caching_float() {
let param = FloatParam::new(0.0, 1.0);
let mut trial = Trial::new(0);
let x1 = param.suggest(&mut trial).unwrap();
let x2 = param.suggest(&mut trial).unwrap();
assert_eq!(x1, x2, "repeated suggest should return cached value");
}
#[test]
fn test_param_caching_int() {
let param = IntParam::new(1, 100);
let mut trial = Trial::new(0);
let n1 = param.suggest(&mut trial).unwrap();
let n2 = param.suggest(&mut trial).unwrap();
assert_eq!(n1, n2, "repeated suggest should return cached value");
}
#[test]
fn test_multiple_params_in_optimization() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(-10.0, 10.0);
let n_param = IntParam::new(1, 5);
study
.optimize(10, |trial| {
let x = x_param.suggest(trial)?;
let n = n_param.suggest(trial)?;
Ok::<_, Error>(x * x + n as f64)
})
.unwrap();
assert_eq!(study.n_trials(), 10);
}
#[test]
fn test_params_with_tpe() {
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5)
.build()
.unwrap();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x_param = FloatParam::new(-5.0, 5.0);
let n_param = IntParam::new(1, 10);
study
.optimize(30, |trial| {
let x = x_param.suggest(trial)?;
let n = n_param.suggest(trial)?;
Ok::<_, Error>(x * x + (n as f64 - 5.0).powi(2))
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(best.value < 10.0, "TPE should find good solution");
}
#[test]
fn test_single_value_int_range() {
let param = IntParam::new(5, 5);
let mut trial = Trial::new(0);
let n = param.suggest(&mut trial).unwrap();
assert_eq!(n, 5, "single-value range should return that value");
}
#[test]
fn test_single_value_float_range() {
let param = FloatParam::new(4.2, 4.2);
let mut trial = Trial::new(0);
let x = param.suggest(&mut trial).unwrap();
assert!(
(x - 4.2).abs() < f64::EPSILON,
"single-value range should return that value"
);
}
// =============================================================================
// Tests for new API features
// =============================================================================
#[test]
fn test_param_name() {
let param = FloatParam::new(0.0, 1.0).name("learning_rate");
let mut trial = Trial::new(0);
param.suggest(&mut trial).unwrap();
let labels = trial.param_labels();
let label = labels.values().next().unwrap();
assert_eq!(label, "learning_rate");
}
#[test]
fn test_completed_trial_get() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x_param = FloatParam::new(-10.0, 10.0).name("x");
let n_param = IntParam::new(1, 10).name("n");
study
.optimize(5, |trial| {
let x = x_param.suggest(trial)?;
let n = n_param.suggest(trial)?;
Ok::<_, Error>(x * x + n as f64)
})
.unwrap();
let best = study.best_trial().unwrap();
let x_val: f64 = best.get(&x_param).unwrap();
let n_val: i64 = best.get(&n_param).unwrap();
assert!((-10.0..=10.0).contains(&x_val));
assert!((1..=10).contains(&n_val));
}