use crate::error::FdarError;
use crate::helpers::simpsons_weights;
use crate::iter_maybe_parallel;
use crate::linalg::{cholesky_factor, cholesky_forward_back, log_det_from_cholesky};
use crate::pace_fpca::PaceFpcaResult;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DesignCriterion {
Trajectory,
Score(OptimalityKind),
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OptimalityKind {
A,
D,
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn design_criterion(
model: &PaceFpcaResult,
selected: &[usize],
criterion: DesignCriterion,
) -> Result<f64, FdarError> {
let m = model.argvals.len();
if model.ncomp == 0 {
return Err(FdarError::InvalidParameter {
parameter: "model.ncomp",
message: "ncomp must be > 0; the model has no FPC components".into(),
});
}
if model.eigenvalues.len() < model.ncomp {
return Err(FdarError::InvalidParameter {
parameter: "model.eigenvalues",
message: format!(
"eigenvalues length {} is smaller than ncomp {}",
model.eigenvalues.len(),
model.ncomp
),
});
}
if model.sigma2 <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "model.sigma2",
message: format!("sigma2 must be > 0; got {}", model.sigma2),
});
}
if m < 2 {
return Err(FdarError::InvalidParameter {
parameter: "model.argvals",
message: format!(
"argvals must have length >= 2 (a trajectory integral / Simpson quadrature is undefined for m < 2); got {m}"
),
});
}
for &idx in selected {
if idx >= m {
return Err(FdarError::InvalidParameter {
parameter: "selected",
message: format!("index {idx} is out of range for argvals of length {m}"),
});
}
}
match criterion {
DesignCriterion::Trajectory => trajectory_criterion(model, selected),
DesignCriterion::Score(kind) => score_criterion(model, selected, kind),
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OptDesConfig {
pub candidate_grid: Vec<f64>,
pub budget: usize,
pub criterion: DesignCriterion,
}
impl Default for OptDesConfig {
fn default() -> Self {
Self {
candidate_grid: vec![],
budget: 1,
criterion: DesignCriterion::Trajectory,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OptDesResult {
pub selected_indices: Vec<usize>,
pub selected_argvals: Vec<f64>,
pub criterion_trace: Vec<f64>,
}
fn map_candidates_to_indices(
candidate_grid: &[f64],
argvals: &[f64],
) -> Result<Vec<usize>, FdarError> {
candidate_grid
.iter()
.map(|&cand| {
argvals
.iter()
.position(|&t| (t - cand).abs() < 1e-9)
.ok_or_else(|| FdarError::InvalidParameter {
parameter: "config.candidate_grid",
message: format!(
"candidate {cand:.6} not found in model.argvals within tolerance 1e-9"
),
})
})
.collect()
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn optimal_design(
model: &PaceFpcaResult,
config: &OptDesConfig,
) -> Result<OptDesResult, FdarError> {
if config.budget == 0 {
return Err(FdarError::InvalidParameter {
parameter: "config.budget",
message: "budget must be > 0".into(),
});
}
if config.budget > config.candidate_grid.len() {
return Err(FdarError::InvalidParameter {
parameter: "config.budget",
message: format!(
"budget {} exceeds the number of candidate points {}",
config.budget,
config.candidate_grid.len()
),
});
}
if model.ncomp == 0 {
return Err(FdarError::InvalidParameter {
parameter: "model.ncomp",
message: "ncomp must be > 0; the model has no FPC components".into(),
});
}
if model.sigma2 <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "model.sigma2",
message: format!("sigma2 must be > 0; got {}", model.sigma2),
});
}
let candidate_indices = {
let mut v = map_candidates_to_indices(&config.candidate_grid, &model.argvals)?;
v.sort_unstable();
v.dedup();
v
};
if config.budget > candidate_indices.len() {
return Err(FdarError::InvalidParameter {
parameter: "config.candidate_grid",
message: format!(
"budget {} exceeds the number of distinct on-grid candidate points {} \
(duplicate or near-duplicate candidate_grid values collapse onto the \
same model.argvals index)",
config.budget,
candidate_indices.len()
),
});
}
let mut selected: Vec<usize> = Vec::with_capacity(config.budget);
let mut trace: Vec<f64> = Vec::with_capacity(config.budget);
for _step in 0..config.budget {
let remaining: Vec<usize> = candidate_indices
.iter()
.copied()
.filter(|idx| !selected.contains(idx))
.collect();
#[cfg(feature = "parallel")]
use rayon::iter::ParallelIterator;
let scores: Vec<(usize, f64)> = iter_maybe_parallel!(remaining)
.map(|idx| {
let mut trial = selected.clone();
trial.push(idx);
let val = design_criterion(model, &trial, config.criterion.clone())?;
Ok::<(usize, f64), FdarError>((idx, val))
})
.collect::<Result<Vec<_>, _>>()?;
let (best_idx, best_val) = scores
.into_iter()
.fold(None::<(usize, f64)>, |acc, (idx, val)| {
Some(match acc {
None => (idx, val),
Some((bi, bv)) => {
if val < bv {
(idx, val)
} else {
(bi, bv)
}
}
})
})
.ok_or_else(|| FdarError::InvalidParameter {
parameter: "config.candidate_grid",
message: "distinct candidate pool exhausted before budget was reached \
(no remaining candidates at a greedy step)"
.into(),
})?;
selected.push(best_idx);
trace.push(best_val);
}
let selected_argvals = selected.iter().map(|&i| model.argvals[i]).collect();
Ok(OptDesResult {
selected_indices: selected,
selected_argvals,
criterion_trace: trace,
})
}
fn build_sigma_design(model: &PaceFpcaResult, selected: &[usize]) -> Vec<f64> {
let p = selected.len();
let ncomp = model.ncomp;
let mut sigma_d = vec![0.0_f64; p * p];
for row in 0..p {
let j_row = selected[row];
for col in 0..p {
let j_col = selected[col];
let mut s = 0.0_f64;
for k in 0..ncomp {
s += model.eigenfunctions[(j_row, k)]
* model.eigenvalues[k]
* model.eigenfunctions[(j_col, k)];
}
sigma_d[row * p + col] = s;
}
sigma_d[row * p + row] += model.sigma2; }
sigma_d
}
fn factor_sigma_design_with_retry(mut sigma_d: Vec<f64>, p: usize) -> Result<Vec<f64>, FdarError> {
match cholesky_factor(&sigma_d, p) {
Ok(l) => Ok(l),
Err(_) => {
for i in 0..p {
sigma_d[i * p + i] += 1e-8;
}
cholesky_factor(&sigma_d, p).map_err(|_| FdarError::ComputationFailed {
operation: "optimal_design Sigma_d Cholesky",
detail: "Cholesky failed after 1e-8 ridge; sigma2 may be too small".into(),
})
}
}
}
fn factor_posterior_cov_with_retry(mut cov: Vec<f64>, ncomp: usize) -> Result<Vec<f64>, FdarError> {
match cholesky_factor(&cov, ncomp) {
Ok(l) => Ok(l),
Err(_) => {
let scale: f64 = (0..ncomp)
.map(|k| cov[k * ncomp + k].abs())
.fold(0.0_f64, f64::max)
.max(1.0);
let ridge = 1e-8 * scale;
for i in 0..ncomp {
cov[i * ncomp + i] += ridge;
}
cholesky_factor(&cov, ncomp).map_err(|_| FdarError::ComputationFailed {
operation: "optimal_design D-optimality log-det",
detail: "posterior covariance Cholesky failed after ridge; \
model may be near-degenerate"
.into(),
})
}
}
}
fn build_phi_d(model: &PaceFpcaResult, selected: &[usize]) -> Vec<f64> {
let p = selected.len();
let ncomp = model.ncomp;
let mut phi_d = vec![0.0_f64; p * ncomp];
for (i, &j) in selected.iter().enumerate() {
for k in 0..ncomp {
phi_d[i * ncomp + k] = model.eigenfunctions[(j, k)];
}
}
phi_d
}
fn trajectory_criterion(model: &PaceFpcaResult, selected: &[usize]) -> Result<f64, FdarError> {
let m = model.argvals.len();
let ncomp = model.ncomp;
let p = selected.len();
let weights = simpsons_weights(&model.argvals);
if p == 0 {
let mut mse = 0.0_f64;
for j in 0..m {
let prior_var: f64 = (0..ncomp)
.map(|k| model.eigenvalues[k] * model.eigenfunctions[(j, k)].powi(2))
.sum();
mse += weights[j] * prior_var;
}
return Ok(mse);
}
let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
let phi_d = build_phi_d(model, selected);
let mut mse = 0.0_f64;
let mut rhs = vec![0.0_f64; p];
for j in 0..m {
let prior_var: f64 = (0..ncomp)
.map(|k| model.eigenvalues[k] * model.eigenfunctions[(j, k)].powi(2))
.sum();
for (i, r) in rhs.iter_mut().enumerate() {
let mut s = 0.0_f64;
for k in 0..ncomp {
s += model.eigenvalues[k] * model.eigenfunctions[(j, k)] * phi_d[i * ncomp + k];
}
*r = s;
}
let v = cholesky_forward_back(&l, &rhs, p);
let reduction: f64 = rhs.iter().zip(v.iter()).map(|(&a, &b)| a * b).sum();
mse += weights[j] * (prior_var - reduction);
}
Ok(mse)
}
fn score_criterion(
model: &PaceFpcaResult,
selected: &[usize],
kind: OptimalityKind,
) -> Result<f64, FdarError> {
let ncomp = model.ncomp;
let p = selected.len();
if p == 0 {
return match kind {
OptimalityKind::A => Ok(model.eigenvalues.iter().take(ncomp).sum()),
OptimalityKind::D => {
let mut s = 0.0_f64;
for &lam in model.eigenvalues.iter().take(ncomp) {
if lam <= 0.0 {
return Err(FdarError::ComputationFailed {
operation: "optimal_design D-optimality",
detail: "non-positive eigenvalue in prior".into(),
});
}
s += lam.ln();
}
Ok(s)
}
};
}
let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
let phi_d = build_phi_d(model, selected);
let mut sigma_inv_phi_lam = vec![0.0_f64; p * ncomp];
let mut phi_col = vec![0.0_f64; p];
for k in 0..ncomp {
for (i, c) in phi_col.iter_mut().enumerate() {
*c = phi_d[i * ncomp + k];
}
let sol = cholesky_forward_back(&l, &phi_col, p);
for j in 0..p {
sigma_inv_phi_lam[j * ncomp + k] = model.eigenvalues[k] * sol[j];
}
}
let mut a_mat = vec![0.0_f64; ncomp * ncomp];
for k in 0..ncomp {
for l in 0..ncomp {
let mut s = 0.0_f64;
for j in 0..p {
s += phi_d[j * ncomp + k] * sigma_inv_phi_lam[j * ncomp + l];
}
a_mat[k * ncomp + l] = model.eigenvalues[k] * s;
}
}
let mut cov = vec![0.0_f64; ncomp * ncomp];
for k in 0..ncomp {
for l in 0..ncomp {
let prior = if k == l { model.eigenvalues[k] } else { 0.0 };
cov[k * ncomp + l] = prior - a_mat[k * ncomp + l];
}
}
match kind {
OptimalityKind::A => {
let tr: f64 = (0..ncomp).map(|k| cov[k * ncomp + k]).sum();
Ok(tr)
}
OptimalityKind::D => {
let l_cov = factor_posterior_cov_with_retry(cov, ncomp)?;
Ok(log_det_from_cholesky(&l_cov, ncomp))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matrix::FdMatrix;
fn synthetic_model(m: usize) -> PaceFpcaResult {
synthetic_model_params(m, vec![2.0, 1.0], 0.5)
}
fn synthetic_model_params(m: usize, eigenvalues: Vec<f64>, sigma2: f64) -> PaceFpcaResult {
let ncomp = eigenvalues.len();
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let weights = simpsons_weights(&argvals);
let mut ef = vec![0.0_f64; m * ncomp];
for k in 0..ncomp {
let freq = (k + 1) as f64 * std::f64::consts::PI;
let raw: Vec<f64> = argvals.iter().map(|&t| (freq * t).cos()).collect();
let norm_sq: f64 = (0..m).map(|j| weights[j] * raw[j] * raw[j]).sum();
let norm = norm_sq.sqrt();
for j in 0..m {
ef[j + k * m] = raw[j] / norm;
}
}
let eigenfunctions = FdMatrix::from_column_major(ef, m, ncomp).unwrap();
PaceFpcaResult {
mean: vec![0.0; m],
eigenvalues,
eigenfunctions,
scores: FdMatrix::zeros(1, ncomp),
fitted: FdMatrix::zeros(1, m),
fitted_lower: FdMatrix::zeros(1, m),
fitted_upper: FdMatrix::zeros(1, m),
argvals,
sigma2,
ncomp,
}
}
#[test]
fn test_trajectory_empty_set() {
let model = synthetic_model(51);
let mse = design_criterion(&model, &[], DesignCriterion::Trajectory).unwrap();
assert!((mse - 3.0).abs() < 1e-10, "MSE(∅) = {mse}, expected 3.0");
}
#[test]
fn test_trajectory_grid_invariance() {
let m21 = design_criterion(&synthetic_model(21), &[], DesignCriterion::Trajectory).unwrap();
let m51 = design_criterion(&synthetic_model(51), &[], DesignCriterion::Trajectory).unwrap();
let m101 =
design_criterion(&synthetic_model(101), &[], DesignCriterion::Trajectory).unwrap();
assert!((m21 - m51).abs() < 1e-10, "m21={m21} m51={m51}");
assert!((m51 - m101).abs() < 1e-10, "m51={m51} m101={m101}");
}
#[test]
fn test_trajectory_reduces_on_point() {
let model = synthetic_model(51);
let mse_empty = design_criterion(&model, &[], DesignCriterion::Trajectory).unwrap();
let mse_one = design_criterion(&model, &[25], DesignCriterion::Trajectory).unwrap();
assert!(
mse_one <= mse_empty + 1e-12,
"mse_one={mse_one} mse_empty={mse_empty}"
);
}
#[test]
fn test_monotonicity_trajectory() {
let model = synthetic_model(51);
let s0 = design_criterion(&model, &[10], DesignCriterion::Trajectory).unwrap();
let s1 = design_criterion(&model, &[10, 30], DesignCriterion::Trajectory).unwrap();
assert!(s1 <= s0 + 1e-12, "s1={s1} s0={s0}");
}
#[test]
fn test_validation_index_range() {
let model = synthetic_model(51);
let res = design_criterion(&model, &[51], DesignCriterion::Trajectory);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_validation_sigma2() {
let model = synthetic_model_params(51, vec![2.0, 1.0], 0.0);
let res = design_criterion(&model, &[0], DesignCriterion::Trajectory);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_validation_ncomp() {
let model = synthetic_model_params(51, vec![], 0.5);
let res = design_criterion(&model, &[0], DesignCriterion::Trajectory);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_ridge_retry() {
let model = synthetic_model_params(51, vec![2.0, 1.0], 1e-13);
let res = design_criterion(&model, &[10, 10], DesignCriterion::Trajectory);
assert!(
res.is_ok(),
"ridge-retry should rescue near-singular Σ_d: {res:?}"
);
let sigma_d = build_sigma_design(&model, &[10, 10]);
assert!(
crate::linalg::cholesky_factor(&sigma_d, 2).is_err(),
"test precondition: raw Σ_d must be non-PD so the retry branch is exercised"
);
}
#[test]
fn test_validation_grid_too_small() {
let model = PaceFpcaResult {
mean: vec![0.0; 1],
eigenvalues: vec![2.0, 1.0],
eigenfunctions: FdMatrix::from_column_major(vec![1.0, 0.5], 1, 2).unwrap(),
scores: FdMatrix::zeros(1, 2),
fitted: FdMatrix::zeros(1, 1),
fitted_lower: FdMatrix::zeros(1, 1),
fitted_upper: FdMatrix::zeros(1, 1),
argvals: vec![0.0],
sigma2: 0.5,
ncomp: 2,
};
let res = design_criterion(&model, &[], DesignCriterion::Trajectory);
assert!(
matches!(res, Err(FdarError::InvalidParameter { parameter, .. }) if parameter == "model.argvals"),
"m<2 must be rejected with InvalidParameter(model.argvals), got {res:?}"
);
}
#[test]
fn test_score_a_empty_set() {
let model = synthetic_model(51);
let a = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::A)).unwrap();
assert!((a - 3.0).abs() < 1e-10, "A(∅) = {a}, expected 3.0");
}
#[test]
fn test_score_d_empty_set() {
let model = synthetic_model(51);
let d = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::D)).unwrap();
let expected = 2.0_f64.ln();
assert!(
(d - expected).abs() < 1e-10,
"D(∅) = {d}, expected {expected}"
);
}
#[test]
fn test_score_prior_recovery() {
let model = synthetic_model(51);
let a = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::A)).unwrap();
let expected_a: f64 = model.eigenvalues.iter().sum();
assert!(
(a - expected_a).abs() < 1e-10,
"a={a} expected_a={expected_a}"
);
let d = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::D)).unwrap();
let expected_d: f64 = model.eigenvalues.iter().map(|&lam| lam.ln()).sum();
assert!(
(d - expected_d).abs() < 1e-10,
"d={d} expected_d={expected_d}"
);
}
#[test]
fn test_monotonicity_a_opt() {
let model = synthetic_model(51);
let s0 =
design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::A)).unwrap();
let s1 =
design_criterion(&model, &[10, 30], DesignCriterion::Score(OptimalityKind::A)).unwrap();
assert!(s1 <= s0 + 1e-12, "s1={s1} s0={s0}");
}
#[test]
fn test_monotonicity_d_opt() {
let model = synthetic_model(51);
let s0 =
design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::D)).unwrap();
let s1 =
design_criterion(&model, &[10, 30], DesignCriterion::Score(OptimalityKind::D)).unwrap();
assert!(s1 <= s0 + 1e-12, "s1={s1} s0={s0}");
}
#[test]
fn test_enum_dispatch() {
let model = synthetic_model(51);
let traj = design_criterion(&model, &[10], DesignCriterion::Trajectory).unwrap();
let a = design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::A)).unwrap();
let d = design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::D)).unwrap();
assert!(
traj.is_finite() && a.is_finite() && d.is_finite(),
"traj={traj} a={a} d={d}"
);
assert!(
(traj - a).abs() < 1e-9,
"orthonormal identity broken: traj={traj} a={a}"
);
assert!(
(d - a).abs() > 1e-9,
"D failed to route separately: d={d} a={a}"
);
assert!(
d < a,
"D-opt (log-det) should be below A-opt (trace) here: d={d} a={a}"
);
}
#[test]
fn test_optimal_design_basic() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 3,
criterion: DesignCriterion::Trajectory,
};
let r = optimal_design(&model, &config).unwrap();
assert_eq!(r.selected_indices.len(), 3);
assert_eq!(r.selected_argvals.len(), 3);
assert_eq!(r.criterion_trace.len(), 3);
}
#[test]
fn test_determinism_two_calls() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 3,
criterion: DesignCriterion::Trajectory,
};
let r1 = optimal_design(&model, &config).expect("first call");
let r2 = optimal_design(&model, &config).expect("second call");
assert_eq!(
r1.selected_indices, r2.selected_indices,
"selection must be deterministic"
);
assert_eq!(
r1.criterion_trace, r2.criterion_trace,
"trace must be deterministic"
);
}
#[test]
fn test_duplicate_free() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 5,
criterion: DesignCriterion::Trajectory,
};
let r = optimal_design(&model, &config).unwrap();
let mut sorted = r.selected_indices.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
r.selected_indices.len(),
"no index may appear twice: {:?}",
r.selected_indices
);
}
#[test]
fn test_monotone_trace() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 5,
criterion: DesignCriterion::Trajectory,
};
let r = optimal_design(&model, &config).unwrap();
for w in r.criterion_trace.windows(2) {
assert!(
w[1] <= w[0] + 1e-12,
"trace not monotone non-increasing: {:?}",
r.criterion_trace
);
}
}
#[test]
fn test_validation_budget_zero() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 0,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_validation_budget_exceeds_grid() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: vec![model.argvals[0], model.argvals[1]],
budget: 3,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_validation_off_grid_candidate() {
let model = synthetic_model(51);
let off_grid = model.argvals[0] + 0.5 / (51.0 - 1.0);
let config = OptDesConfig {
candidate_grid: vec![off_grid],
budget: 1,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_validation_ncomp_zero() {
let model = synthetic_model_params(51, vec![], 0.5);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 1,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_validation_sigma2_nonpositive() {
let model = synthetic_model_params(51, vec![2.0, 1.0], 0.0);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 1,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
}
#[test]
fn test_trajectory_selects_informative_point() {
let model = synthetic_model(51);
let m = model.argvals.len();
let mut best: Option<(usize, f64)> = None;
for idx in 0..m {
let val = design_criterion(&model, &[idx], DesignCriterion::Trajectory).unwrap();
best = Some(match best {
None => (idx, val),
Some((bi, bv)) => {
if val < bv {
(idx, val)
} else {
(bi, bv)
}
}
});
}
let expected_first = best.unwrap().0;
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 2,
criterion: DesignCriterion::Trajectory,
};
let r = optimal_design(&model, &config).unwrap();
assert_eq!(
r.selected_indices[0], expected_first,
"first greedy pick must equal the numerically-computed argmin"
);
}
#[test]
fn test_score_a_selects() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: model.argvals.clone(),
budget: 2,
criterion: DesignCriterion::Score(OptimalityKind::A),
};
let r = optimal_design(&model, &config).unwrap();
assert_eq!(r.selected_indices.len(), 2);
assert_eq!(r.criterion_trace.len(), 2);
for w in r.criterion_trace.windows(2) {
assert!(w[1] <= w[0] + 1e-12, "Score(A) trace not non-increasing");
}
}
#[test]
fn test_config_default() {
let config = OptDesConfig::default();
assert_eq!(config.budget, 1);
assert!(config.candidate_grid.is_empty());
assert_eq!(config.criterion, DesignCriterion::Trajectory);
let model = synthetic_model(51);
let res = optimal_design(&model, &config);
assert!(
matches!(res, Err(FdarError::InvalidParameter { .. })),
"empty grid + budget 1 must fail at call time (budget > grid.len())"
);
}
#[test]
fn test_prelude_reexport() {
assert_eq!(OptDesConfig::default().budget, 1);
}
#[test]
fn test_validation_duplicate_candidates() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: vec![0.0, 0.0],
budget: 2,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(
matches!(res, Err(FdarError::InvalidParameter { parameter, .. }) if parameter == "config.candidate_grid"),
"duplicate candidates with budget > distinct count must be InvalidParameter, got {res:?}"
);
}
#[test]
fn test_validation_distinct_fewer_than_budget() {
let model = synthetic_model(51);
let config = OptDesConfig {
candidate_grid: vec![model.argvals[0], model.argvals[10], model.argvals[0]],
budget: 3,
criterion: DesignCriterion::Trajectory,
};
let res = optimal_design(&model, &config);
assert!(
matches!(res, Err(FdarError::InvalidParameter { parameter, .. }) if parameter == "config.candidate_grid"),
"distinct-but-fewer-than-budget must be InvalidParameter, got {res:?}"
);
}
#[test]
fn test_tiebreak_smallest_index_permutation_invariant() {
let model = synthetic_model(51);
let ascending: Vec<f64> = model.argvals.clone();
let mut shuffled = ascending.clone();
shuffled.reverse();
let cfg_asc = OptDesConfig {
candidate_grid: ascending,
budget: 4,
criterion: DesignCriterion::Trajectory,
};
let cfg_shuf = OptDesConfig {
candidate_grid: shuffled,
budget: 4,
criterion: DesignCriterion::Trajectory,
};
let r_asc = optimal_design(&model, &cfg_asc).unwrap();
let r_shuf = optimal_design(&model, &cfg_shuf).unwrap();
assert_eq!(
r_asc.selected_indices, r_shuf.selected_indices,
"selection must be invariant to candidate_grid ordering (smallest-index tie-break)"
);
assert_eq!(
r_asc.criterion_trace, r_shuf.criterion_trace,
"trace must be invariant to candidate_grid ordering"
);
}
#[test]
fn test_tiebreak_symmetric_model_smallest_index() {
let m = 51usize;
let mut model = synthetic_model_params(m, vec![2.0], 0.5);
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let weights = simpsons_weights(&argvals);
let raw: Vec<f64> = argvals
.iter()
.map(|&t| (std::f64::consts::PI * t).cos())
.collect();
let norm = (0..m)
.map(|j| weights[j] * raw[j] * raw[j])
.sum::<f64>()
.sqrt();
let ef: Vec<f64> = raw.iter().map(|&v| v / norm).collect();
model.eigenfunctions = FdMatrix::from_column_major(ef, m, 1).unwrap();
let j = 10usize;
let mirror = m - 1 - j; assert!(mirror > j);
let vj = design_criterion(&model, &[j], DesignCriterion::Trajectory).unwrap();
let vm = design_criterion(&model, &[mirror], DesignCriterion::Trajectory).unwrap();
assert!(
(vj - vm).abs() < 1e-12,
"expected a genuine tie: v[{j}]={vj} v[{mirror}]={vm}"
);
let config = OptDesConfig {
candidate_grid: vec![model.argvals[mirror], model.argvals[j]],
budget: 1,
criterion: DesignCriterion::Trajectory,
};
let r = optimal_design(&model, &config).unwrap();
assert_eq!(
r.selected_indices[0], j,
"tie must resolve to the smallest argvals index ({j}), not first-in-grid-order ({mirror})"
);
}
}