1use crate::error::FdarError;
76use crate::helpers::simpsons_weights;
77use crate::iter_maybe_parallel;
78use crate::linalg::{cholesky_factor, cholesky_forward_back, log_det_from_cholesky};
83use crate::pace_fpca::PaceFpcaResult;
84
85#[non_exhaustive]
91#[derive(Debug, Clone, PartialEq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub enum DesignCriterion {
94 Trajectory,
97 Score(OptimalityKind),
99}
100
101#[non_exhaustive]
103#[derive(Debug, Clone, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub enum OptimalityKind {
106 A,
109 D,
117}
118
119#[must_use = "expensive computation whose result should not be discarded"]
140pub fn design_criterion(
141 model: &PaceFpcaResult,
142 selected: &[usize],
143 criterion: DesignCriterion,
144) -> Result<f64, FdarError> {
145 let m = model.argvals.len();
147 if model.ncomp == 0 {
148 return Err(FdarError::InvalidParameter {
149 parameter: "model.ncomp",
150 message: "ncomp must be > 0; the model has no FPC components".into(),
151 });
152 }
153 if model.eigenvalues.len() < model.ncomp {
154 return Err(FdarError::InvalidParameter {
155 parameter: "model.eigenvalues",
156 message: format!(
157 "eigenvalues length {} is smaller than ncomp {}",
158 model.eigenvalues.len(),
159 model.ncomp
160 ),
161 });
162 }
163 if model.sigma2 <= 0.0 {
164 return Err(FdarError::InvalidParameter {
165 parameter: "model.sigma2",
166 message: format!("sigma2 must be > 0; got {}", model.sigma2),
167 });
168 }
169 if m < 2 {
170 return Err(FdarError::InvalidParameter {
171 parameter: "model.argvals",
172 message: format!(
173 "argvals must have length >= 2 (a trajectory integral / Simpson quadrature is undefined for m < 2); got {m}"
174 ),
175 });
176 }
177 for &idx in selected {
178 if idx >= m {
179 return Err(FdarError::InvalidParameter {
180 parameter: "selected",
181 message: format!("index {idx} is out of range for argvals of length {m}"),
182 });
183 }
184 }
185
186 match criterion {
188 DesignCriterion::Trajectory => trajectory_criterion(model, selected),
189 DesignCriterion::Score(kind) => score_criterion(model, selected, kind),
190 }
191}
192
193#[derive(Debug, Clone, PartialEq)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
208pub struct OptDesConfig {
209 pub candidate_grid: Vec<f64>,
215 pub budget: usize,
218 pub criterion: DesignCriterion,
220}
221
222impl Default for OptDesConfig {
223 fn default() -> Self {
224 Self {
225 candidate_grid: vec![],
226 budget: 1,
227 criterion: DesignCriterion::Trajectory,
228 }
229 }
230}
231
232#[derive(Debug, Clone, PartialEq)]
237#[non_exhaustive]
238#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
239pub struct OptDesResult {
240 pub selected_indices: Vec<usize>,
243 pub selected_argvals: Vec<f64>,
246 pub criterion_trace: Vec<f64>,
249}
250
251fn map_candidates_to_indices(
262 candidate_grid: &[f64],
263 argvals: &[f64],
264) -> Result<Vec<usize>, FdarError> {
265 candidate_grid
266 .iter()
267 .map(|&cand| {
268 argvals
269 .iter()
270 .position(|&t| (t - cand).abs() < 1e-9)
271 .ok_or_else(|| FdarError::InvalidParameter {
272 parameter: "config.candidate_grid",
273 message: format!(
274 "candidate {cand:.6} not found in model.argvals within tolerance 1e-9"
275 ),
276 })
277 })
278 .collect()
279}
280
281#[must_use = "expensive computation whose result should not be discarded"]
318pub fn optimal_design(
319 model: &PaceFpcaResult,
320 config: &OptDesConfig,
321) -> Result<OptDesResult, FdarError> {
322 if config.budget == 0 {
324 return Err(FdarError::InvalidParameter {
325 parameter: "config.budget",
326 message: "budget must be > 0".into(),
327 });
328 }
329 if config.budget > config.candidate_grid.len() {
330 return Err(FdarError::InvalidParameter {
331 parameter: "config.budget",
332 message: format!(
333 "budget {} exceeds the number of candidate points {}",
334 config.budget,
335 config.candidate_grid.len()
336 ),
337 });
338 }
339 if model.ncomp == 0 {
340 return Err(FdarError::InvalidParameter {
341 parameter: "model.ncomp",
342 message: "ncomp must be > 0; the model has no FPC components".into(),
343 });
344 }
345 if model.sigma2 <= 0.0 {
346 return Err(FdarError::InvalidParameter {
347 parameter: "model.sigma2",
348 message: format!("sigma2 must be > 0; got {}", model.sigma2),
349 });
350 }
351
352 let candidate_indices = {
360 let mut v = map_candidates_to_indices(&config.candidate_grid, &model.argvals)?;
361 v.sort_unstable();
362 v.dedup();
363 v
364 };
365
366 if config.budget > candidate_indices.len() {
371 return Err(FdarError::InvalidParameter {
372 parameter: "config.candidate_grid",
373 message: format!(
374 "budget {} exceeds the number of distinct on-grid candidate points {} \
375 (duplicate or near-duplicate candidate_grid values collapse onto the \
376 same model.argvals index)",
377 config.budget,
378 candidate_indices.len()
379 ),
380 });
381 }
382
383 let mut selected: Vec<usize> = Vec::with_capacity(config.budget);
384 let mut trace: Vec<f64> = Vec::with_capacity(config.budget);
385
386 for _step in 0..config.budget {
387 let remaining: Vec<usize> = candidate_indices
389 .iter()
390 .copied()
391 .filter(|idx| !selected.contains(idx))
392 .collect();
393
394 #[cfg(feature = "parallel")]
398 use rayon::iter::ParallelIterator;
399 let scores: Vec<(usize, f64)> = iter_maybe_parallel!(remaining)
400 .map(|idx| {
401 let mut trial = selected.clone();
402 trial.push(idx);
403 let val = design_criterion(model, &trial, config.criterion.clone())?;
404 Ok::<(usize, f64), FdarError>((idx, val))
405 })
406 .collect::<Result<Vec<_>, _>>()?;
407
408 let (best_idx, best_val) = scores
416 .into_iter()
417 .fold(None::<(usize, f64)>, |acc, (idx, val)| {
418 Some(match acc {
419 None => (idx, val),
420 Some((bi, bv)) => {
421 if val < bv {
422 (idx, val)
423 } else {
424 (bi, bv)
425 }
426 }
427 })
428 })
429 .ok_or_else(|| FdarError::InvalidParameter {
430 parameter: "config.candidate_grid",
431 message: "distinct candidate pool exhausted before budget was reached \
432 (no remaining candidates at a greedy step)"
433 .into(),
434 })?;
435
436 selected.push(best_idx);
437 trace.push(best_val);
438 }
439
440 let selected_argvals = selected.iter().map(|&i| model.argvals[i]).collect();
441 Ok(OptDesResult {
442 selected_indices: selected,
443 selected_argvals,
444 criterion_trace: trace,
445 })
446}
447
448fn build_sigma_design(model: &PaceFpcaResult, selected: &[usize]) -> Vec<f64> {
454 let p = selected.len();
455 let ncomp = model.ncomp;
456 let mut sigma_d = vec![0.0_f64; p * p];
457 for row in 0..p {
458 let j_row = selected[row];
459 for col in 0..p {
460 let j_col = selected[col];
461 let mut s = 0.0_f64;
462 for k in 0..ncomp {
463 s += model.eigenfunctions[(j_row, k)]
464 * model.eigenvalues[k]
465 * model.eigenfunctions[(j_col, k)];
466 }
467 sigma_d[row * p + col] = s;
468 }
469 sigma_d[row * p + row] += model.sigma2; }
471 sigma_d
472}
473
474fn factor_sigma_design_with_retry(mut sigma_d: Vec<f64>, p: usize) -> Result<Vec<f64>, FdarError> {
479 match cholesky_factor(&sigma_d, p) {
480 Ok(l) => Ok(l),
481 Err(_) => {
482 for i in 0..p {
483 sigma_d[i * p + i] += 1e-8;
484 }
485 cholesky_factor(&sigma_d, p).map_err(|_| FdarError::ComputationFailed {
486 operation: "optimal_design Sigma_d Cholesky",
487 detail: "Cholesky failed after 1e-8 ridge; sigma2 may be too small".into(),
488 })
489 }
490 }
491}
492
493fn factor_posterior_cov_with_retry(mut cov: Vec<f64>, ncomp: usize) -> Result<Vec<f64>, FdarError> {
501 match cholesky_factor(&cov, ncomp) {
502 Ok(l) => Ok(l),
503 Err(_) => {
504 let scale: f64 = (0..ncomp)
507 .map(|k| cov[k * ncomp + k].abs())
508 .fold(0.0_f64, f64::max)
509 .max(1.0);
510 let ridge = 1e-8 * scale;
511 for i in 0..ncomp {
512 cov[i * ncomp + i] += ridge;
513 }
514 cholesky_factor(&cov, ncomp).map_err(|_| FdarError::ComputationFailed {
515 operation: "optimal_design D-optimality log-det",
516 detail: "posterior covariance Cholesky failed after ridge; \
517 model may be near-degenerate"
518 .into(),
519 })
520 }
521 }
522}
523
524fn build_phi_d(model: &PaceFpcaResult, selected: &[usize]) -> Vec<f64> {
527 let p = selected.len();
528 let ncomp = model.ncomp;
529 let mut phi_d = vec![0.0_f64; p * ncomp];
530 for (i, &j) in selected.iter().enumerate() {
531 for k in 0..ncomp {
532 phi_d[i * ncomp + k] = model.eigenfunctions[(j, k)];
533 }
534 }
535 phi_d
536}
537
538fn trajectory_criterion(model: &PaceFpcaResult, selected: &[usize]) -> Result<f64, FdarError> {
541 let m = model.argvals.len();
542 let ncomp = model.ncomp;
543 let p = selected.len();
544 let weights = simpsons_weights(&model.argvals);
545
546 if p == 0 {
548 let mut mse = 0.0_f64;
549 for j in 0..m {
550 let prior_var: f64 = (0..ncomp)
551 .map(|k| model.eigenvalues[k] * model.eigenfunctions[(j, k)].powi(2))
552 .sum();
553 mse += weights[j] * prior_var;
554 }
555 return Ok(mse);
556 }
557
558 let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
560 let phi_d = build_phi_d(model, selected); let mut mse = 0.0_f64;
563 let mut rhs = vec![0.0_f64; p];
564 for j in 0..m {
565 let prior_var: f64 = (0..ncomp)
567 .map(|k| model.eigenvalues[k] * model.eigenfunctions[(j, k)].powi(2))
568 .sum();
569
570 for (i, r) in rhs.iter_mut().enumerate() {
572 let mut s = 0.0_f64;
573 for k in 0..ncomp {
574 s += model.eigenvalues[k] * model.eigenfunctions[(j, k)] * phi_d[i * ncomp + k];
575 }
576 *r = s;
577 }
578
579 let v = cholesky_forward_back(&l, &rhs, p);
581 let reduction: f64 = rhs.iter().zip(v.iter()).map(|(&a, &b)| a * b).sum();
582
583 mse += weights[j] * (prior_var - reduction);
584 }
585 Ok(mse)
586}
587
588fn score_criterion(
591 model: &PaceFpcaResult,
592 selected: &[usize],
593 kind: OptimalityKind,
594) -> Result<f64, FdarError> {
595 let ncomp = model.ncomp;
596 let p = selected.len();
597
598 if p == 0 {
600 return match kind {
601 OptimalityKind::A => Ok(model.eigenvalues.iter().take(ncomp).sum()),
602 OptimalityKind::D => {
603 let mut s = 0.0_f64;
604 for &lam in model.eigenvalues.iter().take(ncomp) {
605 if lam <= 0.0 {
606 return Err(FdarError::ComputationFailed {
607 operation: "optimal_design D-optimality",
608 detail: "non-positive eigenvalue in prior".into(),
609 });
610 }
611 s += lam.ln();
612 }
613 Ok(s)
614 }
615 };
616 }
617
618 let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
620 let phi_d = build_phi_d(model, selected); let mut sigma_inv_phi_lam = vec![0.0_f64; p * ncomp];
624 let mut phi_col = vec![0.0_f64; p];
625 for k in 0..ncomp {
626 for (i, c) in phi_col.iter_mut().enumerate() {
627 *c = phi_d[i * ncomp + k];
628 }
629 let sol = cholesky_forward_back(&l, &phi_col, p);
630 for j in 0..p {
631 sigma_inv_phi_lam[j * ncomp + k] = model.eigenvalues[k] * sol[j];
632 }
633 }
634
635 let mut a_mat = vec![0.0_f64; ncomp * ncomp];
637 for k in 0..ncomp {
638 for l in 0..ncomp {
639 let mut s = 0.0_f64;
640 for j in 0..p {
641 s += phi_d[j * ncomp + k] * sigma_inv_phi_lam[j * ncomp + l];
642 }
643 a_mat[k * ncomp + l] = model.eigenvalues[k] * s;
644 }
645 }
646
647 let mut cov = vec![0.0_f64; ncomp * ncomp];
649 for k in 0..ncomp {
650 for l in 0..ncomp {
651 let prior = if k == l { model.eigenvalues[k] } else { 0.0 };
652 cov[k * ncomp + l] = prior - a_mat[k * ncomp + l];
653 }
654 }
655
656 match kind {
657 OptimalityKind::A => {
658 let tr: f64 = (0..ncomp).map(|k| cov[k * ncomp + k]).sum();
660 Ok(tr)
661 }
662 OptimalityKind::D => {
663 let l_cov = factor_posterior_cov_with_retry(cov, ncomp)?;
672 Ok(log_det_from_cholesky(&l_cov, ncomp))
673 }
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::matrix::FdMatrix;
681
682 fn synthetic_model(m: usize) -> PaceFpcaResult {
689 synthetic_model_params(m, vec![2.0, 1.0], 0.5)
690 }
691
692 fn synthetic_model_params(m: usize, eigenvalues: Vec<f64>, sigma2: f64) -> PaceFpcaResult {
693 let ncomp = eigenvalues.len();
694 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
695 let weights = simpsons_weights(&argvals);
696
697 let mut ef = vec![0.0_f64; m * ncomp];
700 for k in 0..ncomp {
701 let freq = (k + 1) as f64 * std::f64::consts::PI;
702 let raw: Vec<f64> = argvals.iter().map(|&t| (freq * t).cos()).collect();
703 let norm_sq: f64 = (0..m).map(|j| weights[j] * raw[j] * raw[j]).sum();
704 let norm = norm_sq.sqrt();
705 for j in 0..m {
706 ef[j + k * m] = raw[j] / norm;
708 }
709 }
710 let eigenfunctions = FdMatrix::from_column_major(ef, m, ncomp).unwrap();
711
712 PaceFpcaResult {
713 mean: vec![0.0; m],
714 eigenvalues,
715 eigenfunctions,
716 scores: FdMatrix::zeros(1, ncomp),
717 fitted: FdMatrix::zeros(1, m),
718 fitted_lower: FdMatrix::zeros(1, m),
719 fitted_upper: FdMatrix::zeros(1, m),
720 argvals,
721 sigma2,
722 ncomp,
723 }
724 }
725
726 #[test]
729 fn test_trajectory_empty_set() {
730 let model = synthetic_model(51);
731 let mse = design_criterion(&model, &[], DesignCriterion::Trajectory).unwrap();
732 assert!((mse - 3.0).abs() < 1e-10, "MSE(∅) = {mse}, expected 3.0");
734 }
735
736 #[test]
737 fn test_trajectory_grid_invariance() {
738 let m21 = design_criterion(&synthetic_model(21), &[], DesignCriterion::Trajectory).unwrap();
739 let m51 = design_criterion(&synthetic_model(51), &[], DesignCriterion::Trajectory).unwrap();
740 let m101 =
741 design_criterion(&synthetic_model(101), &[], DesignCriterion::Trajectory).unwrap();
742 assert!((m21 - m51).abs() < 1e-10, "m21={m21} m51={m51}");
743 assert!((m51 - m101).abs() < 1e-10, "m51={m51} m101={m101}");
744 }
745
746 #[test]
747 fn test_trajectory_reduces_on_point() {
748 let model = synthetic_model(51);
749 let mse_empty = design_criterion(&model, &[], DesignCriterion::Trajectory).unwrap();
750 let mse_one = design_criterion(&model, &[25], DesignCriterion::Trajectory).unwrap();
751 assert!(
752 mse_one <= mse_empty + 1e-12,
753 "mse_one={mse_one} mse_empty={mse_empty}"
754 );
755 }
756
757 #[test]
758 fn test_monotonicity_trajectory() {
759 let model = synthetic_model(51);
760 let s0 = design_criterion(&model, &[10], DesignCriterion::Trajectory).unwrap();
761 let s1 = design_criterion(&model, &[10, 30], DesignCriterion::Trajectory).unwrap();
762 assert!(s1 <= s0 + 1e-12, "s1={s1} s0={s0}");
763 }
764
765 #[test]
766 fn test_validation_index_range() {
767 let model = synthetic_model(51);
768 let res = design_criterion(&model, &[51], DesignCriterion::Trajectory);
769 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
770 }
771
772 #[test]
773 fn test_validation_sigma2() {
774 let model = synthetic_model_params(51, vec![2.0, 1.0], 0.0);
775 let res = design_criterion(&model, &[0], DesignCriterion::Trajectory);
776 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
777 }
778
779 #[test]
780 fn test_validation_ncomp() {
781 let model = synthetic_model_params(51, vec![], 0.5);
783 let res = design_criterion(&model, &[0], DesignCriterion::Trajectory);
784 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
785 }
786
787 #[test]
788 fn test_ridge_retry() {
789 let model = synthetic_model_params(51, vec![2.0, 1.0], 1e-13);
797 let res = design_criterion(&model, &[10, 10], DesignCriterion::Trajectory);
798 assert!(
799 res.is_ok(),
800 "ridge-retry should rescue near-singular Σ_d: {res:?}"
801 );
802 let sigma_d = build_sigma_design(&model, &[10, 10]);
806 assert!(
807 crate::linalg::cholesky_factor(&sigma_d, 2).is_err(),
808 "test precondition: raw Σ_d must be non-PD so the retry branch is exercised"
809 );
810 }
811
812 #[test]
813 fn test_validation_grid_too_small() {
814 let model = PaceFpcaResult {
817 mean: vec![0.0; 1],
818 eigenvalues: vec![2.0, 1.0],
819 eigenfunctions: FdMatrix::from_column_major(vec![1.0, 0.5], 1, 2).unwrap(),
820 scores: FdMatrix::zeros(1, 2),
821 fitted: FdMatrix::zeros(1, 1),
822 fitted_lower: FdMatrix::zeros(1, 1),
823 fitted_upper: FdMatrix::zeros(1, 1),
824 argvals: vec![0.0],
825 sigma2: 0.5,
826 ncomp: 2,
827 };
828 let res = design_criterion(&model, &[], DesignCriterion::Trajectory);
829 assert!(
830 matches!(res, Err(FdarError::InvalidParameter { parameter, .. }) if parameter == "model.argvals"),
831 "m<2 must be rejected with InvalidParameter(model.argvals), got {res:?}"
832 );
833 }
834
835 #[test]
838 fn test_score_a_empty_set() {
839 let model = synthetic_model(51);
840 let a = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::A)).unwrap();
841 assert!((a - 3.0).abs() < 1e-10, "A(∅) = {a}, expected 3.0");
843 }
844
845 #[test]
846 fn test_score_d_empty_set() {
847 let model = synthetic_model(51);
848 let d = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::D)).unwrap();
849 let expected = 2.0_f64.ln();
851 assert!(
852 (d - expected).abs() < 1e-10,
853 "D(∅) = {d}, expected {expected}"
854 );
855 }
856
857 #[test]
858 fn test_score_prior_recovery() {
859 let model = synthetic_model(51);
860 let a = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::A)).unwrap();
861 let expected_a: f64 = model.eigenvalues.iter().sum();
862 assert!(
863 (a - expected_a).abs() < 1e-10,
864 "a={a} expected_a={expected_a}"
865 );
866
867 let d = design_criterion(&model, &[], DesignCriterion::Score(OptimalityKind::D)).unwrap();
868 let expected_d: f64 = model.eigenvalues.iter().map(|&lam| lam.ln()).sum();
869 assert!(
870 (d - expected_d).abs() < 1e-10,
871 "d={d} expected_d={expected_d}"
872 );
873 }
874
875 #[test]
876 fn test_monotonicity_a_opt() {
877 let model = synthetic_model(51);
878 let s0 =
879 design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::A)).unwrap();
880 let s1 =
881 design_criterion(&model, &[10, 30], DesignCriterion::Score(OptimalityKind::A)).unwrap();
882 assert!(s1 <= s0 + 1e-12, "s1={s1} s0={s0}");
883 }
884
885 #[test]
886 fn test_monotonicity_d_opt() {
887 let model = synthetic_model(51);
888 let s0 =
889 design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::D)).unwrap();
890 let s1 =
891 design_criterion(&model, &[10, 30], DesignCriterion::Score(OptimalityKind::D)).unwrap();
892 assert!(s1 <= s0 + 1e-12, "s1={s1} s0={s0}");
893 }
894
895 #[test]
896 fn test_enum_dispatch() {
897 let model = synthetic_model(51);
898 let traj = design_criterion(&model, &[10], DesignCriterion::Trajectory).unwrap();
899 let a = design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::A)).unwrap();
900 let d = design_criterion(&model, &[10], DesignCriterion::Score(OptimalityKind::D)).unwrap();
901 assert!(
902 traj.is_finite() && a.is_finite() && d.is_finite(),
903 "traj={traj} a={a} d={d}"
904 );
905 assert!(
912 (traj - a).abs() < 1e-9,
913 "orthonormal identity broken: traj={traj} a={a}"
914 );
915 assert!(
916 (d - a).abs() > 1e-9,
917 "D failed to route separately: d={d} a={a}"
918 );
919 assert!(
920 d < a,
921 "D-opt (log-det) should be below A-opt (trace) here: d={d} a={a}"
922 );
923 }
924
925 #[test]
928 fn test_optimal_design_basic() {
929 let model = synthetic_model(51);
930 let config = OptDesConfig {
931 candidate_grid: model.argvals.clone(),
932 budget: 3,
933 criterion: DesignCriterion::Trajectory,
934 };
935 let r = optimal_design(&model, &config).unwrap();
936 assert_eq!(r.selected_indices.len(), 3);
937 assert_eq!(r.selected_argvals.len(), 3);
938 assert_eq!(r.criterion_trace.len(), 3);
939 }
940
941 #[test]
942 fn test_determinism_two_calls() {
943 let model = synthetic_model(51);
946 let config = OptDesConfig {
947 candidate_grid: model.argvals.clone(),
948 budget: 3,
949 criterion: DesignCriterion::Trajectory,
950 };
951 let r1 = optimal_design(&model, &config).expect("first call");
952 let r2 = optimal_design(&model, &config).expect("second call");
953 assert_eq!(
954 r1.selected_indices, r2.selected_indices,
955 "selection must be deterministic"
956 );
957 assert_eq!(
958 r1.criterion_trace, r2.criterion_trace,
959 "trace must be deterministic"
960 );
961 }
962
963 #[test]
964 fn test_duplicate_free() {
965 let model = synthetic_model(51);
966 let config = OptDesConfig {
967 candidate_grid: model.argvals.clone(),
968 budget: 5,
969 criterion: DesignCriterion::Trajectory,
970 };
971 let r = optimal_design(&model, &config).unwrap();
972 let mut sorted = r.selected_indices.clone();
973 sorted.sort_unstable();
974 sorted.dedup();
975 assert_eq!(
976 sorted.len(),
977 r.selected_indices.len(),
978 "no index may appear twice: {:?}",
979 r.selected_indices
980 );
981 }
982
983 #[test]
984 fn test_monotone_trace() {
985 let model = synthetic_model(51);
986 let config = OptDesConfig {
987 candidate_grid: model.argvals.clone(),
988 budget: 5,
989 criterion: DesignCriterion::Trajectory,
990 };
991 let r = optimal_design(&model, &config).unwrap();
992 for w in r.criterion_trace.windows(2) {
993 assert!(
994 w[1] <= w[0] + 1e-12,
995 "trace not monotone non-increasing: {:?}",
996 r.criterion_trace
997 );
998 }
999 }
1000
1001 #[test]
1002 fn test_validation_budget_zero() {
1003 let model = synthetic_model(51);
1004 let config = OptDesConfig {
1005 candidate_grid: model.argvals.clone(),
1006 budget: 0,
1007 criterion: DesignCriterion::Trajectory,
1008 };
1009 let res = optimal_design(&model, &config);
1010 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
1011 }
1012
1013 #[test]
1014 fn test_validation_budget_exceeds_grid() {
1015 let model = synthetic_model(51);
1016 let config = OptDesConfig {
1017 candidate_grid: vec![model.argvals[0], model.argvals[1]],
1018 budget: 3,
1019 criterion: DesignCriterion::Trajectory,
1020 };
1021 let res = optimal_design(&model, &config);
1022 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
1023 }
1024
1025 #[test]
1026 fn test_validation_off_grid_candidate() {
1027 let model = synthetic_model(51);
1028 let off_grid = model.argvals[0] + 0.5 / (51.0 - 1.0);
1030 let config = OptDesConfig {
1031 candidate_grid: vec![off_grid],
1032 budget: 1,
1033 criterion: DesignCriterion::Trajectory,
1034 };
1035 let res = optimal_design(&model, &config);
1036 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
1037 }
1038
1039 #[test]
1040 fn test_validation_ncomp_zero() {
1041 let model = synthetic_model_params(51, vec![], 0.5);
1044 let config = OptDesConfig {
1045 candidate_grid: model.argvals.clone(),
1046 budget: 1,
1047 criterion: DesignCriterion::Trajectory,
1048 };
1049 let res = optimal_design(&model, &config);
1050 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
1051 }
1052
1053 #[test]
1054 fn test_validation_sigma2_nonpositive() {
1055 let model = synthetic_model_params(51, vec![2.0, 1.0], 0.0);
1056 let config = OptDesConfig {
1057 candidate_grid: model.argvals.clone(),
1058 budget: 1,
1059 criterion: DesignCriterion::Trajectory,
1060 };
1061 let res = optimal_design(&model, &config);
1062 assert!(matches!(res, Err(FdarError::InvalidParameter { .. })));
1063 }
1064
1065 #[test]
1066 fn test_trajectory_selects_informative_point() {
1067 let model = synthetic_model(51);
1068 let m = model.argvals.len();
1069 let mut best: Option<(usize, f64)> = None;
1072 for idx in 0..m {
1073 let val = design_criterion(&model, &[idx], DesignCriterion::Trajectory).unwrap();
1074 best = Some(match best {
1075 None => (idx, val),
1076 Some((bi, bv)) => {
1077 if val < bv {
1078 (idx, val)
1079 } else {
1080 (bi, bv)
1081 }
1082 }
1083 });
1084 }
1085 let expected_first = best.unwrap().0;
1086
1087 let config = OptDesConfig {
1088 candidate_grid: model.argvals.clone(),
1089 budget: 2,
1090 criterion: DesignCriterion::Trajectory,
1091 };
1092 let r = optimal_design(&model, &config).unwrap();
1093 assert_eq!(
1094 r.selected_indices[0], expected_first,
1095 "first greedy pick must equal the numerically-computed argmin"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_score_a_selects() {
1101 let model = synthetic_model(51);
1102 let config = OptDesConfig {
1103 candidate_grid: model.argvals.clone(),
1104 budget: 2,
1105 criterion: DesignCriterion::Score(OptimalityKind::A),
1106 };
1107 let r = optimal_design(&model, &config).unwrap();
1108 assert_eq!(r.selected_indices.len(), 2);
1109 assert_eq!(r.criterion_trace.len(), 2);
1110 for w in r.criterion_trace.windows(2) {
1111 assert!(w[1] <= w[0] + 1e-12, "Score(A) trace not non-increasing");
1112 }
1113 }
1114
1115 #[test]
1116 fn test_config_default() {
1117 let config = OptDesConfig::default();
1120 assert_eq!(config.budget, 1);
1121 assert!(config.candidate_grid.is_empty());
1122 assert_eq!(config.criterion, DesignCriterion::Trajectory);
1123 let model = synthetic_model(51);
1124 let res = optimal_design(&model, &config);
1125 assert!(
1126 matches!(res, Err(FdarError::InvalidParameter { .. })),
1127 "empty grid + budget 1 must fail at call time (budget > grid.len())"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_prelude_reexport() {
1133 assert_eq!(OptDesConfig::default().budget, 1);
1136 }
1137
1138 #[test]
1139 fn test_validation_duplicate_candidates() {
1140 let model = synthetic_model(51);
1141 let config = OptDesConfig {
1145 candidate_grid: vec![0.0, 0.0],
1146 budget: 2,
1147 criterion: DesignCriterion::Trajectory,
1148 };
1149 let res = optimal_design(&model, &config);
1150 assert!(
1151 matches!(res, Err(FdarError::InvalidParameter { parameter, .. }) if parameter == "config.candidate_grid"),
1152 "duplicate candidates with budget > distinct count must be InvalidParameter, got {res:?}"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_validation_distinct_fewer_than_budget() {
1158 let model = synthetic_model(51);
1159 let config = OptDesConfig {
1162 candidate_grid: vec![model.argvals[0], model.argvals[10], model.argvals[0]],
1163 budget: 3,
1164 criterion: DesignCriterion::Trajectory,
1165 };
1166 let res = optimal_design(&model, &config);
1167 assert!(
1168 matches!(res, Err(FdarError::InvalidParameter { parameter, .. }) if parameter == "config.candidate_grid"),
1169 "distinct-but-fewer-than-budget must be InvalidParameter, got {res:?}"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_tiebreak_smallest_index_permutation_invariant() {
1175 let model = synthetic_model(51);
1180 let ascending: Vec<f64> = model.argvals.clone();
1181 let mut shuffled = ascending.clone();
1182 shuffled.reverse(); let cfg_asc = OptDesConfig {
1185 candidate_grid: ascending,
1186 budget: 4,
1187 criterion: DesignCriterion::Trajectory,
1188 };
1189 let cfg_shuf = OptDesConfig {
1190 candidate_grid: shuffled,
1191 budget: 4,
1192 criterion: DesignCriterion::Trajectory,
1193 };
1194 let r_asc = optimal_design(&model, &cfg_asc).unwrap();
1195 let r_shuf = optimal_design(&model, &cfg_shuf).unwrap();
1196 assert_eq!(
1197 r_asc.selected_indices, r_shuf.selected_indices,
1198 "selection must be invariant to candidate_grid ordering (smallest-index tie-break)"
1199 );
1200 assert_eq!(
1201 r_asc.criterion_trace, r_shuf.criterion_trace,
1202 "trace must be invariant to candidate_grid ordering"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_tiebreak_symmetric_model_smallest_index() {
1208 let m = 51usize;
1213 let mut model = synthetic_model_params(m, vec![2.0], 0.5);
1214 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1216 let weights = simpsons_weights(&argvals);
1217 let raw: Vec<f64> = argvals
1218 .iter()
1219 .map(|&t| (std::f64::consts::PI * t).cos())
1220 .collect();
1221 let norm = (0..m)
1222 .map(|j| weights[j] * raw[j] * raw[j])
1223 .sum::<f64>()
1224 .sqrt();
1225 let ef: Vec<f64> = raw.iter().map(|&v| v / norm).collect();
1226 model.eigenfunctions = FdMatrix::from_column_major(ef, m, 1).unwrap();
1227
1228 let j = 10usize;
1232 let mirror = m - 1 - j; assert!(mirror > j);
1234 let vj = design_criterion(&model, &[j], DesignCriterion::Trajectory).unwrap();
1236 let vm = design_criterion(&model, &[mirror], DesignCriterion::Trajectory).unwrap();
1237 assert!(
1238 (vj - vm).abs() < 1e-12,
1239 "expected a genuine tie: v[{j}]={vj} v[{mirror}]={vm}"
1240 );
1241
1242 let config = OptDesConfig {
1243 candidate_grid: vec![model.argvals[mirror], model.argvals[j]],
1244 budget: 1,
1245 criterion: DesignCriterion::Trajectory,
1246 };
1247 let r = optimal_design(&model, &config).unwrap();
1248 assert_eq!(
1249 r.selected_indices[0], j,
1250 "tie must resolve to the smallest argvals index ({j}), not first-in-grid-order ({mirror})"
1251 );
1252 }
1253}