Skip to main content

fdars_core/
optimal_design.rs

1//! Optimal experimental design criteria for sparse functional data (FOptDes).
2//!
3//! This module scores a caller-supplied set of design points (grid indices)
4//! against a fitted [`PaceFpcaResult`], computing one of two criteria dispatched
5//! through the [`DesignCriterion`] / [`OptimalityKind`] enum pair:
6//!
7//! - **Trajectory** ([`DesignCriterion::Trajectory`], FOD-01): the integrated,
8//!   Simpson-weighted conditional BLUP mean-squared reconstruction error of the
9//!   latent trajectory `x(t)` given noisy observations at the design points.
10//! - **Score** ([`DesignCriterion::Score`], FOD-02): an A- or D-optimal summary
11//!   of the posterior FPC-score covariance `Cov(ξ | Y_S)` — trace for A, log-det
12//!   for D.
13//!
14//! Both criteria share the private [`build_sigma_design`] helper, which assembles
15//! the `p×p` covariance `Σ_d = Φ_d diag(λ) Φ_dᵀ + σ²I_p` of the observations at the
16//! `p = |selected|` design points (mirroring the `Σ_yi` assembly in
17//! `pace_fpca.rs`). All criteria are *minimized* and are monotone non-increasing as
18//! design points are added, so the (future) greedy selector minimizes uncertainty.
19//!
20//! The mathematics follows Ji & Müller (2017) and the Yao–Müller–Wang (2005) PACE
21//! formulation already implemented in [`crate::pace_fpca`]. [`design_criterion`]
22//! is the pure numerical core; [`optimal_design`] wraps it in a deterministic
23//! greedy sequential forward-selection loop.
24//!
25//! # End-to-end example
26//!
27//! Fit a sparse PACE FPCA model, then greedily select informative design points:
28//!
29//! ```rust
30//! use fdars_core::irreg_fdata::IrregFdata;
31//! use fdars_core::pace_fpca::{pace_fpca, PaceFpcaConfig};
32//! use fdars_core::{optimal_design, DesignCriterion, OptDesConfig};
33//!
34//! // A handful of sparsely-sampled curves on [0, 1].
35//! let argvals_list = vec![
36//!     vec![0.1, 0.4, 0.7],
37//!     vec![0.0, 0.3, 0.6, 0.9],
38//!     vec![0.2, 0.5, 0.8],
39//!     vec![0.0, 0.25, 0.5, 0.75, 1.0],
40//!     vec![0.1, 0.5, 0.9],
41//!     vec![0.0, 0.4, 0.8],
42//! ];
43//! let values_list: Vec<Vec<f64>> = argvals_list
44//!     .iter()
45//!     .enumerate()
46//!     .map(|(i, ts)| ts.iter().map(|&t: &f64| (i as f64 + 1.0) * t.sin()).collect())
47//!     .collect();
48//! let data = IrregFdata::from_lists(&argvals_list, &values_list);
49//!
50//! // Fit PACE on a small work grid.
51//! let m = 21_usize;
52//! let pace_cfg = PaceFpcaConfig {
53//!     ncomp: 2,
54//!     bandwidth: 0.2,
55//!     sigma2: 0.01,
56//!     work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
57//!     alpha: 0.05,
58//! };
59//! let model = pace_fpca(&data, &pace_cfg).unwrap();
60//!
61//! // Greedily select 2 design points over the fitted model (read-only).
62//! let config = OptDesConfig {
63//!     candidate_grid: model.argvals.clone(),
64//!     budget: 2,
65//!     criterion: DesignCriterion::Trajectory,
66//! };
67//! let result = optimal_design(&model, &config).unwrap();
68//!
69//! assert_eq!(result.selected_indices.len(), 2);
70//! assert_eq!(result.criterion_trace.len(), 2);
71//! let chosen: &[f64] = &result.selected_argvals;
72//! assert_eq!(chosen.len(), 2);
73//! ```
74
75use crate::error::FdarError;
76use crate::helpers::simpsons_weights;
77use crate::iter_maybe_parallel;
78// Import the factor/forward-back pair directly rather than `cholesky_solve`:
79// the trajectory criterion factors Σ_d once (O(p³)) and then solves the m grid-point
80// right-hand-sides via `cholesky_forward_back` (O(p²) each), amortizing the single
81// factorization instead of re-factoring O(m) times as `cholesky_solve` would.
82use crate::linalg::{cholesky_factor, cholesky_forward_back, log_det_from_cholesky};
83use crate::pace_fpca::PaceFpcaResult;
84
85/// Which design criterion to evaluate.
86///
87/// Dispatched by [`design_criterion`]. `Trajectory` scores reconstruction of the
88/// latent curve; `Score` scores recovery of the FPC scores under an A- or
89/// D-optimality summary.
90#[derive(Debug, Clone, PartialEq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub enum DesignCriterion {
93    /// Integrated Simpson-weighted conditional BLUP trajectory-reconstruction MSE
94    /// (FOD-01). Empty design returns the prior integrated variance `Σ_k λ_k`.
95    Trajectory,
96    /// FPC-score posterior-covariance summary (FOD-02); see [`OptimalityKind`].
97    Score(OptimalityKind),
98}
99
100/// Optimality kind for the [`DesignCriterion::Score`] criterion.
101#[derive(Debug, Clone, PartialEq)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103pub enum OptimalityKind {
104    /// A-optimality: trace of the posterior score covariance `Cov(ξ | Y_S)`.
105    /// Empty design returns `Σ_k λ_k`.
106    A,
107    /// D-optimality: log-determinant of the posterior score covariance,
108    /// `Σ_k log(posterior eigenvalues)`, returned un-negated. Adding design points
109    /// shrinks the posterior covariance, so this value is monotone NON-INCREASING:
110    /// `log det Cov(ξ | Y_S) ≤ log det Λ = Σ_k log λ_k`. Its SIGN is not fixed — it
111    /// depends on the eigenvalue scale (e.g. `λ = [2, 1]` gives an empty-design value
112    /// of `ln 2 ≈ +0.693`, positive). Do NOT assume it is negative, and do NOT negate
113    /// it. Empty design returns `Σ_k log λ_k`.
114    D,
115}
116
117/// Score a design point index set against a fitted PACE FPCA model.
118///
119/// `selected` holds indices into `model.argvals` (0-based). Every index must be
120/// `< model.argvals.len()`. An empty `selected` returns the prior baseline:
121/// `Σ_k λ_k` for [`DesignCriterion::Trajectory`] and [`OptimalityKind::A`], and
122/// `Σ_k log λ_k` for [`OptimalityKind::D`].
123///
124/// Duplicate indices are *tolerated* (the resulting `Σ_d` is singular in the
125/// duplicated rows but the ridge-retry keeps the solve stable); callers that
126/// require distinct design points must dedupe upstream.
127///
128/// All criteria are minimized and are monotone non-increasing as design points
129/// are added: `criterion(S ∪ {t}) ≤ criterion(S) + 1e-12`.
130///
131/// # Errors
132///
133/// Returns [`FdarError::InvalidParameter`] if `model.ncomp == 0`,
134/// `model.sigma2 <= 0.0`, or any index in `selected` is out of range. Returns
135/// [`FdarError::ComputationFailed`] only if a Cholesky factorization fails even
136/// after the `1e-8` ridge-retry (never panics).
137#[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    // --- Validation (ASVS V5 input validation) ---
144    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    // --- Dispatch ---
185    match criterion {
186        DesignCriterion::Trajectory => trajectory_criterion(model, selected),
187        DesignCriterion::Score(kind) => score_criterion(model, selected, kind),
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Greedy selection wrapper (FOD-04 / FOD-05)
193// ---------------------------------------------------------------------------
194
195/// Configuration for the greedy [`optimal_design`] selector.
196///
197/// Carries a *single* [`DesignCriterion`] field — `Score(OptimalityKind)` already
198/// wraps the optimality kind, and `Trajectory` needs none, so no separate
199/// optimality field is required. NOT `#[non_exhaustive]`, so callers can build it
200/// with a struct literal (mirrors [`crate::pace_fpca::PaceFpcaConfig`]).
201///
202/// The empty-grid [`Default`] is a safe minimal placeholder; the empty grid is
203/// rejected at [`optimal_design`] call time, not at construction.
204#[derive(Debug, Clone, PartialEq)]
205#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
206pub struct OptDesConfig {
207    /// Candidate design points. Every value must appear (within `1e-9`) in
208    /// `model.argvals`; each is mapped to its grid index before selection.
209    /// Duplicate or near-duplicate values (two entries within `1e-9` of the same
210    /// `model.argvals` point) collapse onto a single distinct candidate, and
211    /// `budget` must not exceed the number of *distinct* on-grid points.
212    pub candidate_grid: Vec<f64>,
213    /// Number of design points to select (`p`). Must be `> 0` and
214    /// `<= candidate_grid.len()`.
215    pub budget: usize,
216    /// Criterion evaluated at every greedy step via [`design_criterion`].
217    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/// Result of greedy [`optimal_design`] selection.
231///
232/// `#[non_exhaustive]` for forward compatibility (mirrors
233/// [`crate::pace_fpca::PaceFpcaResult`]).
234#[derive(Debug, Clone, PartialEq)]
235#[non_exhaustive]
236#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
237pub struct OptDesResult {
238    /// Grid indices (into `model.argvals`) of the selected design points, in
239    /// selection order. Length `== config.budget`; duplicate-free.
240    pub selected_indices: Vec<usize>,
241    /// `model.argvals` values at the selected indices, in selection order.
242    /// Length `== config.budget`.
243    pub selected_argvals: Vec<f64>,
244    /// Achieved criterion value after each greedy step. Length `== config.budget`;
245    /// monotone non-increasing (`trace[i+1] <= trace[i] + 1e-12`).
246    pub criterion_trace: Vec<f64>,
247}
248
249/// Map each `candidate_grid` value to its `model.argvals` grid index.
250///
251/// Uses an FP-tolerant position search (`|t - cand| < 1e-9`) so grid values
252/// computed as `i as f64 / (m-1) as f64` match a caller-supplied equivalent that
253/// may differ by a few ULPs. Preserves `candidate_grid` order.
254///
255/// # Errors
256///
257/// Returns [`FdarError::InvalidParameter`] if any candidate is not found in
258/// `argvals` within `1e-9`.
259fn 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/// Greedy sequential forward-selection of design points over a fitted PACE model.
280///
281/// Starting from the empty design, at each of `config.budget` steps this adds the
282/// not-yet-selected candidate index that most reduces `config.criterion` (evaluated
283/// through the Phase-64 [`design_criterion`]), until the budget is reached. The
284/// supplied [`PaceFpcaResult`] is consumed **read-only** — no re-estimation of the
285/// eigenstructure or `σ²` (the two-stage FOptDes contract, FOD-05).
286///
287/// # Determinism
288///
289/// Candidate *evaluation* is parallelized (`iter_maybe_parallel!`), but the argmin
290/// is a **sequential** fold over the collected `(index, value)` pairs with a
291/// smallest-index tie-break (never rayon `min_by`, which is not stable under ties).
292/// The candidate pool is sorted by ascending `model.argvals` index once up front,
293/// so a criterion tie deterministically resolves to the smallest argvals index
294/// regardless of the order in which `config.candidate_grid` values were supplied.
295/// Two identical calls produce byte-identical `selected_indices` and
296/// `criterion_trace`, and the result is identical with and without the `parallel`
297/// feature.
298///
299/// # Guarantees
300///
301/// - `selected_indices.len() == config.budget`, duplicate-free.
302/// - `criterion_trace` is monotone non-increasing (inherited from
303///   [`design_criterion`]).
304///
305/// # Errors
306///
307/// Returns [`FdarError::InvalidParameter`] if `config.budget == 0`,
308/// `config.budget > config.candidate_grid.len()`, `config.budget` exceeds the
309/// number of *distinct* on-grid candidate points (duplicate or near-duplicate
310/// `candidate_grid` values that collapse onto the same `model.argvals` index),
311/// any candidate is not in `model.argvals` (within `1e-9`), `model.ncomp == 0`,
312/// or `model.sigma2 <= 0.0`. Also propagates the `model.argvals.len() < 2`
313/// (grid-too-small) [`FdarError::InvalidParameter`] raised by [`design_criterion`],
314/// and any other [`FdarError`] it may return during evaluation.
315#[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    // --- Validation (ASVS V5 input validation) — fail fast before any candidate work ---
321    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    // Map candidate_grid → argvals indices once, then sort ASCENDING and dedupe.
351    // Sorting by argvals index makes a criterion tie deterministically resolve to
352    // the smallest argvals index (the LOCKED contract) regardless of the caller's
353    // candidate_grid ordering — argmin-by-value is order-independent for non-ties,
354    // so this ONLY changes tie behavior. Deduping folds exact/near-duplicate grid
355    // values (which map_candidates_to_indices can collapse onto the same index)
356    // into a single selectable candidate.
357    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    // Guard: after collapsing duplicates, the pool of DISTINCT on-grid candidate
365    // points must still cover the budget. Without this, `candidate_grid = [0.0, 0.0]`
366    // with `budget = 2` would pass the raw-length guard above yet exhaust `remaining`
367    // mid-loop and panic. Reject it as a clean validation error instead.
368    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        // Not-yet-selected candidates, in ascending argvals-index order.
386        let remaining: Vec<usize> = candidate_indices
387            .iter()
388            .copied()
389            .filter(|idx| !selected.contains(idx))
390            .collect();
391
392        // PARALLEL evaluate: each closure captures only immutable refs and allocates
393        // its own `trial`. `PaceFpcaResult` is Send + Sync (all Vec<f64>/FdMatrix/
394        // usize/f64 fields), so this compiles under `--features parallel`.
395        #[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        // SEQUENTIAL argmin over the collected, ascending-index-ordered `scores`.
407        // Strict `<` keeps the FIRST minimum; because `remaining` is sorted by
408        // ascending argvals index, that first minimum IS the smallest-argvals-index
409        // tie-break (rayon `min_by` is NOT stable under ties, so it must not be used
410        // here). The `ok_or_else` is defense-in-depth: the distinct-candidate guard
411        // above already guarantees `remaining` is non-empty at every step, but we
412        // return a recoverable error rather than panic if a future change slips past it.
413        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
446/// Assemble the `p×p` design covariance `Σ_d = Φ_d diag(λ) Φ_dᵀ + σ²I_p`
447/// (row-major), where `p = selected.len()`.
448///
449/// Mirrors the `Σ_yi` assembly in `pace_fpca.rs`, substituting design-point grid
450/// indices for per-curve observation indices. Shape is `|S|×|S|`, NOT `K×K`.
451fn 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; // σ²I_p diagonal
468    }
469    sigma_d
470}
471
472/// Cholesky-factor `Σ_d` with a single `1e-8` diagonal ridge-retry on failure.
473///
474/// Mirrors the ridge-retry in `pace_fpca.rs:480–490`. Never panics; returns the
475/// lower-triangular factor `L` on success.
476fn 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
491/// Cholesky-factor the `K×K` posterior covariance `Cov` with a single ridge-retry
492/// on failure, mirroring [`factor_sigma_design_with_retry`].
493///
494/// The Schur-complement `Cov = Λ − A_mat` is positive-definite in exact arithmetic,
495/// but FP cancellation (especially after a ridge-adjusted `Σ_d`) can make it fail the
496/// Cholesky diagonal test. On failure we add a tiny `1e-8`-scaled diagonal ridge and
497/// retry once, keeping D-opt as robust as A-opt. Never panics.
498fn 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            // Ridge scaled to the covariance magnitude, matching the `1e-8` convention
503            // used for the Σ_d retry (there the scale is implicitly ~O(1)).
504            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
522/// Extract the `p×ncomp` design-point eigenfunction sub-matrix `Φ_d` (row-major),
523/// where `phi_d[i * ncomp + k] = eigenfunctions[(selected[i], k)]`.
524fn 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
536/// Trajectory criterion (FOD-01): integrated Simpson-weighted conditional
537/// BLUP-MSE `Σ_j w_j (Σ_k λ_k φ_k(t_j)² − φ_d(t_j)ᵀ Σ_d⁻¹ φ_d(t_j))`.
538fn 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    // Empty-set fast path: no design points → no reduction → prior variance only.
545    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    // Factor Σ_d once (O(p³)); each grid point is then an O(p²) forward/back solve.
557    let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
558    let phi_d = build_phi_d(model, selected); // p × ncomp, row-major
559
560    let mut mse = 0.0_f64;
561    let mut rhs = vec![0.0_f64; p];
562    for j in 0..m {
563        // Prior variance at grid point j: Σ_k λ_k φ_k(t_j)².
564        let prior_var: f64 = (0..ncomp)
565            .map(|k| model.eigenvalues[k] * model.eigenfunctions[(j, k)].powi(2))
566            .sum();
567
568        // Cross-covariance p-vector: rhs[i] = Σ_k λ_k φ_k(t_j) φ_k(argvals[selected[i]]).
569        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        // reduction = rhsᵀ Σ_d⁻¹ rhs, via the pre-factored Cholesky.
578        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
586/// Score criterion (FOD-02): A- or D-optimal summary of the K×K posterior FPC
587/// score covariance `Cov(ξ | Y_S) = Λ − Λ Φ_dᵀ Σ_d⁻¹ Φ_d Λ`.
588fn 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    // Empty-set fast path: no information → posterior = prior = diag(λ).
597    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    // Factor Σ_d once, then solve Σ_d x_k = Φ_d[:,k] per component (forward/back).
617    let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
618    let phi_d = build_phi_d(model, selected); // p × ncomp, row-major
619
620    // sigma_inv_phi_lam[j,k] = λ_k · (Σ_d⁻¹ Φ_d[:,k])[j]  (mirror pace_fpca.rs:525–545).
621    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    // A_mat[k,l] = λ_k · Σ_j Φ_d[j,k] · sigma_inv_phi_lam[j,l]  (pace_fpca.rs:547–558).
634    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    // Posterior covariance Cov[k,l] = (k==l ? λ_k : 0) − A_mat[k,l].
646    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            // trace(Cov) = Σ_k Cov[k,k].
657            let tr: f64 = (0..ncomp).map(|k| cov[k * ncomp + k]).sum();
658            Ok(tr)
659        }
660        OptimalityKind::D => {
661            // log det(Cov) via Cholesky. Returned un-negated and monotone
662            // non-increasing (its sign depends on the eigenvalue scale). Do NOT negate.
663            //
664            // Mirror the Σ_d ridge-retry: the Schur complement Cov is PD in exact
665            // arithmetic, but when Σ_d was itself ridge-adjusted (tiny sigma2), FP
666            // cancellation in `(λ_k : 0) − A_mat` can push a diagonal entry to
667            // (near-)zero and make the Cholesky fail. A tiny ridge rescues it so D-opt
668            // succeeds wherever A-opt does. Never panics.
669            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    /// Build a synthetic [`PaceFpcaResult`] with exactly-orthonormal eigenfunctions
681    /// under the grid's Simpson weights.
682    ///
683    /// Two eigenfunctions (scaled Fourier cosines) on a uniform `[0, 1]` grid of
684    /// length `m`, each normalized so `Σ_j w_j φ_k(t_j)² = 1`. `λ = [2.0, 1.0]`,
685    /// `σ² = 0.5`, `ncomp = 2`. Unused result fields are valid-shape placeholders.
686    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        // Raw eigenfunctions: cos(k·π·t) for k = 1..=ncomp (orthogonal under the grid),
696        // normalized to unit Simpson-weighted L² norm.
697        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                // column-major: element (row=j, col=k) at index j + k*m
705                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    // ---- Trajectory branch (FOD-01) ----
725
726    #[test]
727    fn test_trajectory_empty_set() {
728        let model = synthetic_model(51);
729        let mse = design_criterion(&model, &[], DesignCriterion::Trajectory).unwrap();
730        // MSE(∅) = Σ_k λ_k = 2.0 + 1.0 = 3.0
731        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        // ncomp == 0 with empty eigenvalues.
780        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        // Force Σ_d genuinely non-PD so the FIRST cholesky_factor fails and only the
788        // 1e-8 ridge-retry rescues it. Duplicating a design index makes two rows of Σ_d
789        // identical → Σ_d is rank-1 + σ²I. Its second Cholesky pivot is
790        // ((a+σ²)² − a²)/(a+σ²) ≈ 2·σ² for small σ². With σ² = 1e-13 this pivot is
791        // ~2e-13 ≤ the 1e-12 cholesky_factor threshold, so the first factorization
792        // FAILS; the 1e-8 ridge lifts the pivot to ~1e-8 and the retry succeeds.
793        // (Duplicate indices are explicitly tolerated per `design_criterion` docs.)
794        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        // Sanity: without the retry this input is non-PD. Confirm the raw factorization
801        // does fail, so the test genuinely exercises the retry branch (it would fail
802        // to reach Ok if the retry were removed).
803        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        // m = 1: a Simpson quadrature / trajectory integral is undefined. Construct the
813        // model directly (synthetic_model_params divides by m-1, so it can't build m=1).
814        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    // ---- Score branch (FOD-02) ----
834
835    #[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        // A(∅) = Σ_k λ_k = 3.0
840        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        // D(∅) = ln(2.0) + ln(1.0) = ln 2
848        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        // Route-correctness. NOTE: when eigenfunctions are orthonormal w.r.t. the
904        // integration weights, the integrated trajectory MSE equals trace(Cov(ξ)),
905        // so Trajectory ≡ A-optimality is an exact algebraic identity — not a
906        // dispatch bug. We assert that identity (proving Trajectory runs the real
907        // integral, not a stub) AND that D (log-det, a distinct code path) yields a
908        // value distinct from both, confirming all three variants route separately.
909        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    // ---- Greedy selection wrapper (FOD-04 / FOD-05) ----
924
925    #[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        // Doubles as the seq==parallel gate: run under BOTH default and
942        // `--features parallel`; the selection must be byte-identical either way.
943        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        // A value strictly between two grid points, well outside the 1e-9 tolerance.
1027        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        // ncomp == 0 (empty eigenvalues). May be caught at entry or delegated to
1040        // design_criterion — either way an InvalidParameter must surface.
1041        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        // Compute the expected first index numerically: sequential smallest-index
1068        // argmin of the single-point Trajectory criterion over ALL candidates.
1069        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        // Default constructs (empty grid, budget 1, Trajectory); the empty grid is
1116        // caught at call time, NOT at construction.
1117        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        // In-crate reachability placeholder. The external prelude/crate-root
1132        // reachability is verified as a doctest in plan 65-02.
1133        assert_eq!(OptDesConfig::default().budget, 1);
1134    }
1135
1136    #[test]
1137    fn test_validation_duplicate_candidates() {
1138        let model = synthetic_model(51);
1139        // Exact duplicate 0.0 collapses to a single distinct argvals index (0), so
1140        // budget 2 exceeds the 1-point distinct pool → clean InvalidParameter, NOT a
1141        // panic on the greedy fold's `.expect()`/`ok_or_else`.
1142        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        // Three grid VALUES but only two DISTINCT argvals indices after dedup
1158        // (0.0 appears twice). budget 3 > distinct pool 2 → InvalidParameter.
1159        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        // The greedy selection must be invariant to candidate_grid ordering: it maps
1174        // to argvals indices and sorts ascending, so ties resolve to the smallest
1175        // argvals index regardless of the caller's supplied order. Feed an UNSORTED
1176        // grid and its sorted counterpart — the selected indices must be identical.
1177        let model = synthetic_model(51);
1178        let ascending: Vec<f64> = model.argvals.clone();
1179        let mut shuffled = ascending.clone();
1180        shuffled.reverse(); // maximally different candidate_grid order
1181
1182        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        // Construct a deliberate tie: with a single eigenfunction φ(t) = cos(π t) on
1207        // [0, 1], the single-point criterion depends on t only through φ(t)², and
1208        // φ is symmetric about t = 0.5, so grid indices j and (m-1-j) give an EXACTLY
1209        // equal criterion value. The tie must resolve to the smaller argvals index.
1210        let m = 51usize;
1211        let mut model = synthetic_model_params(m, vec![2.0], 0.5);
1212        // Overwrite the eigenfunction with a clean symmetric cos(π t), re-normalized.
1213        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        // A symmetric pair of candidate indices j and its mirror; the mirror is listed
1227        // FIRST in candidate_grid so that a "first-in-grid-order" tie-break would (wrongly)
1228        // pick the larger index. A true smallest-index tie-break picks the smaller one.
1229        let j = 10usize;
1230        let mirror = m - 1 - j; // 40
1231        assert!(mirror > j);
1232        // Sanity: the two single-point criteria are genuinely equal.
1233        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}