refactor(tpe): split multivariate sampler into focused submodules

- Extract engine.rs (core sampling logic) and trials.rs (trial processing)
- Deduplicate TPE sampling functions by delegating to tpe::common
- Gate persistence.rs imports with #[cfg(feature = "serde")]
This commit is contained in:
Manuel Raimann
2026-02-12 15:38:23 +01:00
parent d80d2bb1fb
commit a502d7e1b8
4 changed files with 839 additions and 1248 deletions
+607
View File
@@ -0,0 +1,607 @@
//! Core multivariate TPE sampling logic.
//!
//! Contains the main sampling engine: group decomposition, single-group multivariate
//! TPE, candidate selection, independent fallbacks, and value conversion.
use std::collections::HashMap;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::parameter::ParamId;
use crate::sampler::CompletedTrial;
use super::MultivariateTpeSampler;
impl MultivariateTpeSampler {
/// Samples parameters by decomposing the search space into independent groups.
///
/// When `group=true`, this method analyzes the trial history to identify groups of
/// parameters that always appear together, then samples each group independently
/// using multivariate TPE. This is more efficient when parameters naturally partition
/// into independent subsets (e.g., due to conditional search spaces).
///
/// # Arguments
///
/// * `search_space` - The full search space containing all parameters to sample.
/// * `history` - Completed trials from the optimization history.
///
/// # Returns
///
/// A `HashMap` mapping parameter names to their sampled values.
pub(crate) fn sample_with_groups(
&self,
search_space: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
) -> HashMap<ParamId, ParamValue> {
use std::collections::HashSet;
use crate::sampler::tpe::GroupDecomposedSearchSpace;
// Decompose the search space into independent parameter groups
let groups = GroupDecomposedSearchSpace::calculate(history);
let mut result: HashMap<ParamId, ParamValue> = HashMap::new();
// Sample each group independently
for group in &groups {
// Build a sub-search space for this group
let group_search_space: HashMap<ParamId, Distribution> = search_space
.iter()
.filter(|(id, _)| group.contains(id))
.map(|(id, dist)| (*id, dist.clone()))
.collect();
if group_search_space.is_empty() {
continue;
}
// Filter history to trials that have at least one parameter in this group
let group_history: Vec<&CompletedTrial> = history
.iter()
.filter(|trial| {
trial
.distributions
.keys()
.any(|param_id| group.contains(param_id))
})
.collect();
// Build completed trials from references for the group
// We need to create a temporary slice for sample_group_internal
let group_history_owned: Vec<CompletedTrial> =
group_history.iter().map(|t| (*t).clone()).collect();
// Sample this group using multivariate TPE
let mut rng = self.rng.lock();
let group_result =
self.sample_single_group(&group_search_space, &group_history_owned, &mut rng);
drop(rng);
// Merge group results into the main result
for (id, value) in group_result {
result.insert(id, value);
}
}
// Handle parameters not in any group (sample independently)
let grouped_params: HashSet<ParamId> = groups.iter().flatten().copied().collect();
let ungrouped_params: HashMap<ParamId, Distribution> = search_space
.iter()
.filter(|(id, _)| !grouped_params.contains(id) && !result.contains_key(id))
.map(|(id, dist)| (*id, dist.clone()))
.collect();
if !ungrouped_params.is_empty() {
// Sample ungrouped parameters uniformly (no history for them)
let mut rng = self.rng.lock();
for (id, dist) in &ungrouped_params {
let value = crate::sampler::common::sample_random(&mut rng, dist);
result.insert(*id, value);
}
}
result
}
/// Samples parameters as a single group using multivariate TPE.
///
/// This is the core multivariate TPE sampling logic, used both in non-grouped mode
/// and for sampling individual groups in grouped mode.
///
/// # Arguments
///
/// * `search_space` - The search space for this group of parameters.
/// * `history` - Completed trials to use for model fitting.
/// * `rng` - Random number generator (caller must hold lock).
///
/// # Returns
///
/// A `HashMap` mapping parameter names to their sampled values.
#[allow(clippy::too_many_lines)]
pub(crate) fn sample_single_group(
&self,
search_space: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
rng: &mut fastrand::Rng,
) -> HashMap<ParamId, ParamValue> {
use crate::kde::MultivariateKDE;
use crate::sampler::tpe::IntersectionSearchSpace;
use crate::sampler::tpe::common;
// Early returns for cases requiring random sampling
if history.len() < self.n_startup_trials {
return self.sample_all_uniform(search_space, rng);
}
let intersection = IntersectionSearchSpace::calculate(history);
if intersection.is_empty() {
return self.sample_all_independent_with_rng(search_space, history, rng);
}
let filtered = self.filter_trials(history, &intersection);
if filtered.len() < 2 {
return self.sample_all_independent_with_rng(search_space, history, rng);
}
let (good, bad) = self.split_trials(&filtered);
// Sample categorical parameters using TPE with l(x)/g(x) ratio
let mut result: HashMap<ParamId, ParamValue> = HashMap::new();
for (param_id, dist) in &intersection {
if let Distribution::Categorical(d) = dist {
let good_indices = Self::extract_categorical_indices(&good, *param_id);
let bad_indices = Self::extract_categorical_indices(&bad, *param_id);
let idx =
common::sample_tpe_categorical(d.n_choices, &good_indices, &bad_indices, rng);
result.insert(*param_id, ParamValue::Categorical(idx));
}
}
// Collect continuous parameters
let mut param_order: Vec<ParamId> = intersection
.iter()
.filter(|(_, dist)| !matches!(dist, Distribution::Categorical(_)))
.map(|(id, _)| *id)
.collect();
if param_order.is_empty() {
// Only categorical parameters in intersection - fill remaining with independent TPE
self.fill_remaining_independent_with_rng(
search_space,
&intersection,
history,
&mut result,
rng,
);
return result;
}
param_order.sort_by_key(|id| format!("{id}"));
// Extract observations and validate
let good_obs = self.extract_observations(&good, &param_order);
let bad_obs = self.extract_observations(&bad, &param_order);
let expected_dims = param_order.len();
let valid = !good_obs.is_empty()
&& !bad_obs.is_empty()
&& good_obs.iter().all(|obs| obs.len() == expected_dims)
&& bad_obs.iter().all(|obs| obs.len() == expected_dims);
if !valid {
// Observations invalid - fill remaining with independent TPE
self.fill_remaining_independent_with_rng(
search_space,
&intersection,
history,
&mut result,
rng,
);
return result;
}
// Fit KDEs using let...else pattern
let Ok(good_kde) = MultivariateKDE::new(good_obs) else {
// KDE construction failed - fill remaining with independent TPE
self.fill_remaining_independent_with_rng(
search_space,
&intersection,
history,
&mut result,
rng,
);
return result;
};
let Ok(bad_kde) = MultivariateKDE::new(bad_obs) else {
// KDE construction failed - fill remaining with independent TPE
self.fill_remaining_independent_with_rng(
search_space,
&intersection,
history,
&mut result,
rng,
);
return result;
};
let selected = self.select_candidate_with_rng(&good_kde, &bad_kde, rng);
// Map selected values to parameter ids
for (idx, param_id) in param_order.iter().enumerate() {
if let Some(dist) = intersection.get(param_id) {
let value = selected[idx];
let param_value = self.convert_to_param_value(value, dist);
if let Some(pv) = param_value {
result.insert(*param_id, pv);
}
}
}
// Fill remaining parameters using independent TPE sampling
self.fill_remaining_independent_with_rng(
search_space,
&intersection,
history,
&mut result,
rng,
);
result
}
/// Converts a raw f64 value to a `ParamValue` based on the distribution.
#[allow(clippy::unused_self)]
pub(crate) fn convert_to_param_value(
&self,
value: f64,
dist: &Distribution,
) -> Option<ParamValue> {
match dist {
Distribution::Float(d) => {
let clamped = value.clamp(d.low, d.high);
let stepped = if let Some(step) = d.step {
let steps = ((clamped - d.low) / step).round();
(d.low + steps * step).clamp(d.low, d.high)
} else {
clamped
};
Some(ParamValue::Float(stepped))
}
Distribution::Int(d) => {
#[allow(clippy::cast_possible_truncation)]
let int_value = value.round() as i64;
let clamped = int_value.clamp(d.low, d.high);
let stepped = if let Some(step) = d.step {
let steps = (clamped - d.low) / step;
(d.low + steps * step).clamp(d.low, d.high)
} else {
clamped
};
Some(ParamValue::Int(stepped))
}
Distribution::Categorical(_) => None,
}
}
/// Selects the best candidate from a set of samples using the joint acquisition function.
///
/// This method implements the core TPE selection criterion: it generates candidates
/// from the "good" KDE (l(x)) and selects the one that maximizes the ratio l(x)/g(x),
/// which is equivalent to maximizing `log(l(x)) - log(g(x))`.
#[must_use]
#[allow(dead_code)] // Used by tests
pub(crate) fn select_candidate(
&self,
good_kde: &crate::kde::MultivariateKDE,
bad_kde: &crate::kde::MultivariateKDE,
) -> Vec<f64> {
let mut rng = self.rng.lock();
// Generate candidates from the good distribution
let candidates: Vec<Vec<f64>> = (0..self.n_ei_candidates)
.map(|_| good_kde.sample(&mut rng))
.collect();
// Compute log(l(x)) - log(g(x)) for each candidate
// This is equivalent to log(l(x)/g(x)) which we want to maximize
let log_ratios: Vec<f64> = candidates
.iter()
.map(|candidate| {
let log_l = good_kde.log_pdf(candidate);
let log_g = bad_kde.log_pdf(candidate);
log_l - log_g
})
.collect();
// Find the candidate with the maximum log ratio
let mut best_idx = 0;
let mut best_ratio = f64::NEG_INFINITY;
for (idx, &ratio) in log_ratios.iter().enumerate() {
// Handle NaN by treating it as worse than any finite value
if ratio > best_ratio || (best_ratio.is_nan() && !ratio.is_nan()) {
best_ratio = ratio;
best_idx = idx;
}
}
candidates.into_iter().nth(best_idx).unwrap_or_default()
}
/// Selects the best candidate using an external RNG.
///
/// This variant accepts an external RNG, used when the caller already holds the lock.
pub(crate) fn select_candidate_with_rng(
&self,
good_kde: &crate::kde::MultivariateKDE,
bad_kde: &crate::kde::MultivariateKDE,
rng: &mut fastrand::Rng,
) -> Vec<f64> {
// Generate candidates from the good distribution
let candidates: Vec<Vec<f64>> = (0..self.n_ei_candidates)
.map(|_| good_kde.sample(rng))
.collect();
// Compute log(l(x)) - log(g(x)) for each candidate
let log_ratios: Vec<f64> = candidates
.iter()
.map(|candidate| {
let log_l = good_kde.log_pdf(candidate);
let log_g = bad_kde.log_pdf(candidate);
log_l - log_g
})
.collect();
// Find the candidate with the maximum log ratio
let mut best_idx = 0;
let mut best_ratio = f64::NEG_INFINITY;
for (idx, &ratio) in log_ratios.iter().enumerate() {
if ratio > best_ratio || (best_ratio.is_nan() && !ratio.is_nan()) {
best_ratio = ratio;
best_idx = idx;
}
}
candidates.into_iter().nth(best_idx).unwrap_or_default()
}
/// Fills remaining parameters in result using independent TPE sampling.
///
/// This method is used to sample parameters that are not in the intersection
/// search space. It uses independent univariate TPE sampling for each parameter,
/// similar to the standard [`TpeSampler`].
///
/// When there isn't enough history for a parameter, falls back to uniform sampling.
#[allow(dead_code)]
pub(crate) fn fill_remaining_independent(
&self,
search_space: &HashMap<ParamId, Distribution>,
_intersection: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
result: &mut HashMap<ParamId, ParamValue>,
) {
// Identify parameters not in result (and not in intersection)
let missing_params: Vec<(&ParamId, &Distribution)> = search_space
.iter()
.filter(|(id, _)| !result.contains_key(id))
.collect();
if missing_params.is_empty() {
return;
}
// Split trials for independent sampling
let (good_trials, bad_trials) = self.split_trials(&history.iter().collect::<Vec<_>>());
let mut rng = self.rng.lock();
for (param_id, dist) in missing_params {
let value =
self.sample_independent_tpe(*param_id, dist, &good_trials, &bad_trials, &mut rng);
result.insert(*param_id, value);
}
}
/// Fills remaining parameters using independent TPE sampling with an external RNG.
///
/// This variant accepts an external RNG, used when the caller already holds the lock.
pub(crate) fn fill_remaining_independent_with_rng(
&self,
search_space: &HashMap<ParamId, Distribution>,
_intersection: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
result: &mut HashMap<ParamId, ParamValue>,
rng: &mut fastrand::Rng,
) {
// Identify parameters not in result (and not in intersection)
let missing_params: Vec<(&ParamId, &Distribution)> = search_space
.iter()
.filter(|(id, _)| !result.contains_key(id))
.collect();
if missing_params.is_empty() {
return;
}
// Split trials for independent sampling
let (good_trials, bad_trials) = self.split_trials(&history.iter().collect::<Vec<_>>());
for (param_id, dist) in missing_params {
let value =
self.sample_independent_tpe(*param_id, dist, &good_trials, &bad_trials, rng);
result.insert(*param_id, value);
}
}
/// Samples all parameters using independent TPE sampling.
///
/// This is used as a complete fallback when no intersection search space exists.
#[allow(dead_code)] // Used by tests
pub(crate) fn sample_all_independent(
&self,
search_space: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
) -> HashMap<ParamId, ParamValue> {
// Split trials for independent sampling
let (good_trials, bad_trials) = self.split_trials(&history.iter().collect::<Vec<_>>());
let mut rng = self.rng.lock();
let mut result = HashMap::new();
for (param_id, dist) in search_space {
let value =
self.sample_independent_tpe(*param_id, dist, &good_trials, &bad_trials, &mut rng);
result.insert(*param_id, value);
}
result
}
/// Samples all parameters using independent TPE sampling with an external RNG.
///
/// This variant accepts an external RNG, used when the caller already holds the lock.
pub(crate) fn sample_all_independent_with_rng(
&self,
search_space: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
rng: &mut fastrand::Rng,
) -> HashMap<ParamId, ParamValue> {
// Split trials for independent sampling
let (good_trials, bad_trials) = self.split_trials(&history.iter().collect::<Vec<_>>());
let mut result = HashMap::new();
for (param_id, dist) in search_space {
let value =
self.sample_independent_tpe(*param_id, dist, &good_trials, &bad_trials, rng);
result.insert(*param_id, value);
}
result
}
/// Samples a single parameter using independent TPE.
///
/// This method extracts values for the given parameter from good and bad trials,
/// fits univariate KDEs, and samples using the TPE acquisition function.
#[allow(clippy::too_many_lines)]
pub(crate) fn sample_independent_tpe(
&self,
param_id: ParamId,
distribution: &Distribution,
good_trials: &[&CompletedTrial],
bad_trials: &[&CompletedTrial],
rng: &mut fastrand::Rng,
) -> ParamValue {
use crate::sampler::tpe::common;
match distribution {
Distribution::Float(d) => {
let good_values: Vec<f64> = good_trials
.iter()
.filter_map(|t| t.params.get(&param_id))
.filter_map(|v| match v {
ParamValue::Float(f) => Some(*f),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
let bad_values: Vec<f64> = bad_trials
.iter()
.filter_map(|t| t.params.get(&param_id))
.filter_map(|v| match v {
ParamValue::Float(f) => Some(*f),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
if good_values.is_empty() || bad_values.is_empty() {
return crate::sampler::common::sample_random(rng, distribution);
}
let value = common::sample_tpe_float(
d.low,
d.high,
d.log_scale,
d.step,
good_values,
bad_values,
self.n_ei_candidates,
None,
rng,
);
ParamValue::Float(value)
}
Distribution::Int(d) => {
let good_values: Vec<i64> = good_trials
.iter()
.filter_map(|t| t.params.get(&param_id))
.filter_map(|v| match v {
ParamValue::Int(i) => Some(*i),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
let bad_values: Vec<i64> = bad_trials
.iter()
.filter_map(|t| t.params.get(&param_id))
.filter_map(|v| match v {
ParamValue::Int(i) => Some(*i),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
if good_values.is_empty() || bad_values.is_empty() {
return crate::sampler::common::sample_random(rng, distribution);
}
let value = common::sample_tpe_int(
d.low,
d.high,
d.log_scale,
d.step,
good_values,
bad_values,
self.n_ei_candidates,
None,
rng,
);
ParamValue::Int(value)
}
Distribution::Categorical(d) => {
let good_indices: Vec<usize> = good_trials
.iter()
.filter_map(|t| t.params.get(&param_id))
.filter_map(|v| match v {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
.filter(|&i| i < d.n_choices)
.collect();
let bad_indices: Vec<usize> = bad_trials
.iter()
.filter_map(|t| t.params.get(&param_id))
.filter_map(|v| match v {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
.filter(|&i| i < d.n_choices)
.collect();
if good_indices.is_empty() || bad_indices.is_empty() {
return crate::sampler::common::sample_random(rng, distribution);
}
let idx =
common::sample_tpe_categorical(d.n_choices, &good_indices, &bad_indices, rng);
ParamValue::Categorical(idx)
}
}
}
}
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
//! Trial processing for the multivariate TPE sampler.
//!
//! Contains constant-liar imputation, trial filtering, good/bad splitting,
//! observation extraction, and categorical index extraction.
use std::collections::HashMap;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::parameter::ParamId;
use crate::sampler::{CompletedTrial, PendingTrial};
use super::{ConstantLiarStrategy, MultivariateTpeSampler};
impl MultivariateTpeSampler {
/// Imputes objective values for pending trials based on the constant liar strategy.
///
/// In parallel optimization, multiple trials may be running simultaneously. This method
/// assigns "lie" values to pending trials so they can be included in the model fitting,
/// which helps avoid redundant exploration of the same region.
///
/// # Arguments
///
/// * `pending_trials` - Trials that are currently running and have no objective value yet.
/// * `completed_trials` - Trials that have completed and have objective values.
///
/// # Returns
///
/// A vector of `CompletedTrial` objects containing both the original completed trials
/// and the pending trials with imputed values. If the strategy is `None`, returns
/// only the completed trials (pending trials are ignored).
#[must_use]
pub fn impute_pending_trials(
&self,
pending_trials: &[PendingTrial],
completed_trials: &[CompletedTrial],
) -> Vec<CompletedTrial> {
// Start with a copy of completed trials
let mut result: Vec<CompletedTrial> = completed_trials.to_vec();
// If strategy is None or no pending trials, just return completed trials
if matches!(self.constant_liar, ConstantLiarStrategy::None) || pending_trials.is_empty() {
return result;
}
// Compute the imputation value based on strategy
let imputed_value = self.compute_imputation_value(completed_trials);
// Convert pending trials to completed trials with imputed values
for pending in pending_trials {
result.push(CompletedTrial::new(
pending.id,
pending.params.clone(),
pending.distributions.clone(),
HashMap::new(),
imputed_value,
));
}
result
}
/// Computes the imputation value based on the constant liar strategy.
///
/// This is a helper method used by [`impute_pending_trials`](Self::impute_pending_trials).
#[allow(clippy::cast_precision_loss)]
pub(crate) fn compute_imputation_value(&self, completed_trials: &[CompletedTrial]) -> f64 {
match self.constant_liar {
ConstantLiarStrategy::None => 0.0, // This case is handled before calling this method
ConstantLiarStrategy::Mean => {
if completed_trials.is_empty() {
0.0
} else {
let sum: f64 = completed_trials.iter().map(|t| t.value).sum();
sum / completed_trials.len() as f64
}
}
ConstantLiarStrategy::Best => {
// Best means minimum for minimization problems
completed_trials
.iter()
.map(|t| t.value)
.fold(f64::INFINITY, f64::min)
}
ConstantLiarStrategy::Worst => {
// Worst means maximum for minimization problems
completed_trials
.iter()
.map(|t| t.value)
.fold(f64::NEG_INFINITY, f64::max)
}
ConstantLiarStrategy::Custom(v) => v,
}
}
/// Filters trials to those containing all parameters in the search space.
///
/// Only trials that contain ALL parameters in the search space are included,
/// ensuring we can model the joint distribution over all parameters.
#[must_use]
pub fn filter_trials<'a>(
&self,
history: &'a [CompletedTrial],
search_space: &HashMap<ParamId, Distribution>,
) -> Vec<&'a CompletedTrial> {
history
.iter()
.filter(|trial| {
// Include trial only if it has ALL parameters in the search space
search_space
.keys()
.all(|param_id| trial.params.contains_key(param_id))
})
.collect()
}
/// Splits filtered trials into good and bad groups based on the gamma quantile.
///
/// The gamma value is computed dynamically using the configured [`GammaStrategy`].
/// Trials are sorted by objective value (ascending for minimization), and the
/// gamma quantile determines the split point.
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
#[must_use]
pub fn split_trials<'a>(
&self,
trials: &[&'a CompletedTrial],
) -> (Vec<&'a CompletedTrial>, Vec<&'a CompletedTrial>) {
if trials.is_empty() {
return (vec![], vec![]);
}
// Sort trials by objective value (ascending for minimization)
let mut sorted_indices: Vec<usize> = (0..trials.len()).collect();
sorted_indices.sort_by(|&a, &b| {
trials[a]
.value
.partial_cmp(&trials[b].value)
.unwrap_or(core::cmp::Ordering::Equal)
});
// Compute gamma using the strategy and clamp to valid range
let gamma = self
.gamma_strategy
.gamma(trials.len())
.clamp(f64::EPSILON, 1.0 - f64::EPSILON);
// Calculate the split point (gamma quantile)
// Ensure at least 1 trial in each group if possible
let n_good = ((trials.len() as f64 * gamma).ceil() as usize)
.max(1)
.min(trials.len().saturating_sub(1));
// Handle edge case: if we have only 1 trial, put it in good
if trials.len() == 1 {
return (vec![trials[0]], vec![]);
}
let good: Vec<_> = sorted_indices[..n_good]
.iter()
.map(|&i| trials[i])
.collect();
let bad: Vec<_> = sorted_indices[n_good..]
.iter()
.map(|&i| trials[i])
.collect();
(good, bad)
}
/// Extracts parameter values from trials as a numeric observation matrix.
///
/// Each row in the output represents one trial's parameter values in the specified order.
/// Categorical parameters are skipped.
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn extract_observations(
&self,
trials: &[&CompletedTrial],
param_order: &[ParamId],
) -> Vec<Vec<f64>> {
trials
.iter()
.map(|trial| {
param_order
.iter()
.filter_map(|param_id| {
trial.params.get(param_id).and_then(|value| match value {
crate::param::ParamValue::Float(f) => Some(*f),
crate::param::ParamValue::Int(i) => Some(*i as f64),
crate::param::ParamValue::Categorical(_) => None, // Skip categorical
})
})
.collect()
})
.collect()
}
/// Extracts categorical indices from trials for a specific parameter.
pub(crate) fn extract_categorical_indices(
trials: &[&CompletedTrial],
param_id: ParamId,
) -> Vec<usize> {
trials
.iter()
.filter_map(|trial| {
trial.params.get(&param_id).and_then(|value| {
if let ParamValue::Categorical(idx) = value {
Some(*idx)
} else {
None
}
})
})
.collect()
}
}
+4
View File
@@ -1,8 +1,12 @@
#[cfg(feature = "serde")]
use std::collections::HashMap;
#[cfg(feature = "serde")]
use crate::sampler::CompletedTrial;
#[cfg(feature = "serde")]
use crate::types::Direction;
#[cfg(feature = "serde")]
use super::Study;
/// A serializable snapshot of a study's state.