Skip to main content

plskit/
find_k.rs

1//! K-selection: optimal (CV / BIC) and sequence (sequential test) methods.
2
3use std::collections::BTreeMap;
4
5use rand::seq::SliceRandom;
6
7use crate::error::{PlsKitError, PlsKitResult};
8use crate::fit::{
9    FitOpts, IMPLICIT_MAX_MEAN_RATIO, KSpec, ParChoice, PreparedFit, check_fit_y_and_k,
10    pls1_fit_impl, pls1_fit_implicit,
11};
12use crate::linalg::{
13    col_row_subset, normalize_weights, standardize_apply_rows, standardize_rows,
14    standardize1_weighted,
15};
16use crate::sequential::{IncrementalSequenceOpts, SequentialArgs, run_incremental_sequence};
17use crate::signal_test::ConfirmatoryMethod;
18
19use faer::{Col, ColRef, Mat, MatRef};
20
21/// Selector used in `pls1_find_k_optimal` to pick K* from the candidate range.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Selector {
24    /// Maximize CV R² with the 1-SE rule (default).
25    R2Se,
26    /// Maximize CV R² without the 1-SE rule.
27    R2Max,
28    /// Minimize `BIC(k) = n_eff · log(SSR / n_eff) + k · log(n_eff)`
29    /// (`n_eff` is `n` without weights). BIC counts each component as one
30    /// degree of freedom. On wide `x` (p much larger than n) the effective
31    /// degrees of freedom of a PLS fit are much larger, so BIC tends to select
32    /// `k_max`. `R2Se` (the default) is recommended there.
33    Bic,
34}
35
36/// Opts for `pls1_find_k_optimal`.
37#[derive(Debug, Clone, Copy)]
38#[allow(clippy::struct_excessive_bools)]
39pub struct FindKOptimalOpts {
40    /// Which selection criterion to use (CV R² with 1-SE, CV R² max, or BIC).
41    pub selector: Selector,
42    /// Number of CV folds. Used by `R2Se` and `R2Max` selectors. The wrapper
43    /// is responsible for rejecting non-default `n_folds` with `selector=Bic`.
44    pub n_folds: usize,
45    /// Optional same-sample sequential diagnostic to run on K*. `None` disables
46    /// the diagnostic; `Some(method)` enables it (any `ConfirmatoryMethod`
47    /// except `Score` and `Auto`). Selection and test share data, so the resulting
48    /// pvalues are diagnostic only and not honest inference.
49    pub diagnostic: Option<ConfirmatoryMethod>,
50    /// Number of permutations for `raw_perm` / `split_exact` diagnostic. Inert
51    /// when `diagnostic is None`.
52    pub n_perm: usize,
53    /// Number of split-half repetitions for `split_nb` / `split_exact`
54    /// diagnostic. Inert when `diagnostic is None`.
55    pub n_splits: usize,
56    /// `split_nb` diagnostic only: run NB even on a design the sequence-level
57    /// auto-gate flags. Default `false`, which reroutes the diagnostic to
58    /// `split_exact`. Inert unless `diagnostic == Some(SplitNb)`.
59    pub force: bool,
60    /// Caller asserts X and y are already standardized; skips centering/scaling.
61    pub pre_standardized: bool,
62    /// RNG seed; `None` draws from OS entropy.
63    pub seed: Option<u64>,
64    /// Print progress to stderr (reserved for future verbose mode).
65    pub verbose: bool,
66}
67
68impl Default for FindKOptimalOpts {
69    fn default() -> Self {
70        Self {
71            selector: Selector::R2Se,
72            n_folds: 5,
73            diagnostic: None,
74            n_perm: 1000,
75            n_splits: 50,
76            force: false,
77            pre_standardized: false,
78            seed: None,
79            verbose: false,
80        }
81    }
82}
83
84/// Result of `pls1_find_k_optimal`.
85#[derive(Debug, Clone)]
86pub struct FindKOptimalOutput {
87    /// The selected number of components. `0` when the full-data fit cannot
88    /// extract a first component (`y` constant, or orthogonal to `X` up to
89    /// rounding: the `k_used = 0` zero model of `pls1_fit`); the score maps
90    /// are then empty and the diagnostic, if requested, tests nothing.
91    pub k_star: usize,
92    /// Selection criterion used (`"r2_se"`, `"r2_max"`, or `"bic"`).
93    pub selector: String,
94    /// CV R² per K (present for `R2Se` and `R2Max` selectors).
95    pub cv_scores: Option<BTreeMap<usize, f64>>,
96    /// SE of CV R² per K (present only for `R2Se` selector).
97    pub cv_scores_se: Option<BTreeMap<usize, f64>>,
98    /// BIC scores per K (present only for `Bic` selector).
99    pub bic_scores: Option<BTreeMap<usize, f64>>,
100    /// Per-component p-values from the same-sample sequential diagnostic up to
101    /// K* (present when `diagnostic.is_some()`). Same-sample → diagnostic only,
102    /// not honest inference.
103    pub pvalues: Option<Col<f64>>,
104    /// Name of the diagnostic method that actually ran (present when
105    /// `diagnostic.is_some()`). A `split_nb` request the sequence-level
106    /// auto-gate flagged reads `"split_exact"`.
107    pub diagnostic: Option<String>,
108    /// RNG seed actually used.
109    pub seed: u64,
110    /// Kish's effective sample size. Equals `n_samples` for uniform/absent weights.
111    pub n_eff: f64,
112    /// Stable rank of the standardized X, as the sequence-level auto-gate saw
113    /// it. `Some` whenever `diagnostic == Some(SplitNb)` — whether the gate
114    /// fired or not, and also under `force`. `None` otherwise, including when
115    /// no diagnostic was requested. NaN when the Gram matrix of the
116    /// standardized X is not finite; the gate then fires.
117    pub stable_rank: Option<f64>,
118}
119
120impl FindKOptimalOutput {
121    /// The component count a fit at the selected K uses: `k_star`.
122    ///
123    /// This is the policy of `pls1_fit(k = "optimal")` in every wrapper,
124    /// kept here so they share it: selection and fit are separate calls,
125    /// and a wrapper that composes them asks this rather than reading
126    /// `k_star` itself.
127    ///
128    /// # Errors
129    /// [`PlsKitError::OptimalNoComponent`] when `k_star == 0` (no first
130    /// component can be extracted). An explicit `k` still fits the
131    /// `k_used = 0` zero model; this refuses only to present that model as
132    /// the selected one.
133    pub fn k_to_fit(&self) -> PlsKitResult<usize> {
134        if self.k_star == 0 {
135            Err(PlsKitError::OptimalNoComponent)
136        } else {
137            Ok(self.k_star)
138        }
139    }
140}
141
142/// Optimal-K selection on full data. Optionally attaches a same-sample
143/// sequential diagnostic when `diagnostic.is_some()` — selection and test
144/// share data, so the diagnostic pvalues are not honest inference.
145///
146/// `Selector::Bic` counts each component as one degree of freedom. On wide `x`
147/// (p much larger than n) the effective degrees of freedom of a PLS fit are
148/// much larger, so BIC tends to select `k_max`. `Selector::R2Se` (the default)
149/// is recommended there.
150///
151/// # K-range cap (CV selectors)
152/// `R2Se` and `R2Max` evaluate components `k = 1..=max_comp` where
153/// `max_comp = min(k_max, n − n_folds − 2).max(1)`. This cap prevents CV
154/// folds from having fewer training rows than components. `Bic` fits once at
155/// `k_max` and sweeps; it is not capped. `cv_scores` (present for CV
156/// selectors) covers exactly `1..=max_comp`.
157///
158/// # No extractable component
159/// When the full-data fit truncates at the first component (`y` constant,
160/// or orthogonal to the columns of `X` up to rounding), every selector
161/// returns `k_star = 0` with an empty score map rather than a `k` the fit
162/// cannot produce. This is the same `0` that [`pls1_find_k_sequence`]
163/// reports when no component rejects. A requested diagnostic then runs no
164/// step: `pvalues` is empty and `diagnostic` names the method the
165/// `split_nb` auto-gate resolved to.
166///
167/// # Errors
168/// - `InvalidArgument` for `k_max == 0`
169/// - `KExceedsMax` for `k_max > n_features`
170/// - `DimensionMismatch` for shape disagreements
171/// - `InvalidArgument` if `diagnostic == Some(Score)` or `Some(Auto)`
172/// - `NonFiniteInput` when X, y, or weights contain NaN/inf
173/// - `InvalidWeights` for negative, all-zero, or insufficient-`n_eff` weights
174pub fn pls1_find_k_optimal(
175    x: MatRef<'_, f64>,
176    y: ColRef<'_, f64>,
177    k_max: usize,
178    weights: Option<ColRef<'_, f64>>,
179    opts: FindKOptimalOpts,
180) -> PlsKitResult<FindKOptimalOutput> {
181    crate::fit::with_thread_limit(|| find_k_optimal_impl(x, y, k_max, None, weights, opts))
182}
183
184/// Sparse counterpart of [`pls1_find_k_optimal`] (`docs/python/api.md`
185/// §2b.3): identical CV / selector machinery with the inner fitter swapped
186/// to the sparse fit at the caller's fixed `keep`. Same selectors, options,
187/// and output shape; `keep = n_features` reproduces the dense function
188/// bit-exactly.
189///
190/// The `Bic` selector reuses the dense complexity penalty `k · ln(n_eff)` —
191/// NOT a keep-aware sparse BIC: effective complexity scales with `keep` per
192/// component, so the dense penalty underpenalizes added components and
193/// biases the selected `k` upward under sparsity. This is a deliberate
194/// simplification (not a consequence of `keep` being fixed); BIC values are
195/// not comparable across `keep`. See
196/// `docs/concepts/PLS1/sparse.md` for the BIC bias.
197///
198/// # Errors
199/// Everything `pls1_find_k_optimal` returns, plus `InvalidArgument` for
200/// `keep == 0` or `keep > n_features`.
201pub fn spls1_find_k_optimal(
202    x: MatRef<'_, f64>,
203    y: ColRef<'_, f64>,
204    k_max: usize,
205    keep: usize,
206    weights: Option<ColRef<'_, f64>>,
207    opts: FindKOptimalOpts,
208) -> PlsKitResult<FindKOptimalOutput> {
209    crate::fit::with_thread_limit(|| {
210        crate::fit::validate_keep(keep, x.ncols())?;
211        // `keep = n_features` runs the dense path; see `spls1_find_k_sequence`.
212        find_k_optimal_impl(
213            x,
214            y,
215            k_max,
216            (keep < x.ncols()).then_some(keep),
217            weights,
218            opts,
219        )
220    })
221}
222
223#[allow(clippy::needless_pass_by_value)]
224#[allow(clippy::too_many_lines)]
225#[allow(clippy::type_complexity)]
226fn find_k_optimal_impl(
227    x: MatRef<'_, f64>,
228    y: ColRef<'_, f64>,
229    k_max: usize,
230    keep: Option<usize>,
231    weights: Option<ColRef<'_, f64>>,
232    opts: FindKOptimalOpts,
233) -> PlsKitResult<FindKOptimalOutput> {
234    let n = x.nrows();
235    if y.nrows() != n {
236        return Err(PlsKitError::DimensionMismatch {
237            x: (n, x.ncols()),
238            y: y.nrows(),
239        });
240    }
241    if k_max == 0 {
242        return Err(PlsKitError::InvalidArgument("k_max must be >= 1".into()));
243    }
244    let max_allowed = x.ncols();
245    if k_max > max_allowed {
246        return Err(PlsKitError::KExceedsMax {
247            k: k_max,
248            k_max: max_allowed,
249        });
250    }
251    if matches!(opts.diagnostic, Some(ConfirmatoryMethod::Score)) {
252        return Err(PlsKitError::InvalidArgument(
253            "score has no sequential variant".into(),
254        ));
255    }
256    if matches!(opts.diagnostic, Some(ConfirmatoryMethod::Auto)) {
257        return Err(PlsKitError::InvalidArgument(
258            "diagnostic does not accept 'auto'".into(),
259        ));
260    }
261    // Hoisted ahead of selection: `diagnostic="raw_perm"` uses a fixed
262    // 5-fold CV at every sequential step (`SEQUENTIAL_RAW_PERM_N_FOLDS`),
263    // same floor `run_incremental_sequence` enforces for
264    // `pls1_find_k_sequence`. Checking here, not only inside the branch
265    // that calls `run_incremental_sequence`, keeps the outcome independent
266    // of where selection lands K*: a `k_star = 0` selection (e.g. a
267    // constant `y`) must not skip this check, whatever `n` is.
268    if matches!(opts.diagnostic, Some(ConfirmatoryMethod::RawPerm))
269        && n <= crate::sequential::SEQUENTIAL_RAW_PERM_N_FOLDS
270    {
271        return Err(PlsKitError::InvalidArgument(format!(
272            "diagnostic='raw_perm' uses {f}-fold CV and needs n > {f} (got n={n}): with \
273             n_folds >= n every validation fold is a single row, so the pooled CV R² is \
274             undefined",
275            f = crate::sequential::SEQUENTIAL_RAW_PERM_N_FOLDS
276        )));
277    }
278
279    let (w_norm, n_eff_val) = crate::fit::validate_weights_for_k(weights, n, k_max)?;
280
281    let (seed_used, mut rng) = crate::rng::resolve_seed(opts.seed)?;
282
283    // Selector dispatch. A `y` with no extractable first component gives
284    // `k_star = 0` with empty score maps on every selector: the CV selectors
285    // get that from the shared CV layer (`cv_select`), the BIC selector from
286    // its own full-data fit (an empty sweep, see `select_bic`).
287    let (k_star, cv_scores, cv_scores_se, bic_scores, selector_str): (
288        usize,
289        Option<BTreeMap<usize, f64>>,
290        Option<BTreeMap<usize, f64>>,
291        Option<BTreeMap<usize, f64>>,
292        &'static str,
293    ) = match opts.selector {
294        Selector::R2Se => {
295            let (k, scores, se) = select_cv(
296                x,
297                y,
298                k_max,
299                opts.n_folds,
300                true,
301                w_norm.as_ref().map(Col::as_ref),
302                keep,
303                &mut rng,
304            )?;
305            (k, Some(scores), se, None, "r2_se")
306        }
307        Selector::R2Max => {
308            let (k, scores, _) = select_cv(
309                x,
310                y,
311                k_max,
312                opts.n_folds,
313                false,
314                w_norm.as_ref().map(Col::as_ref),
315                keep,
316                &mut rng,
317            )?;
318            (k, Some(scores), None, None, "r2_max")
319        }
320        Selector::Bic => {
321            let (k, scores) = select_bic(
322                x,
323                y,
324                k_max,
325                w_norm.as_ref().map(Col::as_ref),
326                n_eff_val,
327                keep,
328            )?;
329            (k, None, None, Some(scores), "bic")
330        }
331    };
332
333    let (pvalues, diagnostic_str, stable_rank) = if let (Some(method), 0) =
334        (opts.diagnostic, k_star)
335    {
336        // No component to test. Report the method the sequence would have
337        // run, with the same gate evaluation `run_incremental_sequence`
338        // makes, so `diagnostic` and `stable_rank` keep their meaning.
339        if matches!(method, ConfirmatoryMethod::SplitNb) {
340            let gate = crate::signal_test::resolve_split_nb(
341                x,
342                w_norm.as_ref().map(Col::as_ref),
343                n_eff_val,
344                opts.force,
345            );
346            (
347                Some(Col::zeros(0)),
348                Some(gate.method().as_str().to_owned()),
349                Some(gate.stable_rank),
350            )
351        } else {
352            (Some(Col::zeros(0)), Some(method.as_str().to_owned()), None)
353        }
354    } else if let Some(method) = opts.diagnostic {
355        // stop_early_override=true so we collect the full p-value vector up to K*.
356        let seq_args = SequentialArgs::defaults_for(method).ok_or_else(|| {
357            PlsKitError::InvalidArgument(format!("{} has no sequential variant", method.as_str()))
358        })?;
359        let seq_args = match seq_args {
360            SequentialArgs::RawPerm { .. } => SequentialArgs::RawPerm {
361                n_perm: opts.n_perm,
362            },
363            SequentialArgs::SplitNb { .. } => SequentialArgs::SplitNb {
364                n_splits: opts.n_splits,
365                force: opts.force,
366            },
367            SequentialArgs::SplitExact { .. } => SequentialArgs::SplitExact {
368                n_perm: opts.n_perm,
369                n_splits: opts.n_splits,
370            },
371            SequentialArgs::E => SequentialArgs::E,
372            SequentialArgs::Auto { .. } => {
373                unreachable!("`Some(Auto)` is rejected at the top of this function")
374            }
375        };
376        let r = run_incremental_sequence(
377            x,
378            y,
379            k_star,
380            w_norm.as_ref().map(Col::as_ref),
381            n_eff_val,
382            IncrementalSequenceOpts {
383                args: seq_args,
384                alpha: 0.05,
385                stop_early_override: true, // collect ALL p-values up to K*
386                pre_standardized: opts.pre_standardized,
387                seed: Some({
388                    use rand::Rng;
389                    rng.next_u64()
390                }),
391                verbose: opts.verbose,
392                keep,
393            },
394        )?;
395        // Echo what RAN, not what was asked for: the hoisted `split_nb`
396        // auto-gate can reroute the whole sequence to `split_exact`.
397        (Some(r.pvalues), Some(r.method), r.stable_rank)
398    } else {
399        (None, None, None)
400    };
401
402    Ok(FindKOptimalOutput {
403        k_star,
404        selector: selector_str.to_owned(),
405        cv_scores,
406        cv_scores_se,
407        bic_scores,
408        pvalues,
409        diagnostic: diagnostic_str,
410        seed: seed_used,
411        n_eff: n_eff_val,
412        stable_rank,
413    })
414}
415
416/// Opts for `pls1_find_k_sequence`.
417#[derive(Debug, Clone, Copy)]
418#[allow(clippy::struct_excessive_bools)]
419pub struct FindKSequenceOpts {
420    /// Per-step test method (any `ConfirmatoryMethod` except `Score`).
421    /// `SplitNb` is subject to the sequence-level auto-gate:
422    /// a flagged design runs `split_exact` instead, and
423    /// `FindKSequenceOutput.test_method` says so. `Auto` (the default)
424    /// resolves once on the full X to `split_exact` or `split_nb` (see
425    /// [`ConfirmatoryMethod::Auto`]), and every step runs that method.
426    pub test_method: ConfirmatoryMethod,
427    /// Significance threshold for sequential rejection.
428    pub alpha: f64,
429    /// Number of permutations for `raw_perm` / `split_exact` / `auto`.
430    pub n_perm: usize,
431    /// Number of split-half repetitions for `split_nb` / `split_exact` /
432    /// `auto`.
433    pub n_splits: usize,
434    /// `split_nb` only: run NB even on a design the sequence-level auto-gate
435    /// flags. Default `false`, which reroutes the whole sequence to
436    /// `split_exact`. `true` with `test_method = Auto` is an
437    /// `InvalidArgument`; inert for every other `test_method`.
438    pub force: bool,
439    /// Caller asserts X and y are already standardized; skips centering/scaling.
440    pub pre_standardized: bool,
441    /// RNG seed; `None` draws from OS entropy.
442    pub seed: Option<u64>,
443    /// Print progress to stderr (reserved for future verbose mode).
444    pub verbose: bool,
445}
446
447impl Default for FindKSequenceOpts {
448    fn default() -> Self {
449        Self {
450            test_method: ConfirmatoryMethod::Auto,
451            alpha: 0.05,
452            n_perm: 1000,
453            n_splits: 50,
454            force: false,
455            pre_standardized: false,
456            seed: None,
457            verbose: false,
458        }
459    }
460}
461
462/// Result of `pls1_find_k_sequence`.
463#[derive(Debug, Clone)]
464pub struct FindKSequenceOutput {
465    /// The selected number of components (0 if no rejection).
466    pub k_star: usize,
467    /// Per-component p-values, length `k_max`. NaN past the stop point.
468    pub pvalues: Col<f64>,
469    /// Name of the test method that actually ran. A `split_nb` request the
470    /// sequence-level auto-gate flagged reads `"split_exact"`.
471    pub test_method: String,
472    /// Significance threshold used.
473    pub alpha: f64,
474    /// RNG seed actually used.
475    pub seed: u64,
476    /// Kish's effective sample size. Equals `n_samples` for uniform/absent weights.
477    pub n_eff: f64,
478    /// Stable rank of the standardized X, as the sequence-level auto-gate saw
479    /// it. `Some` on an explicit `split_nb` request, and on an `"auto"`
480    /// request that reached the stable-rank check (p > 4, `n_eff` ≥ 250,
481    /// and p ≤ 100·n without `keep`); `None` otherwise. On a `split_nb` request it is set
482    /// whether the gate fired or not, and also under `force`. NaN when the
483    /// Gram matrix of the standardized X is not finite; the gate then fires.
484    pub stable_rank: Option<f64>,
485}
486
487impl FindKSequenceOutput {
488    /// The component count a fit at the selected K uses: `k_star`.
489    ///
490    /// The policy of `pls1_fit(k = "sequence")` in every wrapper; see
491    /// [`FindKOptimalOutput::k_to_fit`].
492    ///
493    /// # Errors
494    /// [`PlsKitError::SequenceNoRejection`] when `k_star == 0` (no
495    /// component rejected at `alpha`).
496    pub fn k_to_fit(&self) -> PlsKitResult<usize> {
497        if self.k_star == 0 {
498            Err(PlsKitError::SequenceNoRejection { alpha: self.alpha })
499        } else {
500            Ok(self.k_star)
501        }
502    }
503}
504
505/// Sequence-based K selection on full data. Stop-early is
506/// always on — call this when you want a frequentist guarantee on the
507/// nested chain.
508///
509/// `k_star = 0` when the first step does not reject, which includes a
510/// `y` from which no first component can be extracted (constant, or
511/// orthogonal to `X` up to rounding), matching [`pls1_find_k_optimal`].
512///
513/// # Errors
514/// - `InvalidArgument` for `k_max == 0`
515/// - `KExceedsMax` for `k_max > n_features`
516/// - `DimensionMismatch` for shape disagreements
517/// - `InvalidArgument` for `Score` test method (no sequential variant)
518/// - `InvalidArgument` for `Auto` with `n_splits < 2`, `n_perm < 1`, or
519///   `force = true`
520/// - `NonFiniteInput` when X, y, or weights contain NaN/inf
521/// - `InvalidWeights` for negative, all-zero, or insufficient-`n_eff` weights
522pub fn pls1_find_k_sequence(
523    x: MatRef<'_, f64>,
524    y: ColRef<'_, f64>,
525    k_max: usize,
526    weights: Option<ColRef<'_, f64>>,
527    opts: FindKSequenceOpts,
528) -> PlsKitResult<FindKSequenceOutput> {
529    crate::fit::with_thread_limit(|| find_k_sequence_impl(x, y, k_max, None, weights, opts))
530}
531
532/// Sparse counterpart of [`pls1_find_k_sequence`] (`docs/python/api.md`
533/// §2b.3): the same sequential closed test with the inner fitter swapped
534/// to the sparse fit at the caller's fixed `keep`. `keep` threads through
535/// BOTH per-step fit sites — the deflation fit and the per-component
536/// confirmatory test — so each step tests the sparse marginal component
537/// against the sparse residual. `keep = n_features` reproduces the dense
538/// function bit-exactly.
539///
540/// # Errors
541/// Everything `pls1_find_k_sequence` returns, plus `InvalidArgument` for
542/// `keep == 0` or `keep > n_features`.
543pub fn spls1_find_k_sequence(
544    x: MatRef<'_, f64>,
545    y: ColRef<'_, f64>,
546    k_max: usize,
547    keep: usize,
548    weights: Option<ColRef<'_, f64>>,
549    opts: FindKSequenceOpts,
550) -> PlsKitResult<FindKSequenceOutput> {
551    crate::fit::with_thread_limit(|| {
552        crate::fit::validate_keep(keep, x.ncols())?;
553        // `keep = n_features` keeps every column, so it runs the dense path
554        // (`None`). Passing `Some` would force split_exact's refit route and
555        // switch off auto's width rule, which key on whether `keep` is set,
556        // and the result would no longer match the dense function bit for bit.
557        find_k_sequence_impl(
558            x,
559            y,
560            k_max,
561            (keep < x.ncols()).then_some(keep),
562            weights,
563            opts,
564        )
565    })
566}
567
568#[allow(clippy::needless_pass_by_value)]
569#[allow(clippy::many_single_char_names)]
570fn find_k_sequence_impl(
571    x: MatRef<'_, f64>,
572    y: ColRef<'_, f64>,
573    k_max: usize,
574    keep: Option<usize>,
575    weights: Option<ColRef<'_, f64>>,
576    opts: FindKSequenceOpts,
577) -> PlsKitResult<FindKSequenceOutput> {
578    let n = x.nrows();
579    if y.nrows() != n {
580        return Err(PlsKitError::DimensionMismatch {
581            x: (n, x.ncols()),
582            y: y.nrows(),
583        });
584    }
585    if k_max == 0 {
586        return Err(PlsKitError::InvalidArgument("k_max must be >= 1".into()));
587    }
588    let max_allowed = x.ncols();
589    if k_max > max_allowed {
590        return Err(PlsKitError::KExceedsMax {
591            k: k_max,
592            k_max: max_allowed,
593        });
594    }
595
596    let (w_norm, n_eff_val) = crate::fit::validate_weights_for_k(weights, n, k_max)?;
597
598    let seq_args = SequentialArgs::defaults_for(opts.test_method).ok_or_else(|| {
599        PlsKitError::InvalidArgument(format!(
600            "test_method='{}' has no sequential variant",
601            opts.test_method.as_str()
602        ))
603    })?;
604    let seq_args = match seq_args {
605        SequentialArgs::RawPerm { .. } => SequentialArgs::RawPerm {
606            n_perm: opts.n_perm,
607        },
608        SequentialArgs::SplitNb { .. } => SequentialArgs::SplitNb {
609            n_splits: opts.n_splits,
610            force: opts.force,
611        },
612        SequentialArgs::SplitExact { .. } => SequentialArgs::SplitExact {
613            n_perm: opts.n_perm,
614            n_splits: opts.n_splits,
615        },
616        SequentialArgs::E => SequentialArgs::E,
617        SequentialArgs::Auto { .. } => SequentialArgs::Auto {
618            n_perm: opts.n_perm,
619            n_splits: opts.n_splits,
620        },
621    };
622    // `Auto` takes split_exact's count floors before it resolves, so a bad
623    // `n_perm` errors on every design. `force` belongs to an explicit
624    // `split_nb` request only.
625    if let SequentialArgs::Auto { n_perm, n_splits } = seq_args {
626        crate::signal_test::check_split_exact_counts(Some(n_perm), n_splits)?;
627        if opts.force {
628            return Err(PlsKitError::InvalidArgument(
629                "force applies to test_method='split_nb' only, not 'auto'".into(),
630            ));
631        }
632    }
633    let r = run_incremental_sequence(
634        x,
635        y,
636        k_max,
637        w_norm.as_ref().map(Col::as_ref),
638        n_eff_val,
639        IncrementalSequenceOpts {
640            args: seq_args,
641            alpha: opts.alpha,
642            stop_early_override: false,
643            pre_standardized: opts.pre_standardized,
644            seed: opts.seed,
645            verbose: opts.verbose,
646            keep,
647        },
648    )?;
649    let k_star = r.last_significant_k.unwrap_or(0);
650    Ok(FindKSequenceOutput {
651        k_star,
652        pvalues: r.pvalues,
653        // What RAN, not what was asked for — see `run_incremental_sequence`'s
654        // hoisted `split_nb` auto-gate.
655        test_method: r.method,
656        alpha: opts.alpha,
657        seed: r.seed,
658        n_eff: n_eff_val,
659        stable_rank: r.stable_rank,
660    })
661}
662
663// ──────────────────────────────────────────────────────────────────────────────
664// Internal selectors
665// ──────────────────────────────────────────────────────────────────────────────
666
667/// One fold of `cv_select`, shared by `select_cv` and
668/// `spls1_find_keep_optimal`: the training rows' standardization moments
669/// (weighted) and the inputs of a fit on X in place
670/// ([`FoldData::fit_predict`]), the standardized `y` of both sides (the
671/// validation side with the training moments), and, in `cv_select`'s
672/// second pass, the standardized blocks. Per-fold weights are re-normalized to
673/// mean 1; an unnormalizable slice yields `None` → that fold runs
674/// unweighted (kept rather than an explicit uniform fill, which would run
675/// the weighted code path and is not provably bit-identical to the
676/// unweighted path).
677struct FoldData {
678    train_idx: Vec<usize>,
679    val_idx: Vec<usize>,
680    /// The training rows' column means and scales, the bits of
681    /// `standardize_rows`'s, and `‖√w'·Xs_tr‖_F` from the same moments.
682    mean: Col<f64>,
683    scale: Col<f64>,
684    fro: f64,
685    /// `max_j |mean_j| / scale_j`: past `IMPLICIT_MAX_MEAN_RATIO` every fit
686    /// of the fold runs on the blocks.
687    mean_ratio: f64,
688    /// One factor per row of X: the `√w''` the fit on the training block
689    /// scales row `i` by (1 when the fold is unweighted), 0 on the
690    /// validation rows.
691    row_factor: Col<f64>,
692    /// `row_factor ⊙ ys` on the training rows, 0 on the validation rows.
693    ys_n: Col<f64>,
694    ys_tr: Col<f64>,
695    ys_val: Col<f64>,
696    train_w: Option<Col<f64>>,
697    val_w: Option<Col<f64>>,
698    /// The standardized training and validation blocks, written by the
699    /// first fit on them and kept for the fold's later fits.
700    blocks: Option<(Mat<f64>, Mat<f64>)>,
701    /// Set for `cv_select`'s second pass: every fit runs on the blocks.
702    on_blocks: bool,
703}
704
705#[allow(clippy::many_single_char_names)]
706#[allow(clippy::similar_names)]
707fn prep_fold(
708    x: MatRef<'_, f64>,
709    y: ColRef<'_, f64>,
710    weights: Option<ColRef<'_, f64>>,
711    folds: &[Vec<usize>],
712    fi: usize,
713    val_idx: &[usize],
714) -> PlsKitResult<FoldData> {
715    let train_idx: Vec<usize> = folds
716        .iter()
717        .enumerate()
718        .filter(|(j, _)| *j != fi)
719        .flat_map(|(_, f)| f.iter().copied())
720        .collect();
721    let y_tr = col_row_subset(y, &train_idx);
722    let y_val = col_row_subset(y, val_idx);
723    let nv = y_val.nrows();
724
725    // Slice and re-normalize weights for train fold (if weights are provided).
726    // An unnormalizable slice yields None → that fold runs unweighted.
727    let train_w_full: Option<Col<f64>> = weights.map(|w| col_row_subset(w, &train_idx));
728    let train_w_norm: Option<Col<f64>> = train_w_full
729        .as_ref()
730        .and_then(|w| normalize_weights(w.as_ref()));
731    let train_wref = train_w_norm.as_ref().map(Col::as_ref);
732
733    // Slice and re-normalize weights for val fold.
734    let val_w_full: Option<Col<f64>> = weights.map(|w| col_row_subset(w, val_idx));
735    let val_w_norm: Option<Col<f64>> = val_w_full
736        .as_ref()
737        .and_then(|w| normalize_weights(w.as_ref()));
738
739    let n_tr = train_idx.len();
740    let fx = crate::linalg::fit_x_moments(
741        x,
742        Some(&train_idx),
743        train_wref,
744        crate::fit::resolve_par(ParChoice::Auto, n_tr, x.ncols(), 1),
745    );
746    let mean_ratio = fx.max_mean_ratio();
747    let (ys_tr, ym, ys) = standardize1_weighted(y_tr.as_ref(), train_wref);
748    let ys_val = Col::<f64>::from_fn(nv, |i| (y_val[i] - ym) / ys);
749
750    // The fit on the training block renormalizes the fold's weights (and
751    // takes all-equal ones as none) and scales its rows by their square
752    // roots; the fit on X in place takes the same factors, row by row.
753    let (w_fit, _) = crate::fit::validate_and_normalize_weights(train_wref, n_tr, 1)?;
754    let sqw = w_fit.as_ref().map(|w| crate::linalg::sqrt_col(w.as_ref()));
755    let n = x.nrows();
756    let mut row_factor = Col::<f64>::zeros(n);
757    let mut ys_n = Col::<f64>::zeros(n);
758    for (i, &r) in train_idx.iter().enumerate() {
759        let f = sqw.as_ref().map_or(1.0, |s| s[i]);
760        row_factor[r] = f;
761        ys_n[r] = f * ys_tr[i];
762    }
763
764    Ok(FoldData {
765        train_idx,
766        val_idx: val_idx.to_vec(),
767        mean: fx.mean,
768        scale: fx.scale,
769        fro: fx.fro,
770        mean_ratio,
771        row_factor,
772        ys_n,
773        ys_tr,
774        ys_val,
775        train_w: train_w_norm,
776        val_w: val_w_norm,
777        blocks: None,
778        on_blocks: false,
779    })
780}
781
782impl FoldData {
783    /// Weighted total sum of squares of the validation `y` about its
784    /// (weighted) mean: the denominator of [`FoldData::r2`].
785    fn ss_tot(&self) -> f64 {
786        let nv = self.ys_val.nrows();
787        let val_wref = self.val_w.as_ref().map(Col::as_ref);
788        let mean_val = match val_wref {
789            None => {
790                if nv > 0 {
791                    (0..nv).map(|i| self.ys_val[i]).sum::<f64>() / nv as f64
792                } else {
793                    0.0
794                }
795            }
796            Some(wv) => (0..nv).map(|i| wv[i] * self.ys_val[i]).sum::<f64>() / nv as f64,
797        };
798        match val_wref {
799            None => (0..nv).map(|i| (self.ys_val[i] - mean_val).powi(2)).sum(),
800            Some(wv) => (0..nv)
801                .map(|i| wv[i] * (self.ys_val[i] - mean_val).powi(2))
802                .sum(),
803        }
804    }
805
806    /// Weighted validation R² of `y_pred` against `ss_tot` (from
807    /// [`FoldData::ss_tot`]); `0` on a validation `y` with no spread.
808    fn r2(&self, y_pred: &Col<f64>, ss_tot: f64) -> f64 {
809        let nv = self.ys_val.nrows();
810        let ss_res: f64 = match self.val_w.as_ref() {
811            None => (0..nv).map(|i| (y_pred[i] - self.ys_val[i]).powi(2)).sum(),
812            Some(wv) => (0..nv)
813                .map(|i| wv[i] * (y_pred[i] - self.ys_val[i]).powi(2))
814                .sum(),
815        };
816        if ss_tot > 0.0 {
817            1.0 - ss_res / ss_tot
818        } else {
819            0.0
820        }
821    }
822
823    /// Fits the training rows to `k` components (`keep` as in `pls1_fit`)
824    /// and returns the validation predictions of the coefficient vectors
825    /// `coefs` takes from the fit, one per vector.
826    ///
827    /// The fit runs on X in place ([`pls1_fit_implicit`] with
828    /// [`FoldData::row_factor`]): the validation rows of `Xs` are 0, so the
829    /// fit is the training rows' to rounding. A prediction is
830    /// `X_val·u − mean'u` with `u = c / scale` ([`FoldData::predict_in_place`]).
831    /// `None` leaves the fit to the standardized blocks: when the columns
832    /// sit past `IMPLICIT_MAX_MEAN_RATIO`, when the fit on X in place cannot
833    /// decide where to stop, or when a coefficient or a prediction is not
834    /// finite. With [`FoldData::on_blocks`] set, the fit runs on the blocks
835    /// ([`FoldData::fit_predict_on_copy`]) and is always `Some`.
836    fn fit_predict<F>(
837        &mut self,
838        x: MatRef<'_, f64>,
839        k: usize,
840        keep: Option<usize>,
841        coefs: F,
842    ) -> PlsKitResult<Option<Vec<Col<f64>>>>
843    where
844        F: Fn(&PreparedFit) -> Vec<Col<f64>>,
845    {
846        check_fit_y_and_k(x.ncols(), self.ys_tr.as_ref(), k, keep)?;
847        if self.on_blocks {
848            return self.fit_predict_on_copy(x, k, keep, coefs).map(Some);
849        }
850        let in_place = if self.mean_ratio <= IMPLICIT_MAX_MEAN_RATIO {
851            pls1_fit_implicit(
852                x,
853                self.ys_n.as_ref(),
854                crate::linalg::owned_col_slice(&self.mean),
855                crate::linalg::owned_col_slice(&self.scale),
856                Some(crate::linalg::owned_col_slice(&self.row_factor)),
857                k,
858                keep,
859                ParChoice::Auto,
860                self.fro,
861                self.mean_ratio,
862                self.train_idx.len(),
863            )
864        } else {
865            None
866        };
867        if let Some(fit) = in_place.filter(|f| f.coef.iter().all(|v| v.is_finite())) {
868            let par = crate::fit::resolve_par(ParChoice::Auto, x.nrows(), x.ncols(), k);
869            let preds = self.predict_in_place(x, &coefs(&fit), par);
870            if preds.iter().all(|p| p.iter().all(|v| v.is_finite())) {
871                return Ok(Some(preds));
872            }
873        }
874        Ok(None)
875    }
876
877    /// `Xs_val·c` for each `c` in `coefs`, from X in place: `Z = X_val·U`
878    /// with `U = [c / scale …]`, then `Z[i, c] − mean'u_c`. The validation
879    /// rows are copied into a block `linalg::stream_block_width(p)` rows at
880    /// a time (the rows [`cv_fold_bytes`] counts; change together) and each
881    /// block is one product at `par`, so nothing `n_val × p` is written.
882    fn predict_in_place(
883        &self,
884        x: MatRef<'_, f64>,
885        coefs: &[Col<f64>],
886        par: faer::Par,
887    ) -> Vec<Col<f64>> {
888        let d = x.ncols();
889        let nv = self.val_idx.len();
890        let u = Mat::<f64>::from_fn(d, coefs.len(), |j, c| coefs[c][j] / self.scale[j]);
891        let off: Vec<f64> = (0..coefs.len())
892            .map(|c| (0..d).map(|j| self.mean[j] * u[(j, c)]).sum())
893            .collect();
894        let mut preds: Vec<Col<f64>> = (0..coefs.len()).map(|_| Col::<f64>::zeros(nv)).collect();
895        let block = crate::linalg::stream_block_width(d);
896        for (b, rows) in self.val_idx.chunks(block).enumerate() {
897            let i0 = b * block;
898            let blk = crate::linalg::row_subset(x, rows);
899            let z = crate::linalg::mat_mul(blk.as_ref(), u.as_ref(), par);
900            for (c, pred) in preds.iter_mut().enumerate() {
901                for i in 0..rows.len() {
902                    pred[i0 + i] = z[(i, c)] - off[c];
903                }
904            }
905        }
906        preds
907    }
908
909    /// [`FoldData::fit_predict`] on the standardized blocks: the training
910    /// rows standardized with weighted moments, the validation rows with
911    /// the training rows' moments, written on the first call and kept for
912    /// the fold's later fits; then `pls1_fit_impl` on the training block
913    /// (`pre_standardized`, rows scaled by `√w''`) and the predictions
914    /// `xs_val·c`. The same bits on every layout of X.
915    fn fit_predict_on_copy<F>(
916        &mut self,
917        x: MatRef<'_, f64>,
918        k: usize,
919        keep: Option<usize>,
920        coefs: F,
921    ) -> PlsKitResult<Vec<Col<f64>>>
922    where
923        F: Fn(&PreparedFit) -> Vec<Col<f64>>,
924    {
925        let train_wref = self.train_w.as_ref().map(Col::as_ref);
926        let (xs_tr, xs_val) = self.blocks.get_or_insert_with(|| {
927            let (xs_tr, mean, scale) = standardize_rows(x, &self.train_idx, train_wref, None);
928            let xs_val =
929                standardize_apply_rows(x, &self.val_idx, mean.as_ref(), scale.as_ref(), None);
930            (xs_tr, xs_val)
931        });
932        let m = pls1_fit_impl(
933            xs_tr.as_ref(),
934            self.ys_tr.as_ref(),
935            KSpec::Fixed(k),
936            train_wref,
937            FitOpts {
938                pre_standardized: true,
939                // check_n_eff: false — per-fold slice may have low n_eff; let the math degrade
940                // and rely on the parent statistic to absorb noise.
941                check_n_eff: false,
942                // Auto: the fit picks its arm from its shape (`fit::resolve_par`),
943                // so the bits do not depend on the batch or the pool.
944                par: ParChoice::Auto,
945                keep,
946            },
947        )?;
948        let fit = PreparedFit {
949            t_scores: m.t_scores,
950            p_loadings: m.p_loadings,
951            w_star: m.w_star,
952            q_loadings: m.q_loadings,
953            coef: m.coef,
954            k_used: m.k_used,
955        };
956        Ok(coefs(&fit)
957            .iter()
958            .map(|c| crate::linalg::mat_vec(xs_val.as_ref(), c.as_ref(), faer::Par::Seq))
959            .collect())
960    }
961}
962
963/// The coefficient vectors of `fit` at `1..=k_used` components, whose
964/// validation predictions `select_cv` scores in each fold.
965fn sweep_coefs(fit: &PreparedFit) -> Vec<Col<f64>> {
966    (1..=fit.k_used)
967        .map(|k| {
968            crate::fit::pls1_coef_at_k(
969                &fit.w_star,
970                &fit.p_loadings,
971                &fit.q_loadings,
972                k,
973                faer::Par::Seq,
974            )
975        })
976        .collect()
977}
978
979/// Fold count the CV layer runs: the caller's `n_folds`, capped at `n − 2`
980/// and floored at 2. The floor can push the effective count back up to (or,
981/// for `n <= 1`, past) `n` at very small `n`; see
982/// [`reject_cv_leave_one_out`], which every caller of this function must
983/// check the result against.
984fn cv_n_folds(n: usize, requested: usize) -> usize {
985    requested.min(n.saturating_sub(2)).max(2)
986}
987
988/// Reject the same leave-one-out degeneracy `raw_perm` rejects, for the CV
989/// layer `cv_select` shares between `select_cv` (backs
990/// `pls1_find_k_optimal`'s `r2_se` / `r2_max`) and `spls1_find_keep_optimal`.
991/// `cv_n_folds` floors the effective fold count at 2, which for `n <= 2`
992/// gives `effective_n_folds >= n`: every validation fold is then a single
993/// row (`linalg::fold_split` puts at most `ceil(n / n_folds)` rows in any
994/// fold, and `n_folds >= n` makes that `<= 1`), so a training fold has too
995/// few rows to fit anything and every per-fold score is `NaN` or an
996/// uninformative constant: the same shape of degeneracy
997/// `signal_test::confirmatory_test_impl` rejects for `raw_perm`, just
998/// surfacing through the per-fold-averaged convention instead of the
999/// pooled `ss_res` / `ss_tot` one. `n / 2 < effective_n_folds < n` (some,
1000/// not all, folds singleton) is unaffected: at least one fold still has a
1001/// usable training set, so the mean CV score stays informative, just
1002/// noisier. `requested` is the caller's own `n_folds` (before the
1003/// `cv_n_folds` cap/floor), reported in the error so a caller who asked
1004/// for a fold count nowhere near the floor still sees what they passed,
1005/// not the capped value they cannot change.
1006fn reject_cv_leave_one_out(n: usize, requested: usize, effective: usize) -> PlsKitResult<()> {
1007    if effective >= n {
1008        return Err(PlsKitError::InvalidArgument(format!(
1009            "cross-validation needs n >= 3 (got n={n}): the fold count (requested \
1010             {requested}, capped at max(2, n - 2) = {effective}) would make every validation \
1011             fold a single row (leave-one-out)"
1012        )));
1013    }
1014    Ok(())
1015}
1016
1017/// The K-fold CV layer shared by `select_cv` (candidates are component
1018/// counts `1..=max_comp`) and `spls1_find_keep_optimal` (candidates are the
1019/// keep grid): shuffle, split into `n_folds` folds, run `fold_work` on each
1020/// fold ([`prep_fold`]; its fits read X in place, [`FoldData::fit_predict`]),
1021/// and pick through [`aggregate_and_pick`]. `fold_work(fd, cells)` returns
1022/// the validation R² of each candidate index in `cells`, in that order,
1023/// `None` where its fit is left to the fold's standardized blocks; `fit_k`
1024/// is the component count of its fits.
1025///
1026/// # No extractable component
1027/// Before any fold runs, it asks the full data whether a first component
1028/// exists at all ([`first_component_exhausted`], at `check_keep`). When it
1029/// does not (`y` constant, or orthogonal to the columns of `X` up to
1030/// rounding), it returns pick `0` with empty score maps: the `k_star = 0`
1031/// convention. The folds would still produce scores (0 on a constant `y`,
1032/// below 0 on an orthogonal one) and a pick, and a fold may even extract a
1033/// component the full data cannot, so a pick there names a model the
1034/// full-data fit returns as the `k_used = 0` zero model. The check draws
1035/// nothing from `rng`.
1036///
1037/// Each fold is independent and RNG-free (the only RNG use is the shuffle).
1038/// Two passes, both in batches of `resample::map_units` by the memory
1039/// budget. The first runs every fold on X in place, one unit being one
1040/// fold fit ([`cv_fold_bytes`] at `fit_k`). The second
1041/// (`resample::fill_pending`) reruns the candidates the first left `None`
1042/// on [`FoldData::on_blocks`], one unit adding the fold's two standardized
1043/// blocks (`resample::fold_blocks_bytes`) and, when weighted, the √w-scaled
1044/// copy of the training block the fit writes. A candidate's value is the
1045/// one its fit gives on either pass, so the result does not depend on the
1046/// batch size or on which worker runs which fold, and an error is the first
1047/// in fold order.
1048#[allow(clippy::too_many_arguments)]
1049#[allow(clippy::type_complexity)]
1050#[allow(clippy::many_single_char_names)]
1051fn cv_select<F>(
1052    x: MatRef<'_, f64>,
1053    y: ColRef<'_, f64>,
1054    weights: Option<ColRef<'_, f64>>,
1055    check_keep: Option<usize>,
1056    n_folds: usize,
1057    candidates: &[usize],
1058    fit_k: usize,
1059    use_1se: bool,
1060    rng: &mut crate::rng::Rng,
1061    fold_work: F,
1062) -> PlsKitResult<(usize, BTreeMap<usize, f64>, BTreeMap<usize, f64>)>
1063where
1064    F: Fn(&mut FoldData, &[usize]) -> PlsKitResult<Vec<Option<f64>>> + Sync,
1065{
1066    use crate::resample::{copy_bytes, fill_pending, fold_blocks_bytes, map_units};
1067    if first_component_exhausted(x, y, weights, check_keep)? {
1068        return Ok((0, BTreeMap::new(), BTreeMap::new()));
1069    }
1070    let (n, p) = (x.nrows(), x.ncols());
1071    let mut indices: Vec<usize> = (0..n).collect();
1072    indices.shuffle(rng);
1073    let folds = crate::linalg::fold_split(&indices, n_folds);
1074    let run = |fi: usize, cells: &[usize], on_blocks: bool| {
1075        let mut fd = prep_fold(x, y, weights, &folds, fi, folds[fi].as_slice())?;
1076        fd.on_blocks = on_blocks;
1077        fold_work(&mut fd, cells)
1078    };
1079
1080    // Pass 1: every candidate fit on X in place; an error stands for its
1081    // fold.
1082    let all: Vec<usize> = (0..candidates.len()).collect();
1083    let n_val = folds.iter().map(Vec::len).max().unwrap_or(0);
1084    let unit = cv_fold_bytes(p, fit_k, n_val);
1085    let rows: Vec<Vec<Option<PlsKitResult<f64>>>> =
1086        map_units(folds.len(), unit, |fi| match run(fi, &all, false) {
1087            Ok(row) => row.into_iter().map(|v| v.map(Ok)).collect(),
1088            Err(e) => vec![Some(Err(e))],
1089        });
1090
1091    // Pass 2: the candidates left to the blocks. A failed fit fills the
1092    // fold's first pending cell with its error, which aborts the selection
1093    // before the other cells are read.
1094    let n_tr = n - folds.iter().map(Vec::len).min().unwrap_or(0);
1095    let fit_copy = if weights.is_some() {
1096        copy_bytes(n_tr, p)
1097    } else {
1098        0
1099    };
1100    let rows = fill_pending(
1101        rows,
1102        unit.saturating_add(fold_blocks_bytes(n, &folds, p))
1103            .saturating_add(fit_copy),
1104        |fi, pending| match run(fi, pending, true) {
1105            Ok(vals) => vals
1106                .into_iter()
1107                .map(|v| Ok(v.expect("a fit on the blocks always predicts")))
1108                .collect(),
1109            Err(e) => std::iter::once(Err(e))
1110                .chain(pending[1..].iter().map(|_| Ok(f64::NAN)))
1111                .collect(),
1112        },
1113    );
1114    let r2_matrix: Vec<Vec<f64>> = rows
1115        .into_iter()
1116        .map(|row| row.into_iter().collect::<PlsKitResult<Vec<f64>>>())
1117        .collect::<PlsKitResult<Vec<_>>>()?;
1118    Ok(aggregate_and_pick(&r2_matrix, candidates, use_1se))
1119}
1120
1121/// Bytes of the large buffers one fold of [`cv_select`] holds while it fits
1122/// X in place: one fit to `fit_k` components ([`crate::fit::pls1_fit_bytes`];
1123/// a fold runs its fits one after another, the keep grid's included), the
1124/// `p × fit_k` coefficient matrix of [`FoldData::predict_in_place`] and the
1125/// fold's column means and scales, and the block of validation rows
1126/// `predict_in_place` copies, `linalg::stream_block_width(p)` rows (change
1127/// together) but no more than `n_val`, the largest fold. The n-vectors are
1128/// not counted. The second pass of `cv_select` counts the standardized
1129/// blocks on top. Mirrored by `cv_fold` in `tests/memory_budget_parity.rs`
1130/// (change together).
1131fn cv_fold_bytes(p: usize, fit_k: usize, n_val: usize) -> usize {
1132    use crate::resample::copy_bytes;
1133    crate::fit::pls1_fit_bytes(p, fit_k)
1134        .saturating_add(copy_bytes(p, fit_k.saturating_add(2)))
1135        .saturating_add(copy_bytes(
1136            crate::linalg::stream_block_width(p).min(n_val),
1137            p,
1138        ))
1139}
1140
1141/// Aggregate per-fold R² rows into mean/SE maps keyed by `candidates`,
1142/// then pick: the best finite mean, or — under the 1-SE rule — the
1143/// SMALLEST candidate whose mean is within one SE of the best. Shared by
1144/// `select_cv` (candidates = component counts `1..=max_comp`) and
1145/// `spls1_find_keep_optimal` (candidates = the keep grid): "smallest" is
1146/// the parsimony order on both axes.
1147fn aggregate_and_pick(
1148    r2_matrix: &[Vec<f64>],
1149    candidates: &[usize],
1150    use_1se: bool,
1151) -> (usize, BTreeMap<usize, f64>, BTreeMap<usize, f64>) {
1152    let mut cv_scores = BTreeMap::new();
1153    let mut cv_scores_se = BTreeMap::new();
1154    for (ci, &cand) in candidates.iter().enumerate() {
1155        let finite: Vec<f64> = r2_matrix
1156            .iter()
1157            .map(|row| row[ci])
1158            .filter(|v| v.is_finite())
1159            .collect();
1160        if finite.is_empty() {
1161            cv_scores.insert(cand, f64::NAN);
1162            cv_scores_se.insert(cand, f64::NAN);
1163        } else {
1164            let mean = finite.iter().sum::<f64>() / finite.len() as f64;
1165            let var = finite.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
1166                / (finite.len() - 1).max(1) as f64;
1167            let se = var.sqrt() / (finite.len() as f64).sqrt();
1168            cv_scores.insert(cand, mean);
1169            cv_scores_se.insert(cand, se);
1170        }
1171    }
1172    let best = *cv_scores
1173        .iter()
1174        .filter(|(_, v)| v.is_finite())
1175        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
1176        .map_or(&candidates[0], |(c, _)| c);
1177    let pick = if use_1se {
1178        let threshold = cv_scores[&best] - cv_scores_se.get(&best).copied().unwrap_or(0.0);
1179        cv_scores
1180            .iter()
1181            .filter(|(_, v)| v.is_finite() && **v >= threshold)
1182            .map(|(c, _)| *c)
1183            .min()
1184            .unwrap_or(candidates[0])
1185    } else {
1186        best
1187    };
1188    (pick, cv_scores, cv_scores_se)
1189}
1190
1191#[allow(clippy::too_many_arguments)]
1192#[allow(clippy::many_single_char_names)]
1193#[allow(clippy::similar_names)]
1194#[allow(clippy::too_many_lines)]
1195#[allow(clippy::type_complexity)]
1196fn select_cv(
1197    x: MatRef<'_, f64>,
1198    y: ColRef<'_, f64>,
1199    k_max: usize,
1200    n_folds: usize,
1201    use_1se: bool,
1202    weights: Option<ColRef<'_, f64>>,
1203    keep: Option<usize>,
1204    rng: &mut crate::rng::Rng,
1205) -> PlsKitResult<(usize, BTreeMap<usize, f64>, Option<BTreeMap<usize, f64>>)> {
1206    // k-fold CV with optional 1-SE rule.
1207    // CV-R² convention (cites the "CV-R² convention" anchor on `pooled_cv_r2_columns` in
1208    // signal_test.rs): this function uses per-fold *weighted-validation* R² averaged across
1209    // folds, which differs from raw_perm's pooled SS_res/SS_tot convention. The two are not
1210    // interchangeable; each method owns the convention appropriate to its statistic.
1211    let n = x.nrows();
1212    let requested_n_folds = n_folds;
1213    let n_folds = cv_n_folds(n, n_folds);
1214    reject_cv_leave_one_out(n, requested_n_folds, n_folds)?;
1215    let max_comp = k_max.min(n.saturating_sub(n_folds + 2)).max(1);
1216
1217    // row[k-1] = CV R² for this fold and k components.
1218    // One fit gives every candidate: `cells` is all of them on either pass.
1219    let fold_work = |fd: &mut FoldData, cells: &[usize]| -> PlsKitResult<Vec<Option<f64>>> {
1220        let Some(preds) = fd.fit_predict(x, max_comp, keep, sweep_coefs)? else {
1221            return Ok(vec![None; cells.len()]);
1222        };
1223        let ss_tot = fd.ss_tot();
1224        let mut row = vec![f64::NAN; max_comp];
1225        for (k, y_pred) in preds.iter().enumerate() {
1226            row[k] = fd.r2(y_pred, ss_tot);
1227        }
1228        Ok(cells.iter().map(|&c| Some(row[c])).collect())
1229    };
1230
1231    let candidates: Vec<usize> = (1..=max_comp).collect();
1232    let (k_star, cv_scores, cv_scores_se) = cv_select(
1233        x,
1234        y,
1235        weights,
1236        keep,
1237        n_folds,
1238        &candidates,
1239        max_comp,
1240        use_1se,
1241        rng,
1242        fold_work,
1243    )?;
1244    Ok((
1245        k_star,
1246        cv_scores,
1247        if use_1se { Some(cv_scores_se) } else { None },
1248    ))
1249}
1250
1251/// The BIC selector: one fit to `k_max` on X in its own layout, which forms
1252/// the weighted standardization moments itself (whatever `pre_standardized`
1253/// says, as [`first_component_exhausted`] does) and writes a standardized
1254/// copy only where it falls back to one; then
1255/// `BIC(k) = n_eff · ln(SSR_k / n_eff) + k · ln(n_eff)` for each
1256/// `k ≤ k_used`. `SSR_k` is the squared norm of the outcome the fit worked
1257/// on less its first `k` score columns times their y-loadings: the weighted
1258/// residual sum of squares of the `k`-component model, with no pass over X.
1259/// No component kept gives `k_star = 0` and no scores. Two layouts of the
1260/// same X agree to rounding, not to the bit.
1261#[allow(clippy::many_single_char_names)]
1262fn select_bic(
1263    x: MatRef<'_, f64>,
1264    y: ColRef<'_, f64>,
1265    k_max: usize,
1266    weights: Option<ColRef<'_, f64>>,
1267    n_eff: f64,
1268    keep: Option<usize>,
1269) -> PlsKitResult<(usize, BTreeMap<usize, f64>)> {
1270    let m = pls1_fit_impl(
1271        x,
1272        y,
1273        KSpec::Fixed(k_max),
1274        weights,
1275        FitOpts {
1276            pre_standardized: false,
1277            // check_n_eff: false: the BIC sweep below iterates 1..=k_used and
1278            // is truncation-tolerant by design (rank-deficient X just shortens
1279            // the sweep); n_eff was already validated at the top-level entry.
1280            check_n_eff: false,
1281            keep,
1282            ..FitOpts::default()
1283        },
1284    )?;
1285    // A first-component truncation (constant y, or y orthogonal to X up to
1286    // rounding) leaves nothing to sweep: no component, no score.
1287    if m.w_star.ncols() == 0 {
1288        return Ok((0, BTreeMap::new()));
1289    }
1290    // The outcome the fit worked on, `√w''·ys`, and its residual after each
1291    // component, `e ← e − q_k·t_k`, in ascending `i`. `m.weights` holds the
1292    // weights the fit normalized and standardized `y` with (`None` when
1293    // there are none or all are equal), so `ys` has the bits of the fit's
1294    // standardized `y`.
1295    let (ys, _, _) = standardize1_weighted(y, m.weights.as_ref().map(Col::as_ref));
1296    let mut e: Col<f64> = match m.weights.as_ref() {
1297        Some(w) => crate::fit::scale_col(ys.as_ref(), crate::linalg::sqrt_col(w.as_ref()).as_ref()),
1298        None => ys.clone(),
1299    };
1300    let mut best_k = 1;
1301    let mut best_bic = f64::INFINITY;
1302    let mut bic_scores = BTreeMap::<usize, f64>::new();
1303    let nv = ys.nrows();
1304    for k in 1..=m.k_used {
1305        let t = m.t_scores.col(k - 1);
1306        let q = m.q_loadings[k - 1];
1307        for i in 0..nv {
1308            e[i] -= q * t[i];
1309        }
1310        let ssr_w: f64 = (0..nv).map(|i| e[i] * e[i]).sum();
1311        let bic = n_eff * (ssr_w / n_eff).ln() + k as f64 * n_eff.ln();
1312        bic_scores.insert(k, bic);
1313        if bic < best_bic {
1314            best_bic = bic;
1315            best_k = k;
1316        }
1317    }
1318    Ok((best_k, bic_scores))
1319}
1320
1321/// Whether the full-data fit truncates at the first component: `y` constant,
1322/// or orthogonal to the columns of `X` up to rounding, which `pls1_fit`
1323/// returns as the `k_used = 0` zero model. Fits X in its own layout exactly
1324/// as `select_bic` does (weighted moments, whatever `pre_standardized` says)
1325/// and threads `keep`, so the sparse entry points ask the sparse fit. When
1326/// the first weight norm sits within the truncation floor's band, the fit
1327/// falls back to a standardized copy in X's layout, so there the answer can
1328/// differ between layouts.
1329fn first_component_exhausted(
1330    x: MatRef<'_, f64>,
1331    y: ColRef<'_, f64>,
1332    weights: Option<ColRef<'_, f64>>,
1333    keep: Option<usize>,
1334) -> PlsKitResult<bool> {
1335    let m = pls1_fit_impl(
1336        x,
1337        y,
1338        KSpec::Fixed(1),
1339        weights,
1340        FitOpts {
1341            pre_standardized: false,
1342            // check_n_eff: false: the truncation is the answer sought here,
1343            // not an error, and n_eff was validated at the entry.
1344            check_n_eff: false,
1345            keep,
1346            ..FitOpts::default()
1347        },
1348    )?;
1349    Ok(m.k_used == 0)
1350}
1351
1352/// Logged geometric keep grid for `spls1_find_keep_optimal`: powers of two
1353/// in `[1, n_features)` plus the dense endpoint `n_features`. Both endpoints
1354/// are always included — so `keep = n_features` (which reproduces
1355/// the dense fit bit for bit) is always on the grid. The 1-SE parsimony selector makes exact spacing non-critical.
1356fn keep_grid(n_features: usize) -> Vec<usize> {
1357    let mut grid = Vec::new();
1358    let mut v = 1usize;
1359    while v < n_features {
1360        grid.push(v);
1361        v = v.saturating_mul(2);
1362    }
1363    grid.push(n_features);
1364    grid
1365}
1366
1367/// Opts for `spls1_find_keep_optimal`.
1368#[derive(Debug, Clone, Copy)]
1369pub struct FindKeepOptimalOpts {
1370    /// Number of CV folds.
1371    pub n_folds: usize,
1372    /// RNG seed; `None` draws from OS entropy.
1373    pub seed: Option<u64>,
1374    /// Print the swept keep grid to stderr.
1375    pub verbose: bool,
1376}
1377
1378impl Default for FindKeepOptimalOpts {
1379    fn default() -> Self {
1380        Self {
1381            n_folds: 5,
1382            seed: None,
1383            verbose: false,
1384        }
1385    }
1386}
1387
1388/// Result of `spls1_find_keep_optimal`.
1389#[derive(Debug, Clone)]
1390pub struct FindKeepOptimalOutput {
1391    /// Selected keep-count: the SPARSEST grid point whose mean CV R² is
1392    /// within 1 SE of the best. `0` when the full-data fit cannot extract a
1393    /// first component at any keep (`y` constant, or orthogonal to `X` up to
1394    /// rounding); the score maps and `keep_grid` are then empty. The same
1395    /// `0` that `spls1_find_k_optimal` reports as `k_star` on that input.
1396    pub keep_star: usize,
1397    /// The fixed component count the sweep ran at (echoes the caller's `k`).
1398    pub k: usize,
1399    /// Mean CV R² per swept keep.
1400    pub cv_scores: BTreeMap<usize, f64>,
1401    /// SE of CV R² per swept keep.
1402    pub cv_scores_se: BTreeMap<usize, f64>,
1403    /// The keep grid actually swept, reported so no cap is applied
1404    /// silently. Empty when `keep_star = 0`: nothing was swept.
1405    pub keep_grid: Vec<usize>,
1406    /// RNG seed actually used.
1407    pub seed: u64,
1408    /// Kish's effective sample size. Equals `n_samples` for uniform/absent weights.
1409    pub n_eff: f64,
1410}
1411
1412/// Keep-count tuning at fixed `k` (`docs/python/api.md` §2b.2): sweep a
1413/// logged geometric keep grid over `[1, n_features]`, compute per-fold CV R²
1414/// at each grid point, and return the sparsest keep whose mean CV R² is
1415/// within 1 SE of the best. Sparsity is tuned inside the training split,
1416/// never on test data — same honest-inference contract as
1417/// `pls1_find_k_optimal`.
1418///
1419/// Runs through the CV layer `select_cv` uses (`cv_select`: folds,
1420/// aggregation, 1-SE band) with its own per-fold work: `keep` is a
1421/// fit-time hard threshold (not post-hoc truncatable like `k`), so each
1422/// grid point is an independent fit, `n_folds × |grid|` fits.
1423///
1424/// # No extractable component
1425/// When the full-data fit cannot extract a first component at any keep
1426/// (`y` constant, or orthogonal to the columns of `X` up to rounding), it
1427/// returns `keep_star = 0` with empty score maps and an empty `keep_grid`
1428/// rather than a keep that selects nothing, matching the `k_star = 0` of
1429/// [`spls1_find_k_optimal`] on the same input.
1430///
1431/// # Errors
1432/// - `DimensionMismatch` for shape disagreements
1433/// - `InvalidArgument` for `k == 0`
1434/// - `KExceedsMax` for `k > n_features`
1435/// - `NonFiniteInput` / `InvalidWeights` as per `pls1_fit`
1436#[allow(clippy::needless_pass_by_value)]
1437#[allow(clippy::many_single_char_names)]
1438pub fn spls1_find_keep_optimal(
1439    x: MatRef<'_, f64>,
1440    y: ColRef<'_, f64>,
1441    k: usize,
1442    weights: Option<ColRef<'_, f64>>,
1443    opts: FindKeepOptimalOpts,
1444) -> PlsKitResult<FindKeepOptimalOutput> {
1445    crate::fit::with_thread_limit(|| find_keep_optimal_impl(x, y, k, weights, opts))
1446}
1447
1448/// Body of [`spls1_find_keep_optimal`], on the caller's pool.
1449#[allow(clippy::needless_pass_by_value)]
1450#[allow(clippy::many_single_char_names)]
1451pub(crate) fn find_keep_optimal_impl(
1452    x: MatRef<'_, f64>,
1453    y: ColRef<'_, f64>,
1454    k: usize,
1455    weights: Option<ColRef<'_, f64>>,
1456    opts: FindKeepOptimalOpts,
1457) -> PlsKitResult<FindKeepOptimalOutput> {
1458    let n = x.nrows();
1459    if y.nrows() != n {
1460        return Err(PlsKitError::DimensionMismatch {
1461            x: (n, x.ncols()),
1462            y: y.nrows(),
1463        });
1464    }
1465    let d = x.ncols();
1466    if k == 0 {
1467        return Err(PlsKitError::InvalidArgument("k must be >= 1".into()));
1468    }
1469    if k > d {
1470        return Err(PlsKitError::KExceedsMax { k, k_max: d });
1471    }
1472    crate::fit::check_finite_mat(x)?;
1473    crate::fit::check_finite_col(y)?;
1474    let (w_norm, n_eff_val) = crate::fit::validate_weights_for_k(weights, n, k)?;
1475
1476    let (seed_used, mut rng) = crate::rng::resolve_seed(opts.seed)?;
1477    let grid = keep_grid(d);
1478    if opts.verbose {
1479        eprintln!("spls1_find_keep_optimal: sweeping keep grid {grid:?} at fixed k={k}");
1480    }
1481
1482    let n_folds = cv_n_folds(n, opts.n_folds);
1483    reject_cv_leave_one_out(n, opts.n_folds, n_folds)?;
1484    let wref = w_norm.as_ref().map(Col::as_ref);
1485
1486    let fold_work = |fd: &mut FoldData, cells: &[usize]| -> PlsKitResult<Vec<Option<f64>>> {
1487        let ss_tot = fd.ss_tot();
1488        cells
1489            .iter()
1490            .map(|&gi| {
1491                let preds = fd.fit_predict(x, k, Some(grid[gi]), |fit| vec![fit.coef.clone()])?;
1492                Ok(preds.map(|p| fd.r2(&p[0], ss_tot)))
1493            })
1494            .collect()
1495    };
1496
1497    // The first-component check runs at the dense endpoint: selection only
1498    // shrinks the first weight vector (`‖select(X'y)‖ ≤ ‖X'y‖`), so a `y` the
1499    // dense fit cannot extract a component from gives none at any keep, and
1500    // one it can gives a component at the dense endpoint of the grid.
1501    let (keep_star, cv_scores, cv_scores_se) = cv_select(
1502        x, y, wref, None, n_folds, &grid, k, true, &mut rng, fold_work,
1503    )?;
1504    // Nothing was swept when there is no first component (`keep_star = 0`).
1505    let grid = if keep_star == 0 { Vec::new() } else { grid };
1506
1507    Ok(FindKeepOptimalOutput {
1508        keep_star,
1509        k,
1510        cv_scores,
1511        cv_scores_se,
1512        keep_grid: grid,
1513        seed: seed_used,
1514        n_eff: n_eff_val,
1515    })
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521    use crate::fit::pls1_fit;
1522    use crate::linalg::standardize_weighted;
1523    use crate::test_support::synth;
1524    use faer::Mat;
1525    use rand::SeedableRng;
1526
1527    /// The two inputs whose full-data fit truncates at the first component:
1528    /// a constant `y`, and a `y` residualized on `[1, X]` (orthogonal to the
1529    /// standardized columns of `X` up to rounding).
1530    #[allow(clippy::many_single_char_names)]
1531    fn degenerate_ys(n: usize, d: usize, seed: u64) -> (Mat<f64>, [(&'static str, Col<f64>); 2]) {
1532        use rand::RngExt;
1533        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(seed);
1534        let x = Mat::<f64>::from_fn(n, d, |_, _| rng.random_range(-1.0..1.0));
1535        let e = Col::<f64>::from_fn(n, |_| rng.random_range(-1.0..1.0));
1536        // Gram-Schmidt basis of [1, X], orthogonalized twice for accuracy.
1537        let basis = crate::test_support::orthonormal_basis(
1538            Col::<f64>::from_fn(n, |_| 1.0).as_ref(),
1539            x.as_ref(),
1540            0.0,
1541        );
1542        let mut y_orth = e;
1543        crate::test_support::project_off(&basis, &mut y_orth);
1544        let y_const = Col::<f64>::from_fn(n, |_| 3.0);
1545        (x, [("constant", y_const), ("orthogonal", y_orth)])
1546    }
1547
1548    /// A first-component truncation leaves no component to select, so every
1549    /// selector, dense and sparse, reports `k_star = 0` with an empty score
1550    /// map. Reporting `k_star = 1` instead would pair it with an empty map
1551    /// (BIC) or with NaN (constant `y`) or negative (orthogonal `y`) scores
1552    /// (CV selectors).
1553    #[test]
1554    fn optimal_first_component_truncation_returns_k_star_zero() {
1555        let (x, ys) = degenerate_ys(60, 8, 5);
1556        for (name, y) in &ys {
1557            // The premise: pls1_fit gives the zero model on this y.
1558            let m = pls1_fit(
1559                x.as_ref(),
1560                y.as_ref(),
1561                KSpec::Fixed(2),
1562                None,
1563                FitOpts::default(),
1564            )
1565            .unwrap();
1566            assert_eq!(m.k_used, 0, "{name}: premise");
1567            for selector in [Selector::R2Se, Selector::R2Max, Selector::Bic] {
1568                let opts = FindKOptimalOpts {
1569                    selector,
1570                    seed: Some(3),
1571                    ..Default::default()
1572                };
1573                let dense = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, None, opts).unwrap();
1574                let sparse =
1575                    spls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, 4, None, opts).unwrap();
1576                for (path, r) in [("pls1", &dense), ("spls1", &sparse)] {
1577                    let tag = format!("{name} {path} {selector:?}");
1578                    assert_eq!(r.k_star, 0, "{tag}");
1579                    assert_eq!(r.seed, 3, "{tag}");
1580                    match selector {
1581                        Selector::R2Se => {
1582                            assert!(r.cv_scores.as_ref().unwrap().is_empty(), "{tag}");
1583                            assert!(r.cv_scores_se.as_ref().unwrap().is_empty(), "{tag}");
1584                            assert!(r.bic_scores.is_none(), "{tag}");
1585                        }
1586                        Selector::R2Max => {
1587                            assert!(r.cv_scores.as_ref().unwrap().is_empty(), "{tag}");
1588                            assert!(r.cv_scores_se.is_none(), "{tag}");
1589                            assert!(r.bic_scores.is_none(), "{tag}");
1590                        }
1591                        Selector::Bic => {
1592                            assert!(r.cv_scores.is_none(), "{tag}");
1593                            assert!(r.bic_scores.as_ref().unwrap().is_empty(), "{tag}");
1594                        }
1595                    }
1596                    assert!(r.pvalues.is_none() && r.diagnostic.is_none(), "{tag}");
1597                }
1598            }
1599        }
1600    }
1601
1602    /// The "fit at the selected K" policy lives in the core, so every
1603    /// wrapper's `k = "optimal"` / `k = "sequence"` gets the same answer:
1604    /// `k_star` itself, or a typed error when there is no component to fit.
1605    #[test]
1606    fn k_to_fit_refuses_k_star_zero() {
1607        let (x, ys) = degenerate_ys(60, 8, 5);
1608        for (name, y) in &ys {
1609            let opt = pls1_find_k_optimal(
1610                x.as_ref(),
1611                y.as_ref(),
1612                3,
1613                None,
1614                FindKOptimalOpts {
1615                    seed: Some(3),
1616                    ..Default::default()
1617                },
1618            )
1619            .unwrap();
1620            assert_eq!(opt.k_star, 0, "{name}");
1621            let e = opt.k_to_fit().unwrap_err();
1622            assert!(matches!(e, PlsKitError::OptimalNoComponent), "{name}: {e}");
1623            assert_eq!(e.code(), "optimal_no_component");
1624
1625            let seq = pls1_find_k_sequence(
1626                x.as_ref(),
1627                y.as_ref(),
1628                3,
1629                None,
1630                FindKSequenceOpts {
1631                    test_method: ConfirmatoryMethod::E,
1632                    seed: Some(3),
1633                    ..Default::default()
1634                },
1635            )
1636            .unwrap();
1637            assert_eq!(seq.k_star, 0, "{name}");
1638            let e = seq.k_to_fit().unwrap_err();
1639            assert!(
1640                matches!(e, PlsKitError::SequenceNoRejection { alpha } if alpha.to_bits() == 0.05_f64.to_bits()),
1641                "{name}: {e}"
1642            );
1643            assert_eq!(e.code(), "sequence_no_rejection");
1644        }
1645
1646        let (x, y) = synth(80, 5, 1, 5.0, 1);
1647        let opt = pls1_find_k_optimal(
1648            x.as_ref(),
1649            y.as_ref(),
1650            4,
1651            None,
1652            FindKOptimalOpts {
1653                seed: Some(7),
1654                ..Default::default()
1655            },
1656        )
1657        .unwrap();
1658        assert!(opt.k_star >= 1);
1659        assert_eq!(opt.k_to_fit().unwrap(), opt.k_star);
1660        let seq = pls1_find_k_sequence(
1661            x.as_ref(),
1662            y.as_ref(),
1663            3,
1664            None,
1665            FindKSequenceOpts {
1666                test_method: ConfirmatoryMethod::E,
1667                seed: Some(7),
1668                ..Default::default()
1669            },
1670        )
1671        .unwrap();
1672        assert!(seq.k_star >= 1);
1673        assert_eq!(seq.k_to_fit().unwrap(), seq.k_star);
1674    }
1675
1676    /// The keep sweep answers the same question from the same shared CV
1677    /// layer: with no first component at any keep, `keep_star = 0` (the
1678    /// `k_star = 0` convention) with empty score maps and an empty swept
1679    /// grid, rather than `keep_star = 1` over scores of 0 (constant) or
1680    /// below 0 (orthogonal).
1681    #[test]
1682    fn keep_optimal_first_component_truncation_returns_keep_star_zero() {
1683        let (x, ys) = degenerate_ys(60, 8, 5);
1684        for (name, y) in &ys {
1685            for k in [1, 2] {
1686                let r = spls1_find_keep_optimal(
1687                    x.as_ref(),
1688                    y.as_ref(),
1689                    k,
1690                    None,
1691                    FindKeepOptimalOpts {
1692                        seed: Some(3),
1693                        ..Default::default()
1694                    },
1695                )
1696                .unwrap();
1697                let tag = format!("{name} k={k}");
1698                assert_eq!(r.keep_star, 0, "{tag}");
1699                assert_eq!(r.k, k, "{tag}");
1700                assert_eq!(r.seed, 3, "{tag}");
1701                assert!(r.cv_scores.is_empty(), "{tag}");
1702                assert!(r.cv_scores_se.is_empty(), "{tag}");
1703                assert!(r.keep_grid.is_empty(), "{tag}");
1704            }
1705        }
1706    }
1707
1708    /// With `k_star = 0` a requested diagnostic runs no step: `pvalues` is
1709    /// empty (length `k_star`), and `diagnostic` / `stable_rank` report what
1710    /// the gate resolved to, as they would on a sequence that ran.
1711    #[test]
1712    fn optimal_diagnostic_at_k_star_zero_is_empty() {
1713        // n = 20 trips the split_nb gate's effective-sample floor.
1714        let (x, ys) = degenerate_ys(20, 5, 9);
1715        let gate = crate::signal_test::split_nb_gate(x.as_ref(), None).unwrap();
1716        assert!(gate.fires);
1717        for (name, y) in &ys {
1718            for (method, force, expect, has_rank) in [
1719                (ConfirmatoryMethod::E, false, "e", false),
1720                (ConfirmatoryMethod::RawPerm, false, "raw_perm", false),
1721                (ConfirmatoryMethod::SplitNb, false, "split_exact", true),
1722                (ConfirmatoryMethod::SplitNb, true, "split_nb", true),
1723            ] {
1724                for selector in [Selector::Bic, Selector::R2Se] {
1725                    let r = pls1_find_k_optimal(
1726                        x.as_ref(),
1727                        y.as_ref(),
1728                        2,
1729                        None,
1730                        FindKOptimalOpts {
1731                            selector,
1732                            diagnostic: Some(method),
1733                            force,
1734                            n_perm: 20,
1735                            n_splits: 5,
1736                            seed: Some(7),
1737                            ..Default::default()
1738                        },
1739                    )
1740                    .unwrap();
1741                    let tag = format!("{name} {selector:?} {method:?} force={force}");
1742                    assert_eq!(r.k_star, 0, "{tag}");
1743                    assert_eq!(r.pvalues.as_ref().unwrap().nrows(), 0, "{tag}");
1744                    assert_eq!(r.diagnostic.as_deref(), Some(expect), "{tag}");
1745                    assert_eq!(r.stable_rank.is_some(), has_rank, "{tag}");
1746                    if has_rank {
1747                        assert_eq!(
1748                            r.stable_rank.unwrap().to_bits(),
1749                            gate.stable_rank.to_bits(),
1750                            "{tag}"
1751                        );
1752                    }
1753                }
1754            }
1755        }
1756    }
1757
1758    /// `diagnostic="raw_perm"` needs `n > 5` (fixed 5-fold CV at every
1759    /// sequential step), rejected up front, before selection runs, so the
1760    /// outcome does not depend on where selection lands `k_star`: a
1761    /// non-constant `y` at n = 4 selects `k_star = 1` with the CV
1762    /// selectors (which would otherwise reach `run_incremental_sequence`
1763    /// and hit the error there instead), and a constant `y` selects
1764    /// `k_star = 0` (which runs no sequential step at all, so without the
1765    /// hoist it would report empty `pvalues` and no error, at any `n`).
1766    /// Both must raise the same way, with the `raw_perm` validator's message
1767    /// (`check_n_eff_for_k` also returns `InvalidArgument`, so the variant
1768    /// alone does not say which check fired), at every n <= 5.
1769    #[test]
1770    fn optimal_raw_perm_diagnostic_rejects_small_n_regardless_of_k_star() {
1771        for n in [3_usize, 4, 5] {
1772            let (x, y) = synth(n, 3, 1, 4.0, 5);
1773            let (_, ys) = degenerate_ys(n, 3, 5);
1774            for selector in [Selector::Bic, Selector::R2Se] {
1775                for (name, y) in [("signal", &y), (ys[0].0, &ys[0].1), (ys[1].0, &ys[1].1)] {
1776                    let r = pls1_find_k_optimal(
1777                        x.as_ref(),
1778                        y.as_ref(),
1779                        2,
1780                        None,
1781                        FindKOptimalOpts {
1782                            selector,
1783                            diagnostic: Some(ConfirmatoryMethod::RawPerm),
1784                            n_perm: 20,
1785                            seed: Some(7),
1786                            ..Default::default()
1787                        },
1788                    );
1789                    match r {
1790                        Err(PlsKitError::InvalidArgument(msg)) => {
1791                            assert!(msg.contains("n > 5"), "n={n} {selector:?} {name}: {msg}");
1792                        }
1793                        other => panic!(
1794                            "n={n} {selector:?} {name}: expected InvalidArgument, got {other:?}"
1795                        ),
1796                    }
1797                }
1798            }
1799        }
1800    }
1801
1802    /// `n = 2` is leave-one-out: `cv_n_folds`'s floor of 2 pushes the
1803    /// effective fold count back up to `n`, so every validation fold is a
1804    /// single row and every training fold is too small to fit anything.
1805    /// The CV score for that fold is then `NaN` or an uninformative
1806    /// constant, depending on how a single-row training fold's
1807    /// zero-variance column happens to fit; either way `n = 2` must be
1808    /// rejected, the same degeneracy `raw_perm` rejects, just surfacing
1809    /// through the per-fold-averaged convention instead of the pooled one.
1810    /// The `spls1_find_keep_optimal` row covers the second call site of
1811    /// `reject_cv_leave_one_out`.
1812    #[test]
1813    fn optimal_rejects_leave_one_out_at_n_eq_2() {
1814        let (x, y) = synth(2, 3, 1, 4.0, 6);
1815        for selector in [Selector::R2Se, Selector::R2Max] {
1816            let r = pls1_find_k_optimal(
1817                x.as_ref(),
1818                y.as_ref(),
1819                1,
1820                None,
1821                FindKOptimalOpts {
1822                    selector,
1823                    ..Default::default()
1824                },
1825            );
1826            assert!(
1827                matches!(r, Err(PlsKitError::InvalidArgument(_))),
1828                "{selector:?}: expected InvalidArgument for n = 2, got {r:?}"
1829            );
1830        }
1831        let r = spls1_find_keep_optimal(
1832            x.as_ref(),
1833            y.as_ref(),
1834            1,
1835            None,
1836            FindKeepOptimalOpts::default(),
1837        );
1838        assert!(
1839            matches!(r, Err(PlsKitError::InvalidArgument(_))),
1840            "find_keep: expected InvalidArgument for n = 2, got {r:?}"
1841        );
1842    }
1843
1844    /// The `split_nb` gate reads its effective-sample input off ONE formula
1845    /// at every entry point. These weights sit on the `n_eff` floor of 25:
1846    /// Kish `n_eff` of the raw weights is `24.999999999999996` (fires), of
1847    /// their normalized copy `25.00000000000002` (does not). Recomputing it
1848    /// from the normalized weights the sequence is handed would run `split_nb`
1849    /// on a design `split_nb_gate` and `pls1_confirmatory_test` both reroute.
1850    #[test]
1851    fn split_nb_gate_agrees_across_entry_points_at_the_n_eff_floor() {
1852        let (x, y) = synth(26, 8, 1, 4.0, 3);
1853        let w = Col::<f64>::from_fn(26, |i| if i == 25 { 2.083_333_333_333_333 } else { 1.0 });
1854        // Without the weights n = 26 clears the floor, so the size half of
1855        // the rule is what decides below.
1856        assert!(
1857            !crate::signal_test::split_nb_gate(x.as_ref(), None)
1858                .unwrap()
1859                .fires
1860        );
1861        let gate = crate::signal_test::split_nb_gate(x.as_ref(), Some(w.as_ref())).unwrap();
1862        assert!(gate.n_eff < 25.0, "n_eff = {}", gate.n_eff);
1863        assert!(gate.fires);
1864
1865        let conf = crate::signal_test::pls1_confirmatory_test(
1866            crate::signal_test::ConfirmatoryTestInput::Raw {
1867                x: x.as_ref(),
1868                y: y.as_ref(),
1869                k: 1,
1870                weights: Some(w.as_ref()),
1871            },
1872            crate::signal_test::ConfirmatoryTestOpts {
1873                args: crate::signal_test::ConfirmatoryArgs::SplitNb {
1874                    n_splits: 5,
1875                    force: false,
1876                },
1877                seed: Some(1),
1878                ..Default::default()
1879            },
1880        )
1881        .unwrap();
1882        assert_eq!(conf.test_method, "split_exact");
1883        assert_eq!(
1884            conf.stable_rank.unwrap().to_bits(),
1885            gate.stable_rank.to_bits()
1886        );
1887
1888        let seq = pls1_find_k_sequence(
1889            x.as_ref(),
1890            y.as_ref(),
1891            2,
1892            Some(w.as_ref()),
1893            FindKSequenceOpts {
1894                test_method: ConfirmatoryMethod::SplitNb,
1895                n_perm: 20,
1896                n_splits: 5,
1897                seed: Some(1),
1898                ..Default::default()
1899            },
1900        )
1901        .unwrap();
1902        assert_eq!(seq.test_method, "split_exact");
1903        assert_eq!(seq.n_eff.to_bits(), gate.n_eff.to_bits());
1904        assert_eq!(
1905            seq.stable_rank.unwrap().to_bits(),
1906            gate.stable_rank.to_bits()
1907        );
1908
1909        let opt = pls1_find_k_optimal(
1910            x.as_ref(),
1911            y.as_ref(),
1912            2,
1913            Some(w.as_ref()),
1914            FindKOptimalOpts {
1915                selector: Selector::Bic,
1916                diagnostic: Some(ConfirmatoryMethod::SplitNb),
1917                n_perm: 20,
1918                n_splits: 5,
1919                seed: Some(1),
1920                ..Default::default()
1921            },
1922        )
1923        .unwrap();
1924        assert!(opt.k_star >= 1);
1925        assert_eq!(opt.diagnostic.as_deref(), Some("split_exact"));
1926        assert_eq!(
1927            opt.stable_rank.unwrap().to_bits(),
1928            gate.stable_rank.to_bits()
1929        );
1930    }
1931
1932    /// The sequence path already reports `k_star = 0` on these inputs (its
1933    /// first step does not reject); pinned so the `find_k` family stays
1934    /// consistent.
1935    #[test]
1936    fn sequence_first_component_truncation_returns_k_star_zero() {
1937        let (x, ys) = degenerate_ys(60, 8, 5);
1938        for (name, y) in &ys {
1939            for method in [
1940                ConfirmatoryMethod::E,
1941                ConfirmatoryMethod::RawPerm,
1942                ConfirmatoryMethod::SplitExact,
1943                ConfirmatoryMethod::SplitNb,
1944            ] {
1945                let opts = FindKSequenceOpts {
1946                    test_method: method,
1947                    n_perm: 50,
1948                    n_splits: 10,
1949                    force: true,
1950                    seed: Some(3),
1951                    ..Default::default()
1952                };
1953                let dense = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 3, None, opts).unwrap();
1954                let sparse =
1955                    spls1_find_k_sequence(x.as_ref(), y.as_ref(), 3, 4, None, opts).unwrap();
1956                for (path, r) in [("pls1", &dense), ("spls1", &sparse)] {
1957                    let tag = format!("{name} {path} {method:?}");
1958                    assert_eq!(r.k_star, 0, "{tag}");
1959                    assert_eq!(r.pvalues.nrows(), 3, "{tag}");
1960                    assert!(r.pvalues[0] >= r.alpha, "{tag}: p1={}", r.pvalues[0]);
1961                    assert!(r.pvalues[1].is_nan() && r.pvalues[2].is_nan(), "{tag}");
1962                }
1963            }
1964        }
1965    }
1966
1967    /// `force` has to be reachable from the optimal entry point's diagnostic
1968    /// too, not only from the sequence API: n = 20 trips the gate, and only
1969    /// the opts field can hold NB in place. The gate is hoisted into
1970    /// `run_incremental_sequence`, so the diagnostic path inherits it and the
1971    /// echoed name says what ran (`"split_exact"` on the unforced call).
1972    /// The gate's rank survives the trip out through the diagnostic branch,
1973    /// and only the `split_nb` diagnostic produces it.
1974    #[test]
1975    fn optimal_diagnostic_force_is_settable_from_public_opts() {
1976        let (x, y) = synth(20, 5, 1, 5.0, 1);
1977        let opts = |force: bool| FindKOptimalOpts {
1978            selector: Selector::Bic,
1979            diagnostic: Some(ConfirmatoryMethod::SplitNb),
1980            n_splits: 10,
1981            force,
1982            seed: Some(7),
1983            ..Default::default()
1984        };
1985        let rerouted = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 2, None, opts(false)).unwrap();
1986        assert_eq!(rerouted.diagnostic.as_deref(), Some("split_exact"));
1987        assert!(rerouted.stable_rank.is_some());
1988        let forced = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 2, None, opts(true)).unwrap();
1989        assert_eq!(forced.diagnostic.as_deref(), Some("split_nb"));
1990        for diagnostic in [Some(ConfirmatoryMethod::RawPerm), None] {
1991            let r = pls1_find_k_optimal(
1992                x.as_ref(),
1993                y.as_ref(),
1994                2,
1995                None,
1996                FindKOptimalOpts {
1997                    diagnostic,
1998                    n_splits: 10,
1999                    n_perm: 50,
2000                    seed: Some(7),
2001                    ..Default::default()
2002                },
2003            )
2004            .unwrap();
2005            assert!(r.stable_rank.is_none(), "{diagnostic:?}");
2006        }
2007    }
2008
2009    /// On an adequate design (n = 80) the `split_nb` gate clears, and the
2010    /// result still carries the rank it saw: `stable_rank` reports what the
2011    /// gate evaluated, not whether it fired.
2012    #[test]
2013    fn sequence_reports_pvalues_n_eff_and_gate_rank() {
2014        let (x, y) = synth(80, 5, 1, 5.0, 1);
2015        let r = pls1_find_k_sequence(
2016            x.as_ref(),
2017            y.as_ref(),
2018            4,
2019            None,
2020            FindKSequenceOpts {
2021                test_method: ConfirmatoryMethod::SplitNb,
2022                n_splits: 30,
2023                alpha: 0.05,
2024                seed: Some(7),
2025                ..Default::default()
2026            },
2027        )
2028        .unwrap();
2029        assert_eq!(r.pvalues.nrows(), 4);
2030        assert_eq!(r.test_method, "split_nb");
2031        assert!((r.n_eff - 80.0).abs() < 1e-9);
2032        assert!(r.stable_rank.is_some());
2033    }
2034
2035    /// `force` has to be reachable from the public sequence API, not just from
2036    /// the crate-internal variant knob: n = 20 trips the gate, and only the
2037    /// opts field can hold NB in place.
2038    #[test]
2039    fn sequence_force_is_settable_from_public_opts() {
2040        let (x, y) = synth(20, 5, 1, 5.0, 1);
2041        let opts = |force: bool| FindKSequenceOpts {
2042            test_method: ConfirmatoryMethod::SplitNb,
2043            n_splits: 10,
2044            alpha: 0.05,
2045            force,
2046            seed: Some(7),
2047            ..Default::default()
2048        };
2049        let rerouted = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 2, None, opts(false)).unwrap();
2050        assert_eq!(rerouted.test_method, "split_exact");
2051        let forced = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 2, None, opts(true)).unwrap();
2052        assert_eq!(forced.test_method, "split_nb");
2053        // Both carry what the gate saw — that is what makes the reroute
2054        // explainable to the caller, and `force` doesn't suppress it.
2055        assert_eq!(rerouted.stable_rank, forced.stable_rank);
2056        assert!(rerouted.stable_rank.is_some());
2057    }
2058
2059    /// The sequence resolves `Auto` once on the full X, so at one seed it
2060    /// returns what an explicit sequence of the resolved method returns,
2061    /// field by field except `stable_rank`: n = 60 resolves to
2062    /// `split_exact`, n = 300 with ten iid columns to `split_nb`.
2063    #[test]
2064    fn sequence_auto_matches_the_resolved_method_at_the_same_seed() {
2065        let opts = |test_method| FindKSequenceOpts {
2066            test_method,
2067            n_perm: 19,
2068            n_splits: 4,
2069            seed: Some(5),
2070            ..Default::default()
2071        };
2072        for ((x, y), explicit, has_rank) in [
2073            (
2074                synth(60, 6, 1, 4.0, 2),
2075                ConfirmatoryMethod::SplitExact,
2076                false,
2077            ),
2078            (synth(300, 10, 1, 1.0, 3), ConfirmatoryMethod::SplitNb, true),
2079        ] {
2080            let run = |m| pls1_find_k_sequence(x.as_ref(), y.as_ref(), 3, None, opts(m)).unwrap();
2081            let mut got = run(ConfirmatoryMethod::Auto);
2082            let mut want = run(explicit);
2083            assert_eq!(got.test_method, explicit.as_str());
2084            assert_eq!(got.stable_rank.is_some(), has_rank, "{explicit:?}");
2085            got.stable_rank = None;
2086            want.stable_rank = None;
2087            assert_eq!(format!("{got:?}"), format!("{want:?}"), "{explicit:?}");
2088        }
2089    }
2090
2091    /// Under `Auto` the sequence checks `split_exact`'s count floors and
2092    /// refuses `force` before it resolves, so all three hold on a design that
2093    /// resolves to `split_nb`.
2094    #[test]
2095    fn sequence_auto_rejects_bad_counts_and_force() {
2096        let (x, y) = synth(300, 10, 1, 1.0, 3);
2097        let base = FindKSequenceOpts {
2098            test_method: ConfirmatoryMethod::Auto,
2099            n_perm: 19,
2100            n_splits: 4,
2101            seed: Some(5),
2102            ..Default::default()
2103        };
2104        assert_eq!(
2105            pls1_find_k_sequence(x.as_ref(), y.as_ref(), 1, None, base)
2106                .unwrap()
2107                .test_method,
2108            "split_nb"
2109        );
2110        for (tag, opts) in [
2111            ("n_perm = 0", FindKSequenceOpts { n_perm: 0, ..base }),
2112            (
2113                "n_splits = 1",
2114                FindKSequenceOpts {
2115                    n_splits: 1,
2116                    ..base
2117                },
2118            ),
2119            (
2120                "force",
2121                FindKSequenceOpts {
2122                    force: true,
2123                    ..base
2124                },
2125            ),
2126        ] {
2127            let r = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 1, None, opts);
2128            assert!(
2129                matches!(r, Err(PlsKitError::InvalidArgument(_))),
2130                "{tag}: {r:?}"
2131            );
2132        }
2133    }
2134
2135    /// `Score` has no sequential variant, as a diagnostic or as the
2136    /// sequence's own method, and `Auto` is not a diagnostic. Each refusal
2137    /// has its own message.
2138    #[test]
2139    fn score_and_auto_are_refused_where_they_have_no_meaning() {
2140        let (x, y) = synth(60, 5, 1, 4.0, 3);
2141        let optimal = |diagnostic| {
2142            pls1_find_k_optimal(
2143                x.as_ref(),
2144                y.as_ref(),
2145                3,
2146                None,
2147                FindKOptimalOpts {
2148                    diagnostic: Some(diagnostic),
2149                    ..Default::default()
2150                },
2151            )
2152            .map(|_| ())
2153        };
2154        let sequence = pls1_find_k_sequence(
2155            x.as_ref(),
2156            y.as_ref(),
2157            3,
2158            None,
2159            FindKSequenceOpts {
2160                test_method: ConfirmatoryMethod::Score,
2161                ..Default::default()
2162            },
2163        )
2164        .map(|_| ());
2165        for (row, got, want) in [
2166            (
2167                "optimal, diagnostic score",
2168                optimal(ConfirmatoryMethod::Score),
2169                "score has no sequential variant",
2170            ),
2171            (
2172                "sequence, test_method score",
2173                sequence,
2174                "test_method='score' has no sequential variant",
2175            ),
2176            (
2177                "optimal, diagnostic auto",
2178                optimal(ConfirmatoryMethod::Auto),
2179                "diagnostic does not accept 'auto'",
2180            ),
2181        ] {
2182            match got {
2183                Err(PlsKitError::InvalidArgument(msg)) => assert_eq!(msg, want, "{row}"),
2184                other => panic!("{row}: expected InvalidArgument, got {other:?}"),
2185            }
2186        }
2187    }
2188
2189    #[test]
2190    fn spls1_find_k_optimal_dense_endpoint_bit_parity() {
2191        // keep = n_features must reproduce pls1_find_k_optimal exactly
2192        // (same seed → same RNG stream → identical floats).
2193        let (x, y) = synth(80, 5, 1, 5.0, 1);
2194        for diagnostic in [None, Some(ConfirmatoryMethod::SplitExact)] {
2195            let opts = FindKOptimalOpts {
2196                selector: Selector::R2Se,
2197                diagnostic,
2198                n_perm: 99,
2199                seed: Some(7),
2200                ..Default::default()
2201            };
2202            let dense = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, None, opts).unwrap();
2203            let sparse = spls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, 5, None, opts).unwrap();
2204            assert_eq!(dense.k_star, sparse.k_star, "{diagnostic:?}");
2205            assert_eq!(dense.seed, sparse.seed, "{diagnostic:?}");
2206            let (dm, sm) = (dense.cv_scores.unwrap(), sparse.cv_scores.unwrap());
2207            assert_eq!(dm.len(), sm.len());
2208            for (k, v) in &dm {
2209                assert_eq!(
2210                    v.to_bits(),
2211                    sm[k].to_bits(),
2212                    "{diagnostic:?} cv_scores[{k}]"
2213                );
2214            }
2215            let bits =
2216                |p: Option<Col<f64>>| p.map(|p| p.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
2217            assert_eq!(
2218                bits(dense.pvalues),
2219                bits(sparse.pvalues),
2220                "{diagnostic:?} pvalues"
2221            );
2222            assert_eq!(
2223                dense.stable_rank.map(f64::to_bits),
2224                sparse.stable_rank.map(f64::to_bits),
2225                "{diagnostic:?} stable_rank"
2226            );
2227        }
2228    }
2229
2230    /// The sparse BIC sweep reports a score for every `k` in `1..=k_used`,
2231    /// selects its first minimum (as `select_bic`'s strict `<` does), and
2232    /// actually runs sparse: `keep = 2` changes the scores relative to the
2233    /// dense sweep on the same data.
2234    #[test]
2235    fn spls1_find_k_optimal_bic_runs_sparse() {
2236        let (x, y) = synth(60, 5, 2, 4.0, 3);
2237        let opts = FindKOptimalOpts {
2238            selector: Selector::Bic,
2239            seed: Some(13),
2240            ..Default::default()
2241        };
2242        let r = spls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, 2, None, opts).unwrap();
2243        let bic = r.bic_scores.as_ref().expect("bic selector reports scores");
2244        let keys: Vec<usize> = bic.keys().copied().collect();
2245        assert!(
2246            !keys.is_empty() && keys == (1..=keys.len()).collect::<Vec<_>>(),
2247            "{keys:?}"
2248        );
2249        let argmin = bic
2250            .iter()
2251            .min_by(|a, b| a.1.total_cmp(b.1))
2252            .map(|(k, _)| *k)
2253            .unwrap();
2254        assert_eq!(r.k_star, argmin);
2255        let dense = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 4, None, opts).unwrap();
2256        let dense_bic = dense.bic_scores.unwrap();
2257        assert!(
2258            bic.iter()
2259                .any(|(k, v)| dense_bic.get(k).is_none_or(|d| d.to_bits() != v.to_bits())),
2260            "keep = 2 must change the BIC sweep"
2261        );
2262    }
2263
2264    /// `keep` above `n_features` is rejected by both keep-taking `find_k`
2265    /// entry points.
2266    #[test]
2267    fn spls1_find_k_entries_reject_bad_keep() {
2268        let (x, y) = synth(60, 5, 1, 4.0, 3);
2269        let keep = 9;
2270        let e = spls1_find_k_optimal(
2271            x.as_ref(),
2272            y.as_ref(),
2273            3,
2274            keep,
2275            None,
2276            FindKOptimalOpts::default(),
2277        );
2278        assert!(
2279            matches!(e, Err(PlsKitError::InvalidArgument(_))),
2280            "optimal keep={keep}: {e:?}"
2281        );
2282        let e = spls1_find_k_sequence(
2283            x.as_ref(),
2284            y.as_ref(),
2285            3,
2286            keep,
2287            None,
2288            FindKSequenceOpts::default(),
2289        );
2290        assert!(
2291            matches!(e, Err(PlsKitError::InvalidArgument(_))),
2292            "sequence keep={keep}: {e:?}"
2293        );
2294    }
2295
2296    #[test]
2297    fn spls1_find_k_sequence_dense_endpoint_bit_parity() {
2298        let (x, y) = synth(80, 5, 1, 5.0, 1);
2299        for test_method in [
2300            ConfirmatoryMethod::SplitNb,
2301            ConfirmatoryMethod::SplitExact,
2302            ConfirmatoryMethod::Auto,
2303        ] {
2304            let opts = FindKSequenceOpts {
2305                test_method,
2306                n_splits: 30,
2307                n_perm: 99,
2308                alpha: 0.05,
2309                seed: Some(7),
2310                ..Default::default()
2311            };
2312            let dense = pls1_find_k_sequence(x.as_ref(), y.as_ref(), 4, None, opts).unwrap();
2313            let sparse = spls1_find_k_sequence(x.as_ref(), y.as_ref(), 4, 5, None, opts).unwrap();
2314            assert_eq!(dense.k_star, sparse.k_star, "{test_method:?}");
2315            assert_eq!(dense.seed, sparse.seed, "{test_method:?}");
2316            assert_eq!(dense.test_method, sparse.test_method, "{test_method:?}");
2317            assert_eq!(
2318                dense.stable_rank.map(f64::to_bits),
2319                sparse.stable_rank.map(f64::to_bits),
2320                "{test_method:?} stable_rank"
2321            );
2322            for i in 0..4 {
2323                assert_eq!(
2324                    dense.pvalues[i].to_bits(),
2325                    sparse.pvalues[i].to_bits(),
2326                    "{test_method:?} pvalues[{i}]"
2327                );
2328            }
2329        }
2330    }
2331
2332    #[test]
2333    fn keep_grid_powers_of_two_with_endpoints() {
2334        assert_eq!(keep_grid(1), vec![1]);
2335        assert_eq!(keep_grid(6), vec![1, 2, 4, 6]);
2336        assert_eq!(keep_grid(8), vec![1, 2, 4, 8]);
2337        assert_eq!(keep_grid(100), vec![1, 2, 4, 8, 16, 32, 64, 100]);
2338    }
2339
2340    /// The keep call site applies the 1-SE rule, not the argmax. The design
2341    /// (2 signal columns of 6, SNR 1.0) is chosen so the two differ: the
2342    /// best mean CV score sits at keep = 4, while keep = 2 is within one SE
2343    /// of it; on a strong-signal design both land on the same grid point and
2344    /// the rule would go unchecked.
2345    #[test]
2346    fn spls1_find_keep_optimal_selects_sparsest_within_1se() {
2347        let (x, y) = synth(80, 6, 2, 1.0, 1);
2348        let r = spls1_find_keep_optimal(
2349            x.as_ref(),
2350            y.as_ref(),
2351            1,
2352            None,
2353            FindKeepOptimalOpts {
2354                seed: Some(7),
2355                ..Default::default()
2356            },
2357        )
2358        .unwrap();
2359        assert_eq!(r.keep_grid, vec![1, 2, 4, 6]);
2360        assert_eq!(r.k, 1);
2361        assert!((r.n_eff - 80.0).abs() < 1e-9);
2362        // Self-consistency of the 1-SE parsimony rule against the returned maps:
2363        // keep_star is the SMALLEST grid point within 1 SE of the best mean.
2364        let best = r
2365            .cv_scores
2366            .iter()
2367            .filter(|(_, v)| v.is_finite())
2368            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
2369            .map(|(k, _)| *k)
2370            .unwrap();
2371        let threshold = r.cv_scores[&best] - r.cv_scores_se[&best];
2372        let expected = r
2373            .cv_scores
2374            .iter()
2375            .filter(|(_, v)| v.is_finite() && **v >= threshold)
2376            .map(|(k, _)| *k)
2377            .min()
2378            .unwrap();
2379        assert_ne!(
2380            best, r.keep_star,
2381            "fixture must separate the 1-SE pick from the argmax"
2382        );
2383        assert_eq!(r.keep_star, expected);
2384        assert!(r.keep_grid.contains(&r.keep_star));
2385    }
2386
2387    /// `k = 0` is `fit::boundary_tests::zero_count_is_invalid_argument_at_every_entry`.
2388    #[test]
2389    fn spls1_find_keep_optimal_rejects_k_above_n_features() {
2390        let (x, y) = synth(40, 5, 1, 4.0, 2);
2391        let e = spls1_find_keep_optimal(
2392            x.as_ref(),
2393            y.as_ref(),
2394            6,
2395            None,
2396            FindKeepOptimalOpts::default(),
2397        );
2398        assert!(matches!(e, Err(PlsKitError::KExceedsMax { .. })));
2399    }
2400
2401    /// A `find_k_optimal` result as flat values for the layout tests:
2402    /// `k_star`, `n_eff`, `stable_rank` (NaN when absent), each score map as
2403    /// its length then its values (`-1` when absent), then the p-values.
2404    #[allow(clippy::cast_precision_loss)]
2405    fn push_optimal(v: &mut Vec<f64>, r: &FindKOptimalOutput) {
2406        fn push_map(v: &mut Vec<f64>, m: Option<&BTreeMap<usize, f64>>) {
2407            match m {
2408                Some(m) => {
2409                    v.push(m.len() as f64);
2410                    v.extend(m.values());
2411                }
2412                None => v.push(-1.0),
2413            }
2414        }
2415        v.extend([r.k_star as f64, r.n_eff, r.stable_rank.unwrap_or(f64::NAN)]);
2416        push_map(v, r.cv_scores.as_ref());
2417        push_map(v, r.cv_scores_se.as_ref());
2418        push_map(v, r.bic_scores.as_ref());
2419        if let Some(p) = &r.pvalues {
2420            v.extend((0..p.nrows()).map(|i| p[i]));
2421        }
2422    }
2423
2424    /// The selections fit X in its own layout (BIC selection on the full
2425    /// data, the selections by cross-validation in each fold), so padded,
2426    /// row-major and reversed-column views agree with the owned matrix to
2427    /// the corpus tolerance, with the same `k_star` / `keep_star` and the
2428    /// same number of scores and p-values, over the copy-free families with
2429    /// and without `pre_standardized`: BIC and `r2_se` selection, each with
2430    /// and without a diagnostic, on `pls1_find_k_optimal` and
2431    /// `spls1_find_k_optimal`, and `spls1_find_keep_optimal`. The diagnostic
2432    /// sequence after selection keeps its bits on every layout
2433    /// (`sequential::incremental_sequence_is_layout_invariant`).
2434    #[test]
2435    #[allow(clippy::cast_precision_loss)]
2436    #[allow(clippy::many_single_char_names)]
2437    fn selection_agrees_across_layouts() {
2438        use crate::test_support::{Agree, assert_layout_agree, copy_free_families};
2439        for f in copy_free_families() {
2440            let w = f.w.as_ref().map(Col::as_ref);
2441            for pre in [false, true] {
2442                let (x, y) = f.inputs(pre);
2443                for (selector, diagnostic) in [
2444                    (Selector::Bic, None),
2445                    (Selector::Bic, Some(ConfirmatoryMethod::SplitExact)),
2446                    (Selector::R2Se, None),
2447                    (Selector::R2Se, Some(ConfirmatoryMethod::SplitNb)),
2448                ] {
2449                    let opts = FindKOptimalOpts {
2450                        selector,
2451                        diagnostic,
2452                        n_perm: 49,
2453                        n_splits: 10,
2454                        pre_standardized: pre,
2455                        seed: Some(7),
2456                        ..Default::default()
2457                    };
2458                    let what = format!("{selector:?} {} pre={pre} {diagnostic:?}", f.name);
2459                    assert_layout_agree(&x, &what, Agree::Corpus(1e-10), |xv| {
2460                        let mut v = Vec::new();
2461                        push_optimal(&mut v, &pls1_find_k_optimal(xv, y.as_ref(), 3, w, opts)?);
2462                        push_optimal(
2463                            &mut v,
2464                            &spls1_find_k_optimal(xv, y.as_ref(), 3, 3, w, opts)?,
2465                        );
2466                        Ok(v)
2467                    })
2468                    .unwrap();
2469                }
2470                let what = format!("keep_optimal {} pre={pre}", f.name);
2471                assert_layout_agree(&x, &what, Agree::Corpus(1e-10), |xv| {
2472                    let r = spls1_find_keep_optimal(
2473                        xv,
2474                        y.as_ref(),
2475                        2,
2476                        w,
2477                        FindKeepOptimalOpts {
2478                            seed: Some(7),
2479                            ..Default::default()
2480                        },
2481                    )?;
2482                    let mut v = vec![r.keep_star as f64, r.n_eff];
2483                    v.extend(r.cv_scores.values());
2484                    v.extend(r.cv_scores_se.values());
2485                    Ok(v)
2486                })
2487                .unwrap();
2488            }
2489        }
2490    }
2491
2492    /// The `r2_se` selection gives the same bits with its folds one at a
2493    /// time (budget 0) and all in one batch (no limit), unweighted and
2494    /// weighted.
2495    #[test]
2496    fn cv_selection_does_not_depend_on_the_budget() {
2497        let (x, y) = crate::test_support::signal_data(48, 7, 101);
2498        let w = crate::test_support::test_weights(48);
2499        for weights in [None, Some(w.as_ref())] {
2500            let run = |b| {
2501                let r = crate::fit::with_budget(b, || {
2502                    find_k_optimal_impl(
2503                        x.as_ref(),
2504                        y.as_ref(),
2505                        4,
2506                        None,
2507                        weights,
2508                        FindKOptimalOpts {
2509                            selector: Selector::R2Se,
2510                            seed: Some(7),
2511                            ..Default::default()
2512                        },
2513                    )
2514                    .unwrap()
2515                });
2516                format!("{r:?}")
2517            };
2518            assert_eq!(run(0), run(u64::MAX), "weighted={}", weights.is_some());
2519        }
2520    }
2521
2522    /// At budget 0 the folds of `cv_select` run one at a time, even on a
2523    /// pool with several threads; with no limit they overlap.
2524    #[test]
2525    fn cv_select_runs_one_fold_at_a_time_at_budget_zero() {
2526        use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
2527        let (x, y) = crate::test_support::signal_data(48, 7, 101);
2528        let pool = rayon::ThreadPoolBuilder::new()
2529            .num_threads(4)
2530            .build()
2531            .unwrap();
2532        let peak_at = |budget| {
2533            let (live, peak) = (AtomicUsize::new(0), AtomicUsize::new(0));
2534            let (_, mut rng) = crate::rng::resolve_seed(Some(7)).unwrap();
2535            pool.install(|| {
2536                crate::fit::with_budget(budget, || {
2537                    cv_select(
2538                        x.as_ref(),
2539                        y.as_ref(),
2540                        None,
2541                        None,
2542                        5,
2543                        &[1],
2544                        1,
2545                        false,
2546                        &mut rng,
2547                        |_, cells| {
2548                            peak.fetch_max(live.fetch_add(1, SeqCst) + 1, SeqCst);
2549                            // Hold the fold open so that folds run together overlap.
2550                            std::thread::sleep(std::time::Duration::from_millis(5));
2551                            live.fetch_sub(1, SeqCst);
2552                            Ok(vec![Some(0.0); cells.len()])
2553                        },
2554                    )
2555                })
2556            })
2557            .unwrap();
2558            peak.load(SeqCst)
2559        };
2560        assert_eq!(peak_at(0), 1);
2561        assert!(peak_at(u64::MAX) >= 2);
2562    }
2563
2564    /// A fold of `cv_select` counts one fit to `fit_k` components, its
2565    /// `p × (fit_k + 2)` buffers and its block of validation rows
2566    /// ([`cv_fold_bytes`]): a budget of two such units runs two of the four
2567    /// folds at once. The second pass, which refits the folds left to the
2568    /// blocks, adds the fold's standardized blocks and, weighted, the
2569    /// √w-scaled copy of the training block to the unit.
2570    #[test]
2571    #[allow(clippy::many_single_char_names)]
2572    fn cv_select_counts_one_fold_fit() {
2573        let (n, p) = (16_usize, 7_usize);
2574        let (x, y) = crate::test_support::signal_data(n, p, 101);
2575        let w = crate::test_support::test_weights(n);
2576        let unit = cv_fold_bytes(p, 1, n / 4);
2577        let folds = crate::linalg::fold_split(&(0..n).collect::<Vec<_>>(), 4);
2578        let blocks = crate::resample::fold_blocks_bytes(n, &folds, p);
2579        for weights in [None, Some(w.as_ref())] {
2580            let fit_copy = if weights.is_some() {
2581                crate::resample::copy_bytes(n - n / 4, p)
2582            } else {
2583                0
2584            };
2585            let unit2 = unit + blocks + fit_copy;
2586            let (_, mut rng) = crate::rng::resolve_seed(Some(7)).unwrap();
2587            let (_, log) = crate::resample::with_batch_log(|| {
2588                crate::fit::with_budget(u64::try_from(2 * unit2).unwrap(), || {
2589                    cv_select(
2590                        x.as_ref(),
2591                        y.as_ref(),
2592                        weights,
2593                        None,
2594                        4,
2595                        &[1],
2596                        1,
2597                        false,
2598                        &mut rng,
2599                        |fd, cells| Ok(vec![fd.on_blocks.then_some(0.0); cells.len()]),
2600                    )
2601                    .unwrap()
2602                })
2603            });
2604            let first = (2 * unit2 / unit).min(4);
2605            crate::resample::assert_batches(&log, 4, first, 1);
2606            crate::resample::assert_batches(&log, 4, 2, 1);
2607        }
2608    }
2609
2610    /// The BIC sweep of `select_bic` (a fit to `k_max` on raw X) agrees, to
2611    /// the corpus array tolerance, with the BIC of the predictions
2612    /// `xs·coef_k` of a fit to `k_max` on the standardized copy `xs`,
2613    /// weighted by `w`, and picks the same `k`. Dense,
2614    /// weighted (zero weights included), wide and weighted wide, and a rank-two
2615    /// X on which the fit stops before `k_max`; dense and `keep = 3`.
2616    #[test]
2617    fn bic_sweep_matches_the_prediction_sweep() {
2618        use crate::test_support::{Agree, Family, assert_agree, copy_free_families, signal_data};
2619        // Every column a combination of the same two: rank two.
2620        let (b, yb) = signal_data(40, 2, 103);
2621        let xb = Mat::<f64>::from_fn(40, 6, |i, j| {
2622            (j + 1) as f64 * b[(i, j % 2)] - b[(i, 1 - j % 2)]
2623        });
2624        let mut families = copy_free_families();
2625        families.push(Family {
2626            name: "rank_two",
2627            x: xb,
2628            y: yb,
2629            w: None,
2630        });
2631        for f in families {
2632            let n = f.x.nrows();
2633            let (w_norm, n_eff) =
2634                crate::fit::validate_and_normalize_weights(f.w.as_ref().map(Col::as_ref), n, 4)
2635                    .unwrap();
2636            let wr = w_norm.as_ref().map(Col::as_ref);
2637            for keep in [None, Some(3)] {
2638                let what = format!("{} keep={keep:?}", f.name);
2639                let (k_star, scores) =
2640                    select_bic(f.x.as_ref(), f.y.as_ref(), 4, wr, n_eff, keep).unwrap();
2641                let (xs, _, _) = standardize_weighted(f.x.as_ref(), wr);
2642                let (ys, _, _) = standardize1_weighted(f.y.as_ref(), wr);
2643                let m = pls1_fit_impl(
2644                    xs.as_ref(),
2645                    ys.as_ref(),
2646                    KSpec::Fixed(4),
2647                    wr,
2648                    FitOpts {
2649                        pre_standardized: true,
2650                        check_n_eff: false,
2651                        keep,
2652                        ..FitOpts::default()
2653                    },
2654                )
2655                .unwrap();
2656                if f.name == "rank_two" {
2657                    assert!(m.k_used > 0 && m.k_used < 4, "{what}: k_used {}", m.k_used);
2658                }
2659                assert!(scores.keys().copied().eq(1..=m.k_used), "{what}: keys");
2660                let want: Vec<f64> = (1..=m.k_used)
2661                    .map(|k| {
2662                        let coef = crate::fit::pls1_coef_at_k(
2663                            &m.w_star,
2664                            &m.p_loadings,
2665                            &m.q_loadings,
2666                            k,
2667                            faer::Par::Seq,
2668                        );
2669                        let pred = crate::linalg::mat_vec(
2670                            xs.as_ref(),
2671                            coef.as_ref(),
2672                            crate::fit::par_fixed(),
2673                        );
2674                        let ssr: f64 = (0..n)
2675                            .map(|i| wr.map_or(1.0, |w| w[i]) * (pred[i] - ys[i]).powi(2))
2676                            .sum();
2677                        n_eff * (ssr / n_eff).ln() + k as f64 * n_eff.ln()
2678                    })
2679                    .collect();
2680                let got: Vec<f64> = scores.values().copied().collect();
2681                assert_agree(&got, &want, Agree::Corpus(1e-10), &what);
2682                let argmin = want
2683                    .iter()
2684                    .enumerate()
2685                    .min_by(|a, b| a.1.total_cmp(b.1))
2686                    .map_or(0, |(i, _)| i + 1);
2687                assert_eq!(k_star, argmin, "{what}");
2688            }
2689        }
2690    }
2691
2692    /// A finite X whose standardization overflows (rows 0 and 1 of column 0
2693    /// at `f64::MAX`, row 2 at `-f64::MAX`) fails every selection by
2694    /// cross-validation with `NonFiniteInput`, on 40 × 6 and 40 × 200: a
2695    /// fold whose fit on X in place gives a non-finite coefficient or
2696    /// prediction refits on its standardized blocks, which hold the
2697    /// overflow.
2698    #[test]
2699    fn cv_selection_rejects_x_that_overflows_in_standardization() {
2700        for p in [6_usize, 200] {
2701            let (mut x, y) = crate::test_support::signal_data(40, p, 105);
2702            x[(0, 0)] = f64::MAX;
2703            x[(1, 0)] = f64::MAX;
2704            x[(2, 0)] = -f64::MAX;
2705            let opts = FindKOptimalOpts {
2706                selector: Selector::R2Se,
2707                seed: Some(7),
2708                ..Default::default()
2709            };
2710            let r = pls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, None, opts);
2711            assert!(
2712                matches!(r, Err(PlsKitError::NonFiniteInput)),
2713                "pls1 40x{p}: {r:?}"
2714            );
2715            let r = spls1_find_k_optimal(x.as_ref(), y.as_ref(), 3, 3, None, opts);
2716            assert!(
2717                matches!(r, Err(PlsKitError::NonFiniteInput)),
2718                "spls1 40x{p}: {r:?}"
2719            );
2720            let r = spls1_find_keep_optimal(
2721                x.as_ref(),
2722                y.as_ref(),
2723                2,
2724                None,
2725                FindKeepOptimalOpts {
2726                    seed: Some(7),
2727                    ..Default::default()
2728                },
2729            );
2730            assert!(
2731                matches!(r, Err(PlsKitError::NonFiniteInput)),
2732                "keep 40x{p}: {r:?}"
2733            );
2734        }
2735    }
2736
2737    /// Each fold's R² row from `fit_predict` on X in place agrees, to the
2738    /// corpus tolerance, with the row from its standardized blocks
2739    /// ([`FoldData::fit_predict_on_copy`]): the `r2_se` sweep to `k = 3`,
2740    /// which must fit X in place, and every keep of the grid at `k = 2`,
2741    /// where a sparse fit may fall back, over five folds of the copy-free
2742    /// families and of a 60 × 2100 C-ordered X (a row-major view at least
2743    /// `ROW_BLOCK_MIN_COLS` wide, whose `Xs'·t` reads its rows in blocks).
2744    /// Prints the largest difference and the number of keep-grid fits that
2745    /// fell back.
2746    #[test]
2747    fn cv_fold_scores_match_the_copy() {
2748        use crate::test_support::{Agree, assert_agree, copy_free_families, signal_data};
2749        let mut largest = 0.0_f64;
2750        let mut fell_back = 0_usize;
2751        let mut check = |name: &str, x: MatRef<'_, f64>, y: &Col<f64>, w: Option<&Col<f64>>| {
2752            let (n, p) = (x.nrows(), x.ncols());
2753            let (w_norm, _) =
2754                crate::fit::validate_and_normalize_weights(w.map(Col::as_ref), n, 1).unwrap();
2755            let wr = w_norm.as_ref().map(Col::as_ref);
2756            let (_, mut rng) = crate::rng::resolve_seed(Some(7)).unwrap();
2757            let mut idx: Vec<usize> = (0..n).collect();
2758            idx.shuffle(&mut rng);
2759            let folds = crate::linalg::fold_split(&idx, 5);
2760            let mut sweeps: Vec<(usize, Option<usize>)> = vec![(3, None)];
2761            sweeps.extend(keep_grid(p).into_iter().map(|kp| (2, Some(kp))));
2762            for fi in 0..folds.len() {
2763                for &(k, keep) in &sweeps {
2764                    let what = format!("{name} fold {fi} k={k} keep={keep:?}");
2765                    let mut fd = prep_fold(x, y.as_ref(), wr, &folds, fi, &folds[fi]).unwrap();
2766                    let Some(got) = fd.fit_predict(x, k, keep, sweep_coefs).unwrap() else {
2767                        assert!(
2768                            keep.is_some(),
2769                            "{what}: the dense sweep fell back to the blocks"
2770                        );
2771                        fell_back += 1;
2772                        continue;
2773                    };
2774                    let want = fd.fit_predict_on_copy(x, k, keep, sweep_coefs).unwrap();
2775                    let ss_tot = fd.ss_tot();
2776                    let g: Vec<f64> = got.iter().map(|p| fd.r2(p, ss_tot)).collect();
2777                    let v: Vec<f64> = want.iter().map(|p| fd.r2(p, ss_tot)).collect();
2778                    assert_agree(&g, &v, Agree::Corpus(1e-10), &what);
2779                    largest = g
2780                        .iter()
2781                        .zip(&v)
2782                        .fold(largest, |m, (a, b)| m.max((a - b).abs()));
2783                }
2784            }
2785        };
2786        for f in copy_free_families() {
2787            check(f.name, f.x.as_ref(), &f.y, f.w.as_ref());
2788        }
2789        let (x, y) = signal_data(60, 2100, 104);
2790        let x_t = x.transpose().to_owned();
2791        check("wide_row_major", x_t.transpose(), &y, None);
2792        eprintln!(
2793            "cv_fold_scores_match_the_copy: largest |difference| {largest:e}, \
2794             keep-grid fits on the blocks {fell_back}"
2795        );
2796    }
2797
2798    /// A fold leaves its fit to the standardized blocks, and on them
2799    /// ([`FoldData::on_blocks`]) fits, where the fit on X in
2800    /// place cannot run or cannot decide: columns past
2801    /// `IMPLICIT_MAX_MEAN_RATIO` (`offset`: the weighted dense family plus
2802    /// 1e6), and a fit that stops on the weight floor before `k` (`rank_two`:
2803    /// every column a combination of the same two, `k = 4`).
2804    #[test]
2805    #[allow(clippy::cast_precision_loss)]
2806    fn cv_fold_falls_back_to_the_blocks() {
2807        use crate::test_support::{copy_free_families, signal_data};
2808        let fams = copy_free_families();
2809        let offset = Mat::<f64>::from_fn(fams[1].x.nrows(), fams[1].x.ncols(), |i, j| {
2810            fams[1].x[(i, j)] + 1e6
2811        });
2812        let (b, yb) = signal_data(40, 2, 103);
2813        let rank_two = Mat::<f64>::from_fn(40, 6, |i, j| {
2814            (j + 1) as f64 * b[(i, j % 2)] - b[(i, 1 - j % 2)]
2815        });
2816        for (name, x, y, w, k) in [
2817            (
2818                "offset",
2819                offset.as_ref(),
2820                &fams[1].y,
2821                fams[1].w.as_ref(),
2822                3_usize,
2823            ),
2824            ("rank_two", rank_two.as_ref(), &yb, None, 4),
2825        ] {
2826            let n = x.nrows();
2827            let (w_norm, _) =
2828                crate::fit::validate_and_normalize_weights(w.map(Col::as_ref), n, 1).unwrap();
2829            let wr = w_norm.as_ref().map(Col::as_ref);
2830            let folds = crate::linalg::fold_split(&(0..n).collect::<Vec<_>>(), 5);
2831            let mut fd = prep_fold(x, y.as_ref(), wr, &folds, 0, &folds[0]).unwrap();
2832            if name == "offset" {
2833                assert!(
2834                    fd.mean_ratio > IMPLICIT_MAX_MEAN_RATIO,
2835                    "offset: ratio {}",
2836                    fd.mean_ratio
2837                );
2838            }
2839            assert!(
2840                fd.fit_predict(x, k, None, sweep_coefs).unwrap().is_none(),
2841                "{name}: did not fall back"
2842            );
2843            fd.on_blocks = true;
2844            let got = fd.fit_predict(x, k, None, sweep_coefs).unwrap().unwrap();
2845            if name == "rank_two" {
2846                assert!(got.len() < k, "{name}: k_used {}", got.len());
2847            }
2848        }
2849    }
2850}