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#[non_exhaustive]
91#[derive(Debug, Clone, PartialEq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub enum DesignCriterion {
94    /// Integrated Simpson-weighted conditional BLUP trajectory-reconstruction MSE
95    /// (FOD-01). Empty design returns the prior integrated variance `Σ_k λ_k`.
96    Trajectory,
97    /// FPC-score posterior-covariance summary (FOD-02); see [`OptimalityKind`].
98    Score(OptimalityKind),
99}
100
101/// Optimality kind for the [`DesignCriterion::Score`] criterion.
102#[non_exhaustive]
103#[derive(Debug, Clone, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub enum OptimalityKind {
106    /// A-optimality: trace of the posterior score covariance `Cov(ξ | Y_S)`.
107    /// Empty design returns `Σ_k λ_k`.
108    A,
109    /// D-optimality: log-determinant of the posterior score covariance,
110    /// `Σ_k log(posterior eigenvalues)`, returned un-negated. Adding design points
111    /// shrinks the posterior covariance, so this value is monotone NON-INCREASING:
112    /// `log det Cov(ξ | Y_S) ≤ log det Λ = Σ_k log λ_k`. Its SIGN is not fixed — it
113    /// depends on the eigenvalue scale (e.g. `λ = [2, 1]` gives an empty-design value
114    /// of `ln 2 ≈ +0.693`, positive). Do NOT assume it is negative, and do NOT negate
115    /// it. Empty design returns `Σ_k log λ_k`.
116    D,
117}
118
119/// Score a design point index set against a fitted PACE FPCA model.
120///
121/// `selected` holds indices into `model.argvals` (0-based). Every index must be
122/// `< model.argvals.len()`. An empty `selected` returns the prior baseline:
123/// `Σ_k λ_k` for [`DesignCriterion::Trajectory`] and [`OptimalityKind::A`], and
124/// `Σ_k log λ_k` for [`OptimalityKind::D`].
125///
126/// Duplicate indices are *tolerated* (the resulting `Σ_d` is singular in the
127/// duplicated rows but the ridge-retry keeps the solve stable); callers that
128/// require distinct design points must dedupe upstream.
129///
130/// All criteria are minimized and are monotone non-increasing as design points
131/// are added: `criterion(S ∪ {t}) ≤ criterion(S) + 1e-12`.
132///
133/// # Errors
134///
135/// Returns [`FdarError::InvalidParameter`] if `model.ncomp == 0`,
136/// `model.sigma2 <= 0.0`, or any index in `selected` is out of range. Returns
137/// [`FdarError::ComputationFailed`] only if a Cholesky factorization fails even
138/// after the `1e-8` ridge-retry (never panics).
139#[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    // --- Validation (ASVS V5 input validation) ---
146    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    // --- Dispatch ---
187    match criterion {
188        DesignCriterion::Trajectory => trajectory_criterion(model, selected),
189        DesignCriterion::Score(kind) => score_criterion(model, selected, kind),
190    }
191}
192
193// ---------------------------------------------------------------------------
194// Greedy selection wrapper (FOD-04 / FOD-05)
195// ---------------------------------------------------------------------------
196
197/// Configuration for the greedy [`optimal_design`] selector.
198///
199/// Carries a *single* [`DesignCriterion`] field — `Score(OptimalityKind)` already
200/// wraps the optimality kind, and `Trajectory` needs none, so no separate
201/// optimality field is required. NOT `#[non_exhaustive]`, so callers can build it
202/// with a struct literal (mirrors [`crate::pace_fpca::PaceFpcaConfig`]).
203///
204/// The empty-grid [`Default`] is a safe minimal placeholder; the empty grid is
205/// rejected at [`optimal_design`] call time, not at construction.
206#[derive(Debug, Clone, PartialEq)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
208pub struct OptDesConfig {
209    /// Candidate design points. Every value must appear (within `1e-9`) in
210    /// `model.argvals`; each is mapped to its grid index before selection.
211    /// Duplicate or near-duplicate values (two entries within `1e-9` of the same
212    /// `model.argvals` point) collapse onto a single distinct candidate, and
213    /// `budget` must not exceed the number of *distinct* on-grid points.
214    pub candidate_grid: Vec<f64>,
215    /// Number of design points to select (`p`). Must be `> 0` and
216    /// `<= candidate_grid.len()`.
217    pub budget: usize,
218    /// Criterion evaluated at every greedy step via [`design_criterion`].
219    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/// Result of greedy [`optimal_design`] selection.
233///
234/// `#[non_exhaustive]` for forward compatibility (mirrors
235/// [`crate::pace_fpca::PaceFpcaResult`]).
236#[derive(Debug, Clone, PartialEq)]
237#[non_exhaustive]
238#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
239pub struct OptDesResult {
240    /// Grid indices (into `model.argvals`) of the selected design points, in
241    /// selection order. Length `== config.budget`; duplicate-free.
242    pub selected_indices: Vec<usize>,
243    /// `model.argvals` values at the selected indices, in selection order.
244    /// Length `== config.budget`.
245    pub selected_argvals: Vec<f64>,
246    /// Achieved criterion value after each greedy step. Length `== config.budget`;
247    /// monotone non-increasing (`trace[i+1] <= trace[i] + 1e-12`).
248    pub criterion_trace: Vec<f64>,
249}
250
251/// Map each `candidate_grid` value to its `model.argvals` grid index.
252///
253/// Uses an FP-tolerant position search (`|t - cand| < 1e-9`) so grid values
254/// computed as `i as f64 / (m-1) as f64` match a caller-supplied equivalent that
255/// may differ by a few ULPs. Preserves `candidate_grid` order.
256///
257/// # Errors
258///
259/// Returns [`FdarError::InvalidParameter`] if any candidate is not found in
260/// `argvals` within `1e-9`.
261fn 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/// Greedy sequential forward-selection of design points over a fitted PACE model.
282///
283/// Starting from the empty design, at each of `config.budget` steps this adds the
284/// not-yet-selected candidate index that most reduces `config.criterion` (evaluated
285/// through the Phase-64 [`design_criterion`]), until the budget is reached. The
286/// supplied [`PaceFpcaResult`] is consumed **read-only** — no re-estimation of the
287/// eigenstructure or `σ²` (the two-stage FOptDes contract, FOD-05).
288///
289/// # Determinism
290///
291/// Candidate *evaluation* is parallelized (`iter_maybe_parallel!`), but the argmin
292/// is a **sequential** fold over the collected `(index, value)` pairs with a
293/// smallest-index tie-break (never rayon `min_by`, which is not stable under ties).
294/// The candidate pool is sorted by ascending `model.argvals` index once up front,
295/// so a criterion tie deterministically resolves to the smallest argvals index
296/// regardless of the order in which `config.candidate_grid` values were supplied.
297/// Two identical calls produce byte-identical `selected_indices` and
298/// `criterion_trace`, and the result is identical with and without the `parallel`
299/// feature.
300///
301/// # Guarantees
302///
303/// - `selected_indices.len() == config.budget`, duplicate-free.
304/// - `criterion_trace` is monotone non-increasing (inherited from
305///   [`design_criterion`]).
306///
307/// # Errors
308///
309/// Returns [`FdarError::InvalidParameter`] if `config.budget == 0`,
310/// `config.budget > config.candidate_grid.len()`, `config.budget` exceeds the
311/// number of *distinct* on-grid candidate points (duplicate or near-duplicate
312/// `candidate_grid` values that collapse onto the same `model.argvals` index),
313/// any candidate is not in `model.argvals` (within `1e-9`), `model.ncomp == 0`,
314/// or `model.sigma2 <= 0.0`. Also propagates the `model.argvals.len() < 2`
315/// (grid-too-small) [`FdarError::InvalidParameter`] raised by [`design_criterion`],
316/// and any other [`FdarError`] it may return during evaluation.
317#[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    // --- Validation (ASVS V5 input validation) — fail fast before any candidate work ---
323    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    // Map candidate_grid → argvals indices once, then sort ASCENDING and dedupe.
353    // Sorting by argvals index makes a criterion tie deterministically resolve to
354    // the smallest argvals index (the LOCKED contract) regardless of the caller's
355    // candidate_grid ordering — argmin-by-value is order-independent for non-ties,
356    // so this ONLY changes tie behavior. Deduping folds exact/near-duplicate grid
357    // values (which map_candidates_to_indices can collapse onto the same index)
358    // into a single selectable candidate.
359    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    // Guard: after collapsing duplicates, the pool of DISTINCT on-grid candidate
367    // points must still cover the budget. Without this, `candidate_grid = [0.0, 0.0]`
368    // with `budget = 2` would pass the raw-length guard above yet exhaust `remaining`
369    // mid-loop and panic. Reject it as a clean validation error instead.
370    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        // Not-yet-selected candidates, in ascending argvals-index order.
388        let remaining: Vec<usize> = candidate_indices
389            .iter()
390            .copied()
391            .filter(|idx| !selected.contains(idx))
392            .collect();
393
394        // PARALLEL evaluate: each closure captures only immutable refs and allocates
395        // its own `trial`. `PaceFpcaResult` is Send + Sync (all Vec<f64>/FdMatrix/
396        // usize/f64 fields), so this compiles under `--features parallel`.
397        #[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        // SEQUENTIAL argmin over the collected, ascending-index-ordered `scores`.
409        // Strict `<` keeps the FIRST minimum; because `remaining` is sorted by
410        // ascending argvals index, that first minimum IS the smallest-argvals-index
411        // tie-break (rayon `min_by` is NOT stable under ties, so it must not be used
412        // here). The `ok_or_else` is defense-in-depth: the distinct-candidate guard
413        // above already guarantees `remaining` is non-empty at every step, but we
414        // return a recoverable error rather than panic if a future change slips past it.
415        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
448/// Assemble the `p×p` design covariance `Σ_d = Φ_d diag(λ) Φ_dᵀ + σ²I_p`
449/// (row-major), where `p = selected.len()`.
450///
451/// Mirrors the `Σ_yi` assembly in `pace_fpca.rs`, substituting design-point grid
452/// indices for per-curve observation indices. Shape is `|S|×|S|`, NOT `K×K`.
453fn 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; // σ²I_p diagonal
470    }
471    sigma_d
472}
473
474/// Cholesky-factor `Σ_d` with a single `1e-8` diagonal ridge-retry on failure.
475///
476/// Mirrors the ridge-retry in `pace_fpca.rs:480–490`. Never panics; returns the
477/// lower-triangular factor `L` on success.
478fn 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
493/// Cholesky-factor the `K×K` posterior covariance `Cov` with a single ridge-retry
494/// on failure, mirroring [`factor_sigma_design_with_retry`].
495///
496/// The Schur-complement `Cov = Λ − A_mat` is positive-definite in exact arithmetic,
497/// but FP cancellation (especially after a ridge-adjusted `Σ_d`) can make it fail the
498/// Cholesky diagonal test. On failure we add a tiny `1e-8`-scaled diagonal ridge and
499/// retry once, keeping D-opt as robust as A-opt. Never panics.
500fn 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            // Ridge scaled to the covariance magnitude, matching the `1e-8` convention
505            // used for the Σ_d retry (there the scale is implicitly ~O(1)).
506            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
524/// Extract the `p×ncomp` design-point eigenfunction sub-matrix `Φ_d` (row-major),
525/// where `phi_d[i * ncomp + k] = eigenfunctions[(selected[i], k)]`.
526fn 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
538/// Trajectory criterion (FOD-01): integrated Simpson-weighted conditional
539/// BLUP-MSE `Σ_j w_j (Σ_k λ_k φ_k(t_j)² − φ_d(t_j)ᵀ Σ_d⁻¹ φ_d(t_j))`.
540fn 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    // Empty-set fast path: no design points → no reduction → prior variance only.
547    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    // Factor Σ_d once (O(p³)); each grid point is then an O(p²) forward/back solve.
559    let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
560    let phi_d = build_phi_d(model, selected); // p × ncomp, row-major
561
562    let mut mse = 0.0_f64;
563    let mut rhs = vec![0.0_f64; p];
564    for j in 0..m {
565        // Prior variance at grid point j: Σ_k λ_k φ_k(t_j)².
566        let prior_var: f64 = (0..ncomp)
567            .map(|k| model.eigenvalues[k] * model.eigenfunctions[(j, k)].powi(2))
568            .sum();
569
570        // Cross-covariance p-vector: rhs[i] = Σ_k λ_k φ_k(t_j) φ_k(argvals[selected[i]]).
571        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        // reduction = rhsᵀ Σ_d⁻¹ rhs, via the pre-factored Cholesky.
580        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
588/// Score criterion (FOD-02): A- or D-optimal summary of the K×K posterior FPC
589/// score covariance `Cov(ξ | Y_S) = Λ − Λ Φ_dᵀ Σ_d⁻¹ Φ_d Λ`.
590fn 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    // Empty-set fast path: no information → posterior = prior = diag(λ).
599    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    // Factor Σ_d once, then solve Σ_d x_k = Φ_d[:,k] per component (forward/back).
619    let l = factor_sigma_design_with_retry(build_sigma_design(model, selected), p)?;
620    let phi_d = build_phi_d(model, selected); // p × ncomp, row-major
621
622    // sigma_inv_phi_lam[j,k] = λ_k · (Σ_d⁻¹ Φ_d[:,k])[j]  (mirror pace_fpca.rs:525–545).
623    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    // A_mat[k,l] = λ_k · Σ_j Φ_d[j,k] · sigma_inv_phi_lam[j,l]  (pace_fpca.rs:547–558).
636    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    // Posterior covariance Cov[k,l] = (k==l ? λ_k : 0) − A_mat[k,l].
648    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            // trace(Cov) = Σ_k Cov[k,k].
659            let tr: f64 = (0..ncomp).map(|k| cov[k * ncomp + k]).sum();
660            Ok(tr)
661        }
662        OptimalityKind::D => {
663            // log det(Cov) via Cholesky. Returned un-negated and monotone
664            // non-increasing (its sign depends on the eigenvalue scale). Do NOT negate.
665            //
666            // Mirror the Σ_d ridge-retry: the Schur complement Cov is PD in exact
667            // arithmetic, but when Σ_d was itself ridge-adjusted (tiny sigma2), FP
668            // cancellation in `(λ_k : 0) − A_mat` can push a diagonal entry to
669            // (near-)zero and make the Cholesky fail. A tiny ridge rescues it so D-opt
670            // succeeds wherever A-opt does. Never panics.
671            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    /// Build a synthetic [`PaceFpcaResult`] with exactly-orthonormal eigenfunctions
683    /// under the grid's Simpson weights.
684    ///
685    /// Two eigenfunctions (scaled Fourier cosines) on a uniform `[0, 1]` grid of
686    /// length `m`, each normalized so `Σ_j w_j φ_k(t_j)² = 1`. `λ = [2.0, 1.0]`,
687    /// `σ² = 0.5`, `ncomp = 2`. Unused result fields are valid-shape placeholders.
688    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        // Raw eigenfunctions: cos(k·π·t) for k = 1..=ncomp (orthogonal under the grid),
698        // normalized to unit Simpson-weighted L² norm.
699        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                // column-major: element (row=j, col=k) at index j + k*m
707                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    // ---- Trajectory branch (FOD-01) ----
727
728    #[test]
729    fn test_trajectory_empty_set() {
730        let model = synthetic_model(51);
731        let mse = design_criterion(&model, &[], DesignCriterion::Trajectory).unwrap();
732        // MSE(∅) = Σ_k λ_k = 2.0 + 1.0 = 3.0
733        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        // ncomp == 0 with empty eigenvalues.
782        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        // Force Σ_d genuinely non-PD so the FIRST cholesky_factor fails and only the
790        // 1e-8 ridge-retry rescues it. Duplicating a design index makes two rows of Σ_d
791        // identical → Σ_d is rank-1 + σ²I. Its second Cholesky pivot is
792        // ((a+σ²)² − a²)/(a+σ²) ≈ 2·σ² for small σ². With σ² = 1e-13 this pivot is
793        // ~2e-13 ≤ the 1e-12 cholesky_factor threshold, so the first factorization
794        // FAILS; the 1e-8 ridge lifts the pivot to ~1e-8 and the retry succeeds.
795        // (Duplicate indices are explicitly tolerated per `design_criterion` docs.)
796        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        // Sanity: without the retry this input is non-PD. Confirm the raw factorization
803        // does fail, so the test genuinely exercises the retry branch (it would fail
804        // to reach Ok if the retry were removed).
805        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        // m = 1: a Simpson quadrature / trajectory integral is undefined. Construct the
815        // model directly (synthetic_model_params divides by m-1, so it can't build m=1).
816        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    // ---- Score branch (FOD-02) ----
836
837    #[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        // A(∅) = Σ_k λ_k = 3.0
842        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        // D(∅) = ln(2.0) + ln(1.0) = ln 2
850        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        // Route-correctness. NOTE: when eigenfunctions are orthonormal w.r.t. the
906        // integration weights, the integrated trajectory MSE equals trace(Cov(ξ)),
907        // so Trajectory ≡ A-optimality is an exact algebraic identity — not a
908        // dispatch bug. We assert that identity (proving Trajectory runs the real
909        // integral, not a stub) AND that D (log-det, a distinct code path) yields a
910        // value distinct from both, confirming all three variants route separately.
911        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    // ---- Greedy selection wrapper (FOD-04 / FOD-05) ----
926
927    #[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        // Doubles as the seq==parallel gate: run under BOTH default and
944        // `--features parallel`; the selection must be byte-identical either way.
945        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        // A value strictly between two grid points, well outside the 1e-9 tolerance.
1029        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        // ncomp == 0 (empty eigenvalues). May be caught at entry or delegated to
1042        // design_criterion — either way an InvalidParameter must surface.
1043        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        // Compute the expected first index numerically: sequential smallest-index
1070        // argmin of the single-point Trajectory criterion over ALL candidates.
1071        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        // Default constructs (empty grid, budget 1, Trajectory); the empty grid is
1118        // caught at call time, NOT at construction.
1119        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        // In-crate reachability placeholder. The external prelude/crate-root
1134        // reachability is verified as a doctest in plan 65-02.
1135        assert_eq!(OptDesConfig::default().budget, 1);
1136    }
1137
1138    #[test]
1139    fn test_validation_duplicate_candidates() {
1140        let model = synthetic_model(51);
1141        // Exact duplicate 0.0 collapses to a single distinct argvals index (0), so
1142        // budget 2 exceeds the 1-point distinct pool → clean InvalidParameter, NOT a
1143        // panic on the greedy fold's `.expect()`/`ok_or_else`.
1144        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        // Three grid VALUES but only two DISTINCT argvals indices after dedup
1160        // (0.0 appears twice). budget 3 > distinct pool 2 → InvalidParameter.
1161        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        // The greedy selection must be invariant to candidate_grid ordering: it maps
1176        // to argvals indices and sorts ascending, so ties resolve to the smallest
1177        // argvals index regardless of the caller's supplied order. Feed an UNSORTED
1178        // grid and its sorted counterpart — the selected indices must be identical.
1179        let model = synthetic_model(51);
1180        let ascending: Vec<f64> = model.argvals.clone();
1181        let mut shuffled = ascending.clone();
1182        shuffled.reverse(); // maximally different candidate_grid order
1183
1184        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        // Construct a deliberate tie: with a single eigenfunction φ(t) = cos(π t) on
1209        // [0, 1], the single-point criterion depends on t only through φ(t)², and
1210        // φ is symmetric about t = 0.5, so grid indices j and (m-1-j) give an EXACTLY
1211        // equal criterion value. The tie must resolve to the smaller argvals index.
1212        let m = 51usize;
1213        let mut model = synthetic_model_params(m, vec![2.0], 0.5);
1214        // Overwrite the eigenfunction with a clean symmetric cos(π t), re-normalized.
1215        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        // A symmetric pair of candidate indices j and its mirror; the mirror is listed
1229        // FIRST in candidate_grid so that a "first-in-grid-order" tie-break would (wrongly)
1230        // pick the larger index. A true smallest-index tie-break picks the smaller one.
1231        let j = 10usize;
1232        let mirror = m - 1 - j; // 40
1233        assert!(mirror > j);
1234        // Sanity: the two single-point criteria are genuinely equal.
1235        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}