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