Skip to main content

fdars_core/
coclustering.rs

1//! Functional co-clustering via the funLBM latent block model.
2//!
3//! This module implements a functional latent block model (funLBM) using a
4//! Classification EM (CEM) algorithm. It simultaneously clusters:
5//! - **Row clusters**: partitions the n curves into K row-clusters.
6//! - **Column clusters**: partitions the m argument points into L column-clusters.
7//!
8//! ## Column-cluster semantics (RESOLVED)
9//!
10//! `col_labels` has length **m** — the m argument/evaluation points are partitioned
11//! into L column-clusters (need not be contiguous). This is true funLBM. The column
12//! clusters do NOT range over the FPC components.
13//!
14//! ## Global FPCA reuse with block-score projection
15//!
16//! ONE global FPCA is computed via [`fdata_to_pc_1d`]. For a curve i in column-block l,
17//! the block score is the projection of Y_i **restricted to column-block l's argument points**
18//! onto the global FPC loadings restricted to those same points:
19//!
20//! ```text
21//! block_score[i][l][k] = Σ_{j: col_labels[j]==l}  weights[j] * (data[(i,j)] - mean[j]) * rotation[(j,k)]
22//! ```
23//!
24//! This restricts the standard weighted FPC inner product to a column-block's argument-point
25//! subset, keeping columns = argument points while reusing a single global FPCA.
26//!
27//! ## Divergences from R funLBM 2.3.1
28//!
29//! | Aspect                | fdars (this module)           | R funLBM 2.3.1          |
30//! |-----------------------|-------------------------------|-------------------------|
31//! | FPCA scope            | One global FPCA               | Per-block FPCA          |
32//! | EM variant            | Deterministic CEM (hard)      | SEM-Gibbs (stochastic)  |
33//! | Block covariance      | Diagonal (ncomp variances)    | Full covariance matrix  |
34//! | Column semantics      | m argument points             | m argument points (same)|
35//!
36//! ## References
37//!
38//! - Bouveyron et al. (2018), "Co-clustering of Multivariate Functional Data", JASA.
39//! - Govaert & Nadif (2008), "Block clustering with Bernoulli mixture models", CIS.
40
41use std::f64::consts::PI;
42
43use rand::prelude::*;
44
45use crate::error::FdarError;
46use crate::iter_maybe_parallel;
47use crate::matrix::FdMatrix;
48use crate::regression::fdata_to_pc_1d;
49#[cfg(feature = "parallel")]
50use rayon::iter::ParallelIterator;
51
52/// Below this init count, the multi-restart CEM loop dispatches sequentially to avoid rayon
53/// overhead exceeding the per-init compute. Payback threshold — mirrors the v0.17.0
54/// `SCORES_PARALLEL_THRESHOLD` precedent; refined by `perf_parallelism` thread-scaling in Wave 3.
55pub(crate) const CO_CLUSTER_INIT_PARALLEL_THRESHOLD: usize = 3;
56
57/// Per-block Gaussian parameters (diagonal covariance in the FPC score space).
58///
59/// Each block (k, l) — row-cluster k, column-cluster l — is modelled by a
60/// diagonal multivariate Gaussian on the `ncomp`-dimensional block scores.
61///
62/// Indexed as `block_params[k * n_col_blocks + l]`.
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct BlockParams {
67    /// Per-component block mean (length `eff_ncomp`).
68    pub mean: Vec<f64>,
69    /// Per-component block variance, diagonal (length `eff_ncomp`).
70    pub variance: Vec<f64>,
71}
72
73/// Result of [`co_cluster`].
74///
75/// The block structure is indexed as `block_params[k * n_col_blocks + l]`
76/// where k ∈ 0..n_row_blocks and l ∈ 0..n_col_blocks.
77///
78/// ## ICL formula
79///
80/// The ICL (Integrated Completed Likelihood) uses the symmetric Govaert-Nadif penalty:
81/// ```text
82/// p_KL = (K-1) + (L-1) + 2 * K * L * eff_ncomp
83/// ICL   = log_likelihood - 0.5 * p_KL * (ln(n) + ln(m))
84/// ```
85/// Here `ln(n)` penalises the n-curve row dimension and `ln(m)` penalises the
86/// m-argument-point column dimension — reflecting that column-clusters partition
87/// the m argument points (not the FPC components).
88#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub struct CoClusterResult {
92    /// Hard row-cluster assignments, length n.
93    /// Values in `0..n_row_blocks`.
94    pub row_labels: Vec<usize>,
95
96    /// Hard column-cluster assignments, length **m** (the number of argument points).
97    ///
98    /// Values in `0..n_col_blocks`. This always satisfies `col_labels.len() == m` —
99    /// columns cluster the argument points, NOT the FPC components.
100    pub col_labels: Vec<usize>,
101
102    /// Number of row clusters K.
103    pub n_row_blocks: usize,
104
105    /// Number of column clusters L.
106    pub n_col_blocks: usize,
107
108    /// Per-block Gaussian parameters, length K*L, indexed `k*L + l`.
109    /// Each element describes the diagonal Gaussian on the `eff_ncomp`-dimensional
110    /// block scores for the (k, l) block.
111    pub block_params: Vec<BlockParams>,
112
113    /// Mixing proportions for row clusters, length K. Sums to 1.
114    pub row_props: Vec<f64>,
115
116    /// Mixing proportions for column clusters, length L. Sums to 1.
117    pub col_props: Vec<f64>,
118
119    /// Converged classification log-likelihood (non-decreasing across CEM iterations).
120    pub log_likelihood: f64,
121
122    /// ICL model-selection criterion (finite; lower = better model).
123    ///
124    /// Formula: `ICL = log_likelihood - 0.5 * p_KL * (ln(n) + ln(m))`
125    /// where `p_KL = (K-1) + (L-1) + 2*K*L*eff_ncomp`.
126    pub icl: f64,
127
128    /// Number of CEM iterations performed.
129    pub iterations: usize,
130
131    /// Whether the algorithm converged before `max_iter`.
132    pub converged: bool,
133}
134
135/// Configuration for funLBM functional co-clustering.
136///
137/// Builder-style config mirroring [`GmmClusterConfig`](crate::gmm::cluster::GmmClusterConfig).
138/// Modify fields directly after calling [`CoClusterConfig::default()`].
139///
140/// # Example
141/// ```no_run
142/// use fdars_core::coclustering::CoClusterConfig;
143///
144/// let mut cfg = CoClusterConfig::default();
145/// cfg.n_row_blocks = 3;
146/// cfg.n_col_blocks = 4;
147/// cfg.ncomp = 3;
148/// cfg.n_init = 5;
149/// ```
150#[derive(Debug, Clone, PartialEq)]
151#[non_exhaustive]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153pub struct CoClusterConfig {
154    /// Number of row clusters K (default: 2).
155    pub n_row_blocks: usize,
156    /// Number of column clusters L (default: 2).
157    pub n_col_blocks: usize,
158    /// Number of FPC components for the block-score projection (default: 5).
159    /// The effective ncomp may be reduced to `min(ncomp, n, m)` by the FPCA.
160    pub ncomp: usize,
161    /// Maximum CEM iterations per initialization (default: 200).
162    pub max_iter: usize,
163    /// Convergence tolerance on the classification log-likelihood (default: 1e-6).
164    pub tol: f64,
165    /// Number of random initializations; the best by log-likelihood is returned (default: 3).
166    pub n_init: usize,
167    /// Base random seed for deterministic results (default: 42).
168    pub seed: u64,
169}
170
171impl Default for CoClusterConfig {
172    fn default() -> Self {
173        Self {
174            n_row_blocks: 2,
175            n_col_blocks: 2,
176            ncomp: 5,
177            max_iter: 200,
178            tol: 1e-6,
179            n_init: 3,
180            seed: 42,
181        }
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Internal helpers
187// ---------------------------------------------------------------------------
188
189/// Log-density of a scalar under a 1-D Gaussian N(x; mu, var).
190///
191/// Returns -∞ if `var <= 0`.
192#[inline]
193fn log_gaussian_1d(x: f64, mu: f64, var: f64) -> f64 {
194    if var <= 0.0 {
195        return f64::NEG_INFINITY;
196    }
197    -0.5 * ((x - mu).powi(2) / var + var.ln() + (2.0 * PI).ln())
198}
199
200/// Build the flat block-score buffer (n * L * eff_ncomp, indexed `(i*L + l)*e + k`).
201///
202/// For each curve i and column-cluster l:
203/// `bscore[i][l][k] = Σ_{j: col_labels[j]==l}  weights[j] * (data[(i,j)] - mean[j]) * rotation[(j,k)]`
204fn build_block_scores(
205    data: &FdMatrix,
206    rotation: &FdMatrix,
207    mean: &[f64],
208    weights: &[f64],
209    col_labels: &[usize],
210    n: usize,
211    m: usize,
212    l_blocks: usize,
213    eff_ncomp: usize,
214) -> Vec<f64> {
215    let total = n * l_blocks * eff_ncomp;
216    let mut buf = vec![0.0_f64; total];
217
218    // Iterate over argument points j; for each j accumulate into the correct l-block.
219    for j in 0..m {
220        let l = col_labels[j];
221        let w = weights[j];
222        let mean_j = mean[j];
223        // rotation is m×eff_ncomp column-major: rotation[(j, k)] at j + k*m
224        for i in 0..n {
225            let val = data[(i, j)] - mean_j;
226            let base = (i * l_blocks + l) * eff_ncomp;
227            for k in 0..eff_ncomp {
228                // rotation[(j, k)] = rotation.data[j + k*m], but use column() for the k-th column
229                buf[base + k] += w * val * rotation[(j, k)];
230            }
231        }
232    }
233
234    buf
235}
236
237/// Compute data-scaled regularization floor over block scores (1-D analogue of data_scaled_reg).
238fn block_score_reg(block_scores: &[f64], n: usize, l_blocks: usize, eff_ncomp: usize) -> f64 {
239    const REG_REL: f64 = 1e-6;
240    if n == 0 || l_blocks == 0 || eff_ncomp == 0 {
241        return REG_REL;
242    }
243    let total_blocks = l_blocks * eff_ncomp;
244    let mut total_var = 0.0_f64;
245    let mut n_dims = 0u64;
246    for l in 0..l_blocks {
247        for comp in 0..eff_ncomp {
248            // Collect all n scores for this (l, comp)
249            let mut sum = 0.0_f64;
250            let mut ss = 0.0_f64;
251            for i in 0..n {
252                let v = block_scores[(i * l_blocks + l) * eff_ncomp + comp];
253                sum += v;
254                ss += v * v;
255            }
256            let mean = sum / n as f64;
257            let var = ss / n as f64 - mean * mean;
258            total_var += var;
259            n_dims += 1;
260        }
261    }
262    let _ = total_blocks; // suppress unused warning
263    let mean_var = if n_dims > 0 {
264        total_var / n_dims as f64
265    } else {
266        0.0
267    };
268    if mean_var > 0.0 {
269        REG_REL * mean_var
270    } else {
271        REG_REL
272    }
273}
274
275/// M-step: recompute row_props, col_props, and block_params from current labels.
276fn m_step(
277    block_scores: &[f64],
278    row_labels: &[usize],
279    col_labels: &[usize],
280    n: usize,
281    m: usize,
282    k_blocks: usize,
283    l_blocks: usize,
284    eff_ncomp: usize,
285    reg: f64,
286) -> (Vec<f64>, Vec<f64>, Vec<BlockParams>) {
287    // Row proportions
288    let mut row_counts = vec![0usize; k_blocks];
289    for &r in row_labels {
290        row_counts[r] += 1;
291    }
292    let row_props: Vec<f64> = row_counts.iter().map(|&c| c as f64 / n as f64).collect();
293
294    // Column proportions
295    let mut col_counts = vec![0usize; l_blocks];
296    for &c in col_labels {
297        col_counts[c] += 1;
298    }
299    let col_props: Vec<f64> = col_counts.iter().map(|&c| c as f64 / m as f64).collect();
300
301    // Per-block Gaussian parameters
302    let mut block_params = Vec::with_capacity(k_blocks * l_blocks);
303    for k in 0..k_blocks {
304        for l in 0..l_blocks {
305            let mut mean = vec![0.0_f64; eff_ncomp];
306            let mut var = vec![0.0_f64; eff_ncomp];
307            let mut cnt = 0u64;
308
309            for i in 0..n {
310                if row_labels[i] != k {
311                    continue;
312                }
313                cnt += 1;
314                let base = (i * l_blocks + l) * eff_ncomp;
315                for comp in 0..eff_ncomp {
316                    mean[comp] += block_scores[base + comp];
317                }
318            }
319
320            if cnt > 0 {
321                let nf = cnt as f64;
322                for comp in 0..eff_ncomp {
323                    mean[comp] /= nf;
324                }
325                // Second pass for variance
326                for i in 0..n {
327                    if row_labels[i] != k {
328                        continue;
329                    }
330                    let base = (i * l_blocks + l) * eff_ncomp;
331                    for comp in 0..eff_ncomp {
332                        let d = block_scores[base + comp] - mean[comp];
333                        var[comp] += d * d;
334                    }
335                }
336                for comp in 0..eff_ncomp {
337                    var[comp] = var[comp] / nf + reg;
338                }
339            } else {
340                // Empty block: use flat variance = reg to avoid NaN
341                for comp in 0..eff_ncomp {
342                    var[comp] = reg;
343                }
344            }
345
346            block_params.push(BlockParams {
347                mean,
348                variance: var,
349            });
350        }
351    }
352
353    (row_props, col_props, block_params)
354}
355
356/// Compute classification log-likelihood from current hard labels + parameters.
357fn classification_log_likelihood(
358    block_scores: &[f64],
359    row_labels: &[usize],
360    _col_labels: &[usize],
361    row_props: &[f64],
362    col_props: &[f64],
363    block_params: &[BlockParams],
364    n: usize,
365    _m: usize,
366    _k_blocks: usize,
367    l_blocks: usize,
368    eff_ncomp: usize,
369) -> f64 {
370    let mut ll = 0.0_f64;
371
372    for i in 0..n {
373        let k = row_labels[i];
374        let rp = row_props[k];
375        if rp < 1e-15 {
376            continue;
377        }
378        ll += rp.ln();
379
380        // Sum log-density over all l blocks (the block score for l already encodes col assignment)
381        for l in 0..l_blocks {
382            let cp = col_props[l];
383            if cp < 1e-15 {
384                continue;
385            }
386            let bp = &block_params[k * l_blocks + l];
387            let base = (i * l_blocks + l) * eff_ncomp;
388            let mut block_ld = 0.0_f64;
389            for comp in 0..eff_ncomp {
390                block_ld +=
391                    log_gaussian_1d(block_scores[base + comp], bp.mean[comp], bp.variance[comp]);
392            }
393            ll += cp.ln() + block_ld;
394        }
395    }
396
397    ll
398}
399
400/// E-row: for each curve i, pick argmax_k classification log-density.
401fn e_row_step(
402    block_scores: &[f64],
403    row_props: &[f64],
404    col_props: &[f64],
405    block_params: &[BlockParams],
406    n: usize,
407    k_blocks: usize,
408    l_blocks: usize,
409    eff_ncomp: usize,
410) -> Vec<usize> {
411    let mut row_labels = vec![0usize; n];
412    for i in 0..n {
413        let mut best_k = 0usize;
414        let mut best_score = f64::NEG_INFINITY;
415        for k in 0..k_blocks {
416            let rp = row_props[k];
417            if rp < 1e-15 {
418                continue;
419            }
420            let mut score = rp.ln();
421            for l in 0..l_blocks {
422                let cp = col_props[l];
423                if cp < 1e-15 {
424                    continue;
425                }
426                let bp = &block_params[k * l_blocks + l];
427                let base = (i * l_blocks + l) * eff_ncomp;
428                let mut block_ld = 0.0_f64;
429                for comp in 0..eff_ncomp {
430                    block_ld += log_gaussian_1d(
431                        block_scores[base + comp],
432                        bp.mean[comp],
433                        bp.variance[comp],
434                    );
435                }
436                score += cp.ln() + block_ld;
437            }
438            if score > best_score {
439                best_score = score;
440                best_k = k;
441            }
442        }
443        row_labels[i] = best_k;
444    }
445    row_labels
446}
447
448/// E-col: for each argument point j, pick argmax_l of the classification log-density gain.
449///
450/// The gain of assigning argument point j to column-cluster l is:
451/// Σ_i [ log π_k(i) + Σ_{l'} (cp[l'] + block_ld(i,l')) ] where l's contribution changes.
452///
453/// We use a simpler but equivalent approach: for each j, try each candidate l, compute
454/// the change in total classification LL from reassigning j from current label to l.
455/// Since block scores depend on col_labels, we compute this by holding all other j fixed
456/// and computing the marginal LL contribution of adding point j to column-cluster l for
457/// each curve i. This is computed as:
458///
459/// For each l_candidate: Δ_j(l_candidate) = Σ_i Σ_k [I(row_labels[i]==k) *
460///     weights[j] * (data[(i,j)] - mean[j]) * Σ_comp rotation[(j,comp)] *
461///     (log N(b_score | mu_kl, var_kl))] — a per-j marginal computation.
462///
463/// In practice we compute it as the direct contribution to the classification LL
464/// of reassigning j → l_candidate (approximation: fix other j's col_labels unchanged).
465fn e_col_step(
466    data: &FdMatrix,
467    rotation: &FdMatrix,
468    mean: &[f64],
469    weights: &[f64],
470    col_labels: &[usize],
471    row_labels: &[usize],
472    row_props: &[f64],
473    col_props: &[f64],
474    block_params: &[BlockParams],
475    n: usize,
476    m: usize,
477    _k_blocks: usize,
478    l_blocks: usize,
479    eff_ncomp: usize,
480) -> Vec<usize> {
481    let mut new_col_labels = col_labels.to_vec();
482
483    // For each argument point j, compute the incremental block-score contribution
484    // from point j to each possible column-cluster l_cand, then pick argmax_l_cand
485    // of the sum (over curves i) of the resulting log-density gain.
486    for j in 0..m {
487        let w_j = weights[j];
488        let mean_j = mean[j];
489
490        // Precompute for each curve i and FPC component: the weighted centered value at j
491        // s[i][comp] = weights[j] * (data[(i,j)] - mean[j]) * rotation[(j, comp)]
492        let mut s = vec![0.0_f64; n * eff_ncomp];
493        for i in 0..n {
494            let val = w_j * (data[(i, j)] - mean_j);
495            for comp in 0..eff_ncomp {
496                s[i * eff_ncomp + comp] = val * rotation[(j, comp)];
497            }
498        }
499
500        let mut best_l = 0usize;
501        let mut best_gain = f64::NEG_INFINITY;
502
503        for l_cand in 0..l_blocks {
504            let cp = col_props[l_cand];
505            if cp < 1e-15 {
506                continue;
507            }
508            // Compute the gain from assigning j → l_cand.
509            // For each curve i: the block score for (i, l_cand) gains s[i][·].
510            // We compute the log-density gain vs. the current assignment.
511            let l_curr = col_labels[j];
512            let mut gain = 0.0_f64;
513
514            for i in 0..n {
515                let k = row_labels[i];
516                let rp = row_props[k];
517                if rp < 1e-15 {
518                    continue;
519                }
520
521                let bp_cand = &block_params[k * l_blocks + l_cand];
522
523                // Log-density for l_cand: use the marginal contribution of point j
524                // (s[i][comp] = weights[j]*(data[(i,j)]-mean[j])*rotation[(j,comp)]) as a
525                // proxy for the gain from assigning j to l_cand. Terms constant across l_cand
526                // choices cancel in the argmax.
527                let mut ld_cand_new = 0.0_f64;
528                for comp in 0..eff_ncomp {
529                    ld_cand_new += log_gaussian_1d(
530                        s[i * eff_ncomp + comp],
531                        bp_cand.mean[comp],
532                        bp_cand.variance[comp],
533                    );
534                }
535                gain += cp.ln() + ld_cand_new;
536
537                // Subtract the current assignment's contribution for l_curr
538                if l_curr != l_cand {
539                    let bp_curr = &block_params[k * l_blocks + l_curr];
540                    let mut ld_curr = 0.0_f64;
541                    for comp in 0..eff_ncomp {
542                        ld_curr += log_gaussian_1d(
543                            s[i * eff_ncomp + comp],
544                            bp_curr.mean[comp],
545                            bp_curr.variance[comp],
546                        );
547                    }
548                    let cp_curr = col_props[l_curr];
549                    if cp_curr >= 1e-15 {
550                        gain -= cp_curr.ln() + ld_curr;
551                    }
552                }
553            }
554
555            if gain > best_gain {
556                best_gain = gain;
557                best_l = l_cand;
558            }
559        }
560
561        new_col_labels[j] = best_l;
562    }
563
564    new_col_labels
565}
566
567/// Column k-means++ initialization on argument-point profiles (each point j has n-dim profile).
568fn col_kmeans_init(data: &FdMatrix, n: usize, m: usize, l_blocks: usize, seed: u64) -> Vec<usize> {
569    if l_blocks >= m {
570        // Each point gets its own cluster (degenerate case handled upstream)
571        return (0..m).map(|j| j % l_blocks).collect();
572    }
573
574    let mut rng = StdRng::seed_from_u64(seed);
575
576    // Profile of point j: data.column(j) — length n, column-major so contiguous.
577    // Compute squared L2 distance between two argument-point profiles.
578    let profile_l2sq = |j1: usize, j2: usize| -> f64 {
579        let c1 = data.column(j1);
580        let c2 = data.column(j2);
581        c1.iter().zip(c2.iter()).map(|(a, b)| (a - b).powi(2)).sum()
582    };
583
584    // k-means++ initialization
585    let first = rng.gen_range(0..m);
586    let mut centers: Vec<usize> = vec![first];
587
588    for _ in 1..l_blocks {
589        // Compute distance from each point to nearest center
590        let dists: Vec<f64> = (0..m)
591            .map(|j| {
592                centers
593                    .iter()
594                    .map(|&c| profile_l2sq(j, c))
595                    .fold(f64::INFINITY, f64::min)
596            })
597            .collect();
598        let total: f64 = dists.iter().sum();
599        if total < 1e-15 {
600            // All points are identical; assign round-robin
601            centers.push(centers.len() % m);
602            continue;
603        }
604        // Sample proportional to distance squared
605        let threshold = rng.gen::<f64>() * total;
606        let mut cum = 0.0;
607        let mut next = m - 1;
608        for (j, &d) in dists.iter().enumerate() {
609            cum += d;
610            if cum >= threshold {
611                next = j;
612                break;
613            }
614        }
615        centers.push(next);
616    }
617
618    // Assign each point to nearest center; run 10 assign-update iterations
619    let mut col_labels: Vec<usize> = (0..m)
620        .map(|j| {
621            centers
622                .iter()
623                .enumerate()
624                .map(|(ci, &c)| (ci, profile_l2sq(j, c)))
625                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
626                .map(|(ci, _)| ci)
627                .unwrap_or(0)
628        })
629        .collect();
630
631    for _ in 0..10 {
632        // Recompute centroids as mean of assigned profiles (in feature space R^n)
633        // We track centroid as column-major buffer of size n*l_blocks
634        let mut cent = vec![0.0_f64; n * l_blocks];
635        let mut cnt = vec![0u64; l_blocks];
636        for j in 0..m {
637            let l = col_labels[j];
638            cnt[l] += 1;
639            let col = data.column(j);
640            for i in 0..n {
641                cent[l * n + i] += col[i];
642            }
643        }
644        for l in 0..l_blocks {
645            if cnt[l] > 0 {
646                let c = cnt[l] as f64;
647                for i in 0..n {
648                    cent[l * n + i] /= c;
649                }
650            }
651        }
652
653        // Reassign
654        let mut changed = false;
655        for j in 0..m {
656            let col = data.column(j);
657            let mut best_l = 0usize;
658            let mut best_d = f64::INFINITY;
659            for l in 0..l_blocks {
660                let d: f64 = (0..n).map(|i| (col[i] - cent[l * n + i]).powi(2)).sum();
661                if d < best_d {
662                    best_d = d;
663                    best_l = l;
664                }
665            }
666            if col_labels[j] != best_l {
667                changed = true;
668                col_labels[j] = best_l;
669            }
670        }
671
672        if !changed {
673            break;
674        }
675    }
676
677    col_labels
678}
679
680/// Run a single CEM fit from given initial row/col labels. Returns (result, per_iter_ll).
681#[allow(clippy::too_many_arguments)]
682fn cem_single_fit(
683    data: &FdMatrix,
684    rotation: &FdMatrix,
685    mean: &[f64],
686    weights: &[f64],
687    init_row_labels: Vec<usize>,
688    init_col_labels: Vec<usize>,
689    n: usize,
690    m: usize,
691    k_blocks: usize,
692    l_blocks: usize,
693    eff_ncomp: usize,
694    max_iter: usize,
695    tol: f64,
696) -> (CoClusterResult, Vec<f64>) {
697    let mut row_labels = init_row_labels;
698    let mut col_labels = init_col_labels;
699
700    // Initial block scores
701    let mut block_scores = build_block_scores(
702        data,
703        rotation,
704        mean,
705        weights,
706        &col_labels,
707        n,
708        m,
709        l_blocks,
710        eff_ncomp,
711    );
712
713    let reg = block_score_reg(&block_scores, n, l_blocks, eff_ncomp);
714
715    // Initial M-step
716    let (mut row_props, mut col_props, mut block_params) = m_step(
717        &block_scores,
718        &row_labels,
719        &col_labels,
720        n,
721        m,
722        k_blocks,
723        l_blocks,
724        eff_ncomp,
725        reg,
726    );
727
728    let mut prev_ll = f64::NEG_INFINITY;
729    let mut per_iter_ll: Vec<f64> = Vec::with_capacity(max_iter);
730    let mut iterations = 0usize;
731    let mut converged = false;
732
733    for iter in 0..max_iter {
734        // E-row: reassign curves
735        row_labels = e_row_step(
736            &block_scores,
737            &row_props,
738            &col_props,
739            &block_params,
740            n,
741            k_blocks,
742            l_blocks,
743            eff_ncomp,
744        );
745
746        // E-col: reassign argument points (uses current block_scores and params)
747        col_labels = e_col_step(
748            data,
749            rotation,
750            mean,
751            weights,
752            &col_labels,
753            &row_labels,
754            &row_props,
755            &col_props,
756            &block_params,
757            n,
758            m,
759            k_blocks,
760            l_blocks,
761            eff_ncomp,
762        );
763
764        // Rebuild block scores after col reassignment
765        block_scores = build_block_scores(
766            data,
767            rotation,
768            mean,
769            weights,
770            &col_labels,
771            n,
772            m,
773            l_blocks,
774            eff_ncomp,
775        );
776
777        // M-step
778        let (rp, cp, bp) = m_step(
779            &block_scores,
780            &row_labels,
781            &col_labels,
782            n,
783            m,
784            k_blocks,
785            l_blocks,
786            eff_ncomp,
787            reg,
788        );
789        row_props = rp;
790        col_props = cp;
791        block_params = bp;
792
793        // Classification log-likelihood
794        let ll = classification_log_likelihood(
795            &block_scores,
796            &row_labels,
797            &col_labels,
798            &row_props,
799            &col_props,
800            &block_params,
801            n,
802            m,
803            k_blocks,
804            l_blocks,
805            eff_ncomp,
806        );
807
808        per_iter_ll.push(ll);
809        iterations = iter + 1;
810
811        // Convergence check (skip iter 0 to allow at least one update)
812        if iter > 0 && (ll - prev_ll).abs() < tol {
813            converged = true;
814            break;
815        }
816        prev_ll = ll;
817    }
818
819    let log_likelihood = per_iter_ll.last().copied().unwrap_or(f64::NEG_INFINITY);
820
821    // ICL: p_KL = (K-1) + (L-1) + 2*K*L*eff_ncomp
822    let p_kl = (k_blocks.saturating_sub(1))
823        + (l_blocks.saturating_sub(1))
824        + 2 * k_blocks * l_blocks * eff_ncomp;
825    let icl = log_likelihood - 0.5 * (p_kl as f64) * ((n as f64).ln() + (m as f64).ln());
826
827    let result = CoClusterResult {
828        row_labels,
829        col_labels,
830        n_row_blocks: k_blocks,
831        n_col_blocks: l_blocks,
832        block_params,
833        row_props,
834        col_props,
835        log_likelihood,
836        icl,
837        iterations,
838        converged,
839    };
840
841    (result, per_iter_ll)
842}
843
844// ---------------------------------------------------------------------------
845// Public entry point
846// ---------------------------------------------------------------------------
847
848/// Fit a funLBM functional co-clustering model via Classification EM (CEM).
849///
850/// Simultaneously partitions the n curves into K row-clusters and the m argument
851/// points into L column-clusters. Returns hard assignments and per-block Gaussian
852/// parameters.
853///
854/// # Arguments
855/// * `data` — Functional data matrix (n × m), column-major.
856/// * `argvals` — Evaluation/argument points, length m. Must be sorted ascending.
857/// * `config` — Tuning parameters (K, L, ncomp, restarts, seed, …).
858///
859/// # Errors
860/// - [`FdarError::InvalidParameter`] if `config.ncomp < 1`, `n_row_blocks > n`, or `n_col_blocks > m`.
861/// - [`FdarError::InvalidDimension`] if `data` or `argvals` dimensions are inconsistent
862///   (propagated from [`fdata_to_pc_1d`]).
863/// - [`FdarError::ComputationFailed`] if all initializations fail (propagated from FPCA).
864///
865/// # Example
866/// ```no_run
867/// use fdars_core::coclustering::{co_cluster, CoClusterConfig};
868/// use fdars_core::matrix::FdMatrix;
869///
870/// let data = FdMatrix::zeros(10, 8);
871/// let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
872/// let mut config = CoClusterConfig::default();
873/// config.n_row_blocks = 2;
874/// config.n_col_blocks = 2;
875/// config.ncomp = 3;
876/// let result = co_cluster(&data, &argvals, &config)?;
877/// assert_eq!(result.row_labels.len(), 10);
878/// assert_eq!(result.col_labels.len(), 8);
879/// # Ok::<(), fdars_core::error::FdarError>(())
880/// ```
881#[must_use = "expensive computation whose result should not be discarded"]
882pub fn co_cluster(
883    data: &FdMatrix,
884    argvals: &[f64],
885    config: &CoClusterConfig,
886) -> Result<CoClusterResult, FdarError> {
887    let (n, m) = data.shape();
888
889    // --- Input validation ---
890    if config.ncomp < 1 {
891        return Err(FdarError::InvalidParameter {
892            parameter: "ncomp",
893            message: format!("ncomp must be >= 1, got {}", config.ncomp),
894        });
895    }
896    if config.n_row_blocks > n {
897        return Err(FdarError::InvalidParameter {
898            parameter: "n_row_blocks",
899            message: format!(
900                "n_row_blocks={} exceeds number of observations n={}",
901                config.n_row_blocks, n
902            ),
903        });
904    }
905    if config.n_row_blocks == 0 {
906        return Err(FdarError::InvalidParameter {
907            parameter: "n_row_blocks",
908            message: "n_row_blocks must be >= 1".to_string(),
909        });
910    }
911    if config.n_col_blocks > m {
912        return Err(FdarError::InvalidParameter {
913            parameter: "n_col_blocks",
914            message: format!(
915                "n_col_blocks={} exceeds number of argument points m={}",
916                config.n_col_blocks, m
917            ),
918        });
919    }
920    if config.n_col_blocks == 0 {
921        return Err(FdarError::InvalidParameter {
922            parameter: "n_col_blocks",
923            message: "n_col_blocks must be >= 1".to_string(),
924        });
925    }
926
927    let k_blocks = config.n_row_blocks;
928    let l_blocks = config.n_col_blocks;
929
930    // --- Global FPCA ---
931    // fdata_to_pc_1d validates data/argvals dimensions and propagates its errors.
932    let fpca = fdata_to_pc_1d(data, config.ncomp, argvals)?;
933    // Read effective ncomp — may be < requested (clipped to min(n, m))
934    let eff_ncomp = fpca.scores.ncols();
935    let rotation = &fpca.rotation; // m × eff_ncomp
936    let mean = &fpca.mean; // len m
937    let weights = &fpca.weights; // len m
938
939    // --- Multi-restart CEM ---
940    let n_init = config.n_init.max(1);
941
942    // One initialization+fit, fully determined by `init` (per-init seeding is order-independent).
943    // Only `kmeans_fd` is fallible; its error is the one the old sequential `?` short-circuited on.
944    let run_init = |init: usize| -> Result<CoClusterResult, FdarError> {
945        let seed = config.seed.wrapping_add(init as u64 * 1000);
946
947        // Row initialization via kmeans_fd
948        use crate::clustering::kmeans_fd;
949        let km = kmeans_fd(data, argvals, k_blocks, 100, 1e-4, seed)?;
950        let init_row_labels = km.cluster;
951
952        // Column initialization via k-means++ on argument-point profiles
953        let init_col_labels = col_kmeans_init(data, n, m, l_blocks, seed.wrapping_add(1));
954
955        let (result, _per_iter_ll) = cem_single_fit(
956            data,
957            rotation,
958            mean,
959            weights,
960            init_row_labels,
961            init_col_labels,
962            n,
963            m,
964            k_blocks,
965            l_blocks,
966            eff_ncomp,
967            config.max_iter,
968            config.tol,
969        );
970        Ok(result)
971    };
972
973    // Build the per-init results in index order. Above the payback threshold the map runs in
974    // parallel (when the `parallel` feature is on). `collect::<Result<Vec,_>>()` short-circuits on
975    // an Err and preserves index order for the Ok values (rayon's IndexedParallelIterator). Error
976    // equivalence here does NOT rely on *which* Err wins a race: `run_init`'s only fallible call
977    // (`kmeans_fd`) fails solely on init-independent input validation, so every init would yield the
978    // identical error value anyway. Both branches thus produce the identical index-ordered Vec (or
979    // the identical Err); only the dispatch differs. (If an init-DEPENDENT fallible call is ever
980    // added inside `run_init`, revisit this: rayon does not guarantee the *lowest-index* Err wins.)
981    let results: Vec<CoClusterResult> = if n_init >= CO_CLUSTER_INIT_PARALLEL_THRESHOLD {
982        iter_maybe_parallel!(0..n_init)
983            .map(run_init)
984            .collect::<Result<Vec<_>, _>>()?
985    } else {
986        (0..n_init).map(run_init).collect::<Result<Vec<_>, _>>()?
987    };
988
989    // Reduce SEQUENTIALLY with strict `>` so the lowest init index wins ties — `reduce` keeps `acc`
990    // (the earlier element) unless a strictly-greater log_likelihood appears, exactly matching the
991    // old loop's "only replace when strictly greater" tie-break.
992    let best = results.into_iter().reduce(|acc, r| {
993        if r.log_likelihood > acc.log_likelihood {
994            r
995        } else {
996            acc
997        }
998    });
999
1000    best.ok_or_else(|| FdarError::ComputationFailed {
1001        operation: "co_cluster",
1002        detail: "all initializations failed".to_string(),
1003    })
1004}
1005
1006// ---------------------------------------------------------------------------
1007// Slope-heuristic model selection
1008// ---------------------------------------------------------------------------
1009
1010/// Result of [`co_cluster_select`]: the slope-heuristic-selected (K, L) fit
1011/// together with full grid diagnostics.
1012///
1013/// ## Grid diagnostics
1014///
1015/// `grid_scores` contains one entry per (K, L) pair in the sweep:
1016/// `(K, L, log_likelihood, model_dim, penalised_score)`.
1017///
1018/// `penalised_score = log_likelihood − penalty_rate × model_dim`.
1019/// In fallback branches (single cell, flat slope, small grid) `penalised_score = log_likelihood`.
1020///
1021/// ## Slope heuristic calibration
1022///
1023/// The Birgé–Massart penalty is estimated by OLS over the large-model (top-50% by dimension)
1024/// region of the fitted grid. This is a data-driven heuristic: it works best when the grid
1025/// spans a range of model dimensions and the data is well-separated enough for the
1026/// log-likelihood to grow linearly with dimension in the overparameterised region.
1027/// On poorly separated data the slope may be noisy and the selection may land at a boundary.
1028/// Inspect `grid_scores` to audit the selection.
1029///
1030/// ## Divergence from R funHDDC
1031///
1032/// The slope calibration here uses OLS over the top-50% by model dimension (the "linear region"
1033/// heuristic of Baudry, Maugis & Michel 2012). R's funHDDC uses a slightly different calibration
1034/// based on the full grid. The selected model may differ on small grids.
1035#[derive(Debug, Clone)]
1036#[non_exhaustive]
1037#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1038pub struct CoClusterSelectResult {
1039    /// The selected (K*, L*) co-clustering result.
1040    pub best: CoClusterResult,
1041    /// Selected number of row clusters K*.
1042    pub best_k: usize,
1043    /// Selected number of column clusters L*.
1044    pub best_l: usize,
1045    /// All grid fits: `(K, L, log_likelihood, model_dim, penalised_score)`.
1046    ///
1047    /// `penalised_score = log_likelihood − penalty_rate * model_dim`.
1048    /// In fallback branches (< 4 grid points, flat slope, etc.) `penalised_score = log_likelihood`.
1049    pub grid_scores: Vec<(usize, usize, f64, usize, f64)>,
1050    /// OLS slope estimated from the top-50% of fits by model dimension.
1051    /// Zero when the grid is too small or the slope heuristic fell back to max-LL.
1052    pub slope_estimate: f64,
1053    /// Penalty rate applied per model dimension: `2 × |slope_estimate|`.
1054    /// Zero in fallback branches.
1055    pub penalty_rate: f64,
1056}
1057
1058/// Fit funLBM over a (K, L) grid and select the best block count via the
1059/// Birgé–Massart slope heuristic.
1060///
1061/// For every combination of K in `k_range` and L in `l_range` the function
1062/// calls [`co_cluster`] (with `config` cloned and `n_row_blocks`/`n_col_blocks`
1063/// overridden), collects the (model_dimension, log_likelihood) pair, estimates
1064/// the slope of the LL-vs-dim curve in the large-model region by OLS, and
1065/// selects `argmax (LL − 2 × |slope| × dim)`.
1066///
1067/// ## Model dimension formula
1068///
1069/// `dim(K, L) = (K−1) + (L−1) + 2·K·L·eff_ncomp`
1070///
1071/// where `eff_ncomp` is the effective FPC count used by the fitted model
1072/// (read from `block_params[0].mean.len()`; may be less than `config.ncomp`
1073/// when clipped to `min(n, m)`).
1074///
1075/// ## Slope estimation
1076///
1077/// OLS over the top-50% of fits by model dimension (the region assumed to be
1078/// linear in the LL-vs-dim curve). Fallback to `argmax LL` when:
1079/// - the grid has fewer than 4 distinct-dimension points (or fewer than 4 total),
1080/// - the OLS denominator is near zero (all dims equal in the large-model subset),
1081/// - or the estimated penalty rate is ≤ 0 (flat/increasing LL with dimension).
1082///
1083/// In every fallback branch `slope_estimate = 0.0` and `penalty_rate = 0.0`;
1084/// `grid_scores` is always fully populated.
1085///
1086/// ## Determinism
1087///
1088/// Each grid fit uses the seed from `config.seed`. Fits that would fail
1089/// `co_cluster`'s own validation (e.g. K > n or L > m) propagate their
1090/// `FdarError` immediately.
1091///
1092/// # Arguments
1093/// * `data` — Functional data matrix (n × m), column-major.
1094/// * `argvals` — Evaluation points, length m. Must be sorted ascending.
1095/// * `k_range` — Candidate K values (number of row clusters). Must be non-empty.
1096/// * `l_range` — Candidate L values (number of column clusters). Must be non-empty.
1097/// * `config` — Base tuning parameters. `n_row_blocks` and `n_col_blocks` are
1098///   overridden per grid cell; all other fields are reused as-is.
1099///
1100/// # Errors
1101/// - [`FdarError::InvalidParameter`] if `k_range` or `l_range` is empty.
1102/// - Any error propagated from [`co_cluster`] for an invalid (K, L) combination.
1103///
1104/// # Example
1105/// ```no_run
1106/// use fdars_core::coclustering::{co_cluster_select, CoClusterConfig};
1107/// use fdars_core::matrix::FdMatrix;
1108///
1109/// let data = FdMatrix::zeros(20, 10);
1110/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1111/// let mut config = CoClusterConfig::default();
1112/// config.ncomp = 3;
1113/// config.n_init = 2;
1114/// let result = co_cluster_select(&data, &argvals, &[2, 3, 4], &[2, 3], &config)?;
1115/// println!("Selected K={}, L={}", result.best_k, result.best_l);
1116/// # Ok::<(), fdars_core::error::FdarError>(())
1117/// ```
1118#[must_use = "expensive grid sweep whose result should not be discarded"]
1119pub fn co_cluster_select(
1120    data: &FdMatrix,
1121    argvals: &[f64],
1122    k_range: &[usize],
1123    l_range: &[usize],
1124    config: &CoClusterConfig,
1125) -> Result<CoClusterSelectResult, FdarError> {
1126    // --- Validate inputs ---
1127    if k_range.is_empty() {
1128        return Err(FdarError::InvalidParameter {
1129            parameter: "k_range",
1130            message: "k_range must be non-empty".to_string(),
1131        });
1132    }
1133    if l_range.is_empty() {
1134        return Err(FdarError::InvalidParameter {
1135            parameter: "l_range",
1136            message: "l_range must be non-empty".to_string(),
1137        });
1138    }
1139
1140    // --- Build the (K, L) grid ---
1141    let grid: Vec<(usize, usize)> = k_range
1142        .iter()
1143        .flat_map(|&k| l_range.iter().map(move |&l| (k, l)))
1144        .collect();
1145
1146    // --- Sweep the grid sequentially (co_cluster is internally parallelised) ---
1147    // We use sequential iteration to keep grid results in deterministic order.
1148    // Each co_cluster call may itself use rayon via its internal helpers.
1149    let mut cell_results: Vec<(usize, usize, CoClusterResult)> = Vec::with_capacity(grid.len());
1150    for &(k, l) in &grid {
1151        let mut cell_cfg = config.clone();
1152        cell_cfg.n_row_blocks = k;
1153        cell_cfg.n_col_blocks = l;
1154        let result = co_cluster(data, argvals, &cell_cfg)?;
1155        cell_results.push((k, l, result));
1156    }
1157
1158    // --- Compute (dim, ll) for each cell ---
1159    // eff_ncomp = block_params[0].mean.len() (may be < config.ncomp when clipped)
1160    // model_dim = (K-1) + (L-1) + 2*K*L*eff_ncomp
1161    struct CellInfo {
1162        k: usize,
1163        l: usize,
1164        ll: f64,
1165        dim: usize,
1166        result_idx: usize,
1167    }
1168
1169    let infos: Vec<CellInfo> = cell_results
1170        .iter()
1171        .enumerate()
1172        .map(|(idx, (k, l, res))| {
1173            let eff_ncomp = if res.block_params.is_empty() {
1174                0
1175            } else {
1176                res.block_params[0].mean.len()
1177            };
1178            let dim = k.saturating_sub(1) + l.saturating_sub(1) + 2 * k * l * eff_ncomp;
1179            CellInfo {
1180                k: *k,
1181                l: *l,
1182                ll: res.log_likelihood,
1183                dim,
1184                result_idx: idx,
1185            }
1186        })
1187        .collect();
1188
1189    // --- Birgé–Massart slope estimation ---
1190    // Sort by dim descending to identify large-model region
1191    let n_grid = infos.len();
1192
1193    let (slope_estimate, penalty_rate) = if n_grid < 4 {
1194        // Too few points for reliable slope estimation; fall back to max-LL
1195        (0.0_f64, 0.0_f64)
1196    } else {
1197        // Take the top 50% (at least 4 points) by model dimension
1198        let mut sorted_by_dim: Vec<usize> = (0..n_grid).collect();
1199        sorted_by_dim.sort_by(|&a, &b| infos[b].dim.cmp(&infos[a].dim));
1200
1201        let n_top = (n_grid / 2).max(4).min(n_grid);
1202        let top_idxs = &sorted_by_dim[..n_top];
1203
1204        // OLS: slope = Σ(dim_i − d̄)(ll_i − l̄) / Σ(dim_i − d̄)²
1205        let d_mean: f64 = top_idxs.iter().map(|&i| infos[i].dim as f64).sum::<f64>() / n_top as f64;
1206        let l_mean: f64 = top_idxs.iter().map(|&i| infos[i].ll).sum::<f64>() / n_top as f64;
1207
1208        let numerator: f64 = top_idxs
1209            .iter()
1210            .map(|&i| (infos[i].dim as f64 - d_mean) * (infos[i].ll - l_mean))
1211            .sum();
1212        let denominator: f64 = top_idxs
1213            .iter()
1214            .map(|&i| (infos[i].dim as f64 - d_mean).powi(2))
1215            .sum();
1216
1217        if denominator.abs() < 1e-10 {
1218            // All dims equal in the large-model subset; fall back to max-LL
1219            (0.0_f64, 0.0_f64)
1220        } else {
1221            let slope = numerator / denominator;
1222            let pen = 2.0 * slope.abs();
1223            if pen <= 0.0 {
1224                (slope, 0.0_f64)
1225            } else {
1226                (slope, pen)
1227            }
1228        }
1229    };
1230
1231    // --- Compute penalised scores and select the best ---
1232    // penalty_rate == 0 means we fall back to argmax LL
1233    let penalised: Vec<f64> = infos
1234        .iter()
1235        .map(|ci| {
1236            if penalty_rate > 0.0 {
1237                ci.ll - penalty_rate * ci.dim as f64
1238            } else {
1239                ci.ll
1240            }
1241        })
1242        .collect();
1243
1244    // argmax of penalised scores
1245    let best_pos = penalised
1246        .iter()
1247        .enumerate()
1248        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
1249        .map(|(i, _)| i)
1250        .unwrap_or(0);
1251
1252    let best_k = infos[best_pos].k;
1253    let best_l = infos[best_pos].l;
1254    let best_result_idx = infos[best_pos].result_idx;
1255
1256    // --- Build grid_scores (fully populated) ---
1257    let grid_scores: Vec<(usize, usize, f64, usize, f64)> = infos
1258        .iter()
1259        .enumerate()
1260        .map(|(pos, ci)| (ci.k, ci.l, ci.ll, ci.dim, penalised[pos]))
1261        .collect();
1262
1263    let best = cell_results.remove(best_result_idx).2;
1264
1265    Ok(CoClusterSelectResult {
1266        best,
1267        best_k,
1268        best_l,
1269        grid_scores,
1270        slope_estimate,
1271        penalty_rate,
1272    })
1273}
1274
1275// ---------------------------------------------------------------------------
1276// Tests
1277// ---------------------------------------------------------------------------
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use crate::test_helpers::{adjusted_rand_index, uniform_grid};
1283
1284    /// Build a synthetic (K=2, L=2) block-structured dataset.
1285    ///
1286    /// Returns (data, argvals, true_row_labels, true_col_labels).
1287    /// - First n/2 curves have a large positive offset on the first m/2 argument points.
1288    /// - Second n/2 curves have a large negative offset there.
1289    /// - Small Normal noise is added everywhere.
1290    fn make_block_data(
1291        n: usize,
1292        m: usize,
1293        seed: u64,
1294    ) -> (FdMatrix, Vec<f64>, Vec<usize>, Vec<usize>) {
1295        use rand::prelude::*;
1296        use rand_distr::Normal;
1297
1298        let argvals = uniform_grid(m);
1299        let mut rng = StdRng::seed_from_u64(seed);
1300        let noise_dist = Normal::new(0.0_f64, 0.1).unwrap();
1301
1302        let m_half = m / 2;
1303
1304        let mut data = FdMatrix::zeros(n, m);
1305        let mut true_row_labels = vec![0usize; n];
1306        let mut true_col_labels = vec![0usize; m];
1307
1308        // Column labels: first half → 0, second half → 1
1309        for j in m_half..m {
1310            true_col_labels[j] = 1;
1311        }
1312
1313        // Row labels and curve values
1314        for i in 0..n {
1315            let row_group = if i < n / 2 { 0 } else { 1 };
1316            true_row_labels[i] = row_group;
1317
1318            let signal = if row_group == 0 { 5.0_f64 } else { -5.0_f64 };
1319
1320            for j in 0..m {
1321                let noise: f64 = rng.sample(noise_dist);
1322                // Large signal only on the first m/2 columns (col-cluster 0)
1323                let base = if j < m_half { signal } else { 0.0 };
1324                data[(i, j)] = base + noise;
1325            }
1326        }
1327
1328        (data, argvals, true_row_labels, true_col_labels)
1329    }
1330
1331    /// Internal helper: run a single CEM fit and return the per-iteration LL vector.
1332    fn run_single_cem_with_ll(
1333        data: &FdMatrix,
1334        argvals: &[f64],
1335        k: usize,
1336        l: usize,
1337        ncomp: usize,
1338        seed: u64,
1339    ) -> (CoClusterResult, Vec<f64>) {
1340        let (n, m) = data.shape();
1341        let fpca = fdata_to_pc_1d(data, ncomp, argvals).unwrap();
1342        let eff_ncomp = fpca.scores.ncols();
1343
1344        use crate::clustering::kmeans_fd;
1345        let km = kmeans_fd(data, argvals, k, 100, 1e-4, seed).unwrap();
1346        let init_row = km.cluster;
1347        let init_col = col_kmeans_init(data, n, m, l, seed.wrapping_add(1));
1348
1349        cem_single_fit(
1350            data,
1351            &fpca.rotation,
1352            &fpca.mean,
1353            &fpca.weights,
1354            init_row,
1355            init_col,
1356            n,
1357            m,
1358            k,
1359            l,
1360            eff_ncomp,
1361            200,
1362            1e-6,
1363        )
1364    }
1365
1366    // -----------------------------------------------------------------------
1367    // Task 1 smoke test
1368    // -----------------------------------------------------------------------
1369
1370    #[test]
1371    fn test_co_cluster_smoke() {
1372        let n = 8;
1373        let m = 6;
1374        let argvals = uniform_grid(m);
1375        let data = FdMatrix::zeros(n, m);
1376        let config = CoClusterConfig {
1377            n_row_blocks: 2,
1378            n_col_blocks: 2,
1379            ncomp: 3,
1380            n_init: 1,
1381            ..Default::default()
1382        };
1383        let result = co_cluster(&data, &argvals, &config).unwrap();
1384        assert_eq!(result.row_labels.len(), n);
1385        assert_eq!(result.col_labels.len(), m);
1386        assert_eq!(result.block_params.len(), 4);
1387        // log-likelihood should be finite (may be -inf only if all zeros; accept either)
1388        // In practice, zeros → all equal block means → finite LL from the Gaussian
1389        // (variance will be reg-floored)
1390        assert!(result.log_likelihood.is_finite() || result.log_likelihood == f64::NEG_INFINITY);
1391    }
1392
1393    // -----------------------------------------------------------------------
1394    // Task 2 correctness tests
1395    // -----------------------------------------------------------------------
1396
1397    #[test]
1398    fn test_classification_ll_nondecreasing() {
1399        let (data, argvals, _, _) = make_block_data(16, 10, 7777);
1400        let (_result, per_iter_ll) = run_single_cem_with_ll(&data, &argvals, 2, 2, 3, 42);
1401
1402        // Classification LL must be non-decreasing across iterations
1403        // (allow tiny floating-point slack of 1e-6)
1404        for w in per_iter_ll.windows(2) {
1405            assert!(
1406                w[1] >= w[0] - 1e-6,
1407                "LL decreased: iter[i]={:.6} -> iter[i+1]={:.6}",
1408                w[0],
1409                w[1]
1410            );
1411        }
1412    }
1413
1414    #[test]
1415    fn test_coclustering_recovers_block_structure() {
1416        let (data, argvals, true_row, true_col) = make_block_data(20, 12, 1234);
1417        let config = CoClusterConfig {
1418            n_row_blocks: 2,
1419            n_col_blocks: 2,
1420            ncomp: 3,
1421            n_init: 3,
1422            seed: 42,
1423            ..Default::default()
1424        };
1425        let result = co_cluster(&data, &argvals, &config).unwrap();
1426
1427        let ari_row = adjusted_rand_index(&true_row, &result.row_labels);
1428        let ari_col = adjusted_rand_index(&true_col, &result.col_labels);
1429
1430        assert!(
1431            ari_row > 0.8,
1432            "Row ARI too low: {ari_row:.3} (expected > 0.8)"
1433        );
1434        assert!(
1435            ari_col > 0.8,
1436            "Col ARI too low: {ari_col:.3} (expected > 0.8)"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_determinism_under_seed() {
1442        let (data, argvals, _, _) = make_block_data(16, 10, 999);
1443        let config = CoClusterConfig {
1444            n_row_blocks: 2,
1445            n_col_blocks: 2,
1446            ncomp: 3,
1447            n_init: 2,
1448            seed: 77,
1449            ..Default::default()
1450        };
1451
1452        let r1 = co_cluster(&data, &argvals, &config).unwrap();
1453        let r2 = co_cluster(&data, &argvals, &config).unwrap();
1454
1455        assert_eq!(
1456            r1.row_labels, r2.row_labels,
1457            "row_labels differ across runs"
1458        );
1459        assert_eq!(
1460            r1.col_labels, r2.col_labels,
1461            "col_labels differ across runs"
1462        );
1463        assert_eq!(
1464            r1.log_likelihood, r2.log_likelihood,
1465            "log_likelihood differs"
1466        );
1467        assert_eq!(r1.icl, r2.icl, "ICL differs");
1468    }
1469
1470    #[test]
1471    fn test_icl_is_finite() {
1472        let (data, argvals, _, _) = make_block_data(16, 10, 42);
1473        let config = CoClusterConfig {
1474            n_row_blocks: 2,
1475            n_col_blocks: 2,
1476            ncomp: 3,
1477            n_init: 1,
1478            ..Default::default()
1479        };
1480        let result = co_cluster(&data, &argvals, &config).unwrap();
1481        assert!(result.icl.is_finite(), "ICL is not finite: {}", result.icl);
1482    }
1483
1484    // -----------------------------------------------------------------------
1485    // Task 3 error-path tests
1486    // -----------------------------------------------------------------------
1487
1488    #[test]
1489    fn test_error_k_exceeds_n() {
1490        let n = 8;
1491        let m = 6;
1492        let data = FdMatrix::zeros(n, m);
1493        let argvals = uniform_grid(m);
1494        let config = CoClusterConfig {
1495            n_row_blocks: 99,
1496            n_col_blocks: 2,
1497            ncomp: 3,
1498            ..Default::default()
1499        };
1500        let err = co_cluster(&data, &argvals, &config).unwrap_err();
1501        assert!(
1502            matches!(
1503                err,
1504                FdarError::InvalidParameter {
1505                    parameter: "n_row_blocks",
1506                    ..
1507                }
1508            ),
1509            "Expected InvalidParameter(n_row_blocks), got: {err:?}"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_error_l_exceeds_m() {
1515        let n = 8;
1516        let m = 6;
1517        let data = FdMatrix::zeros(n, m);
1518        let argvals = uniform_grid(m);
1519        let config = CoClusterConfig {
1520            n_row_blocks: 2,
1521            n_col_blocks: 99,
1522            ncomp: 3,
1523            ..Default::default()
1524        };
1525        let err = co_cluster(&data, &argvals, &config).unwrap_err();
1526        assert!(
1527            matches!(
1528                err,
1529                FdarError::InvalidParameter {
1530                    parameter: "n_col_blocks",
1531                    ..
1532                }
1533            ),
1534            "Expected InvalidParameter(n_col_blocks), got: {err:?}"
1535        );
1536    }
1537
1538    #[test]
1539    fn test_error_zero_ncomp() {
1540        let n = 8;
1541        let m = 6;
1542        let data = FdMatrix::zeros(n, m);
1543        let argvals = uniform_grid(m);
1544        let config = CoClusterConfig {
1545            n_row_blocks: 2,
1546            n_col_blocks: 2,
1547            ncomp: 0,
1548            ..Default::default()
1549        };
1550        let err = co_cluster(&data, &argvals, &config).unwrap_err();
1551        assert!(
1552            matches!(
1553                err,
1554                FdarError::InvalidParameter {
1555                    parameter: "ncomp",
1556                    ..
1557                }
1558            ),
1559            "Expected InvalidParameter(ncomp), got: {err:?}"
1560        );
1561    }
1562
1563    #[test]
1564    fn test_error_argvals_mismatch() {
1565        let n = 8;
1566        let m = 6;
1567        let data = FdMatrix::zeros(n, m);
1568        let argvals = uniform_grid(m + 3); // wrong length
1569        let config = CoClusterConfig {
1570            n_row_blocks: 2,
1571            n_col_blocks: 2,
1572            ncomp: 3,
1573            ..Default::default()
1574        };
1575        let err = co_cluster(&data, &argvals, &config).unwrap_err();
1576        assert!(
1577            matches!(err, FdarError::InvalidDimension { .. }),
1578            "Expected InvalidDimension, got: {err:?}"
1579        );
1580    }
1581
1582    // -----------------------------------------------------------------------
1583    // Task 1 (tracer) + Task 2 (slope heuristic) tests
1584    // -----------------------------------------------------------------------
1585
1586    #[test]
1587    fn test_co_cluster_select_smoke() {
1588        // Small grid: k_range=[2,3], l_range=[2] → 2 grid cells
1589        let n = 8;
1590        let m = 6;
1591        let argvals = uniform_grid(m);
1592        let data = FdMatrix::zeros(n, m);
1593        let config = CoClusterConfig {
1594            ncomp: 2,
1595            n_init: 1,
1596            ..Default::default()
1597        };
1598        let result = co_cluster_select(&data, &argvals, &[2, 3], &[2], &config).unwrap();
1599        assert_eq!(
1600            result.grid_scores.len(),
1601            2,
1602            "Expected 2 grid cells (K in {{2,3}}, L=2)"
1603        );
1604        assert_eq!(
1605            result.best.row_labels.len(),
1606            n,
1607            "best.row_labels.len() should equal n"
1608        );
1609        assert_eq!(
1610            result.best.col_labels.len(),
1611            m,
1612            "best.col_labels.len() should equal m"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_slope_heuristic_selects_correct_kl() {
1618        // Use well-separated (K=2, L=2) block data; sweep [2,3,4] × [2,3].
1619        // The slope heuristic should select the true (K=2, L=2) or at least a
1620        // model with ARI > 0.8 on row assignments.
1621        let (data, argvals, true_row, _) = make_block_data(24, 12, 2024);
1622        let config = CoClusterConfig {
1623            ncomp: 3,
1624            n_init: 3,
1625            seed: 42,
1626            ..Default::default()
1627        };
1628        let result = co_cluster_select(&data, &argvals, &[2, 3, 4], &[2, 3], &config).unwrap();
1629
1630        // grid_scores should have 6 entries (3 K × 2 L)
1631        assert_eq!(result.grid_scores.len(), 6, "Expected 6 grid cells");
1632
1633        // All grid_scores entries should have finite (or NEG_INFINITY) log-likelihoods
1634        for &(k, l, ll, dim, pen) in &result.grid_scores {
1635            assert!(
1636                ll.is_finite() || ll == f64::NEG_INFINITY,
1637                "grid entry (K={k}, L={l}) has non-finite ll={ll}"
1638            );
1639            let _ = (dim, pen); // used
1640        }
1641
1642        // The best result should assign n curves
1643        assert_eq!(result.best.row_labels.len(), 24);
1644
1645        // ARI tolerance: best row assignment should have ARI > 0.6 with true labels
1646        // (relaxed because slope heuristic may pick K=3 on some runs, which is near-true)
1647        let ari = adjusted_rand_index(&true_row, &result.best.row_labels);
1648        assert!(
1649            ari > 0.6,
1650            "Row ARI too low: {ari:.3}. best_k={}, best_l={}",
1651            result.best_k,
1652            result.best_l
1653        );
1654    }
1655
1656    #[test]
1657    fn test_select_single_cell() {
1658        // Single-cell grid (k_range=[2], l_range=[2]) → 1 grid entry, no slope estimation
1659        let n = 10;
1660        let m = 8;
1661        let (data, argvals, _, _) = make_block_data(n, m, 42);
1662        let config = CoClusterConfig {
1663            ncomp: 2,
1664            n_init: 1,
1665            seed: 1,
1666            ..Default::default()
1667        };
1668        let result = co_cluster_select(&data, &argvals, &[2], &[2], &config).unwrap();
1669
1670        assert_eq!(
1671            result.grid_scores.len(),
1672            1,
1673            "Single-cell grid should have 1 entry"
1674        );
1675        assert_eq!(result.best_k, 2);
1676        assert_eq!(result.best_l, 2);
1677        // Slope fallback: < 4 points → slope_estimate = 0, penalty_rate = 0
1678        assert_eq!(
1679            result.slope_estimate, 0.0,
1680            "slope_estimate should be 0 for single-cell"
1681        );
1682        assert_eq!(
1683            result.penalty_rate, 0.0,
1684            "penalty_rate should be 0 for single-cell"
1685        );
1686    }
1687
1688    #[test]
1689    fn test_select_empty_range_errors() {
1690        let n = 8;
1691        let m = 6;
1692        let data = FdMatrix::zeros(n, m);
1693        let argvals = uniform_grid(m);
1694        let config = CoClusterConfig::default();
1695
1696        // Empty k_range
1697        let err = co_cluster_select(&data, &argvals, &[], &[2], &config).unwrap_err();
1698        assert!(
1699            matches!(
1700                err,
1701                FdarError::InvalidParameter {
1702                    parameter: "k_range",
1703                    ..
1704                }
1705            ),
1706            "Expected InvalidParameter(k_range), got: {err:?}"
1707        );
1708
1709        // Empty l_range
1710        let err = co_cluster_select(&data, &argvals, &[2], &[], &config).unwrap_err();
1711        assert!(
1712            matches!(
1713                err,
1714                FdarError::InvalidParameter {
1715                    parameter: "l_range",
1716                    ..
1717                }
1718            ),
1719            "Expected InvalidParameter(l_range), got: {err:?}"
1720        );
1721    }
1722
1723    #[test]
1724    fn test_select_determinism() {
1725        let (data, argvals, _, _) = make_block_data(16, 10, 12345);
1726        let config = CoClusterConfig {
1727            ncomp: 3,
1728            n_init: 2,
1729            seed: 99,
1730            ..Default::default()
1731        };
1732
1733        let r1 = co_cluster_select(&data, &argvals, &[2, 3], &[2, 3], &config).unwrap();
1734        let r2 = co_cluster_select(&data, &argvals, &[2, 3], &[2, 3], &config).unwrap();
1735
1736        assert_eq!(r1.best_k, r2.best_k, "best_k differs across runs");
1737        assert_eq!(r1.best_l, r2.best_l, "best_l differs across runs");
1738        assert_eq!(
1739            r1.grid_scores.len(),
1740            r2.grid_scores.len(),
1741            "grid_scores.len() differs"
1742        );
1743        for (a, b) in r1.grid_scores.iter().zip(r2.grid_scores.iter()) {
1744            assert_eq!(a.0, b.0, "K differs in grid_scores");
1745            assert_eq!(a.1, b.1, "L differs in grid_scores");
1746            assert_eq!(a.2, b.2, "log_lik differs in grid_scores");
1747            assert_eq!(a.3, b.3, "model_dim differs in grid_scores");
1748            assert_eq!(a.4, b.4, "penalised_score differs in grid_scores");
1749        }
1750    }
1751
1752    #[test]
1753    fn test_result_surface_populated() {
1754        let n = 10;
1755        let m = 8;
1756        let (data, argvals, _, _) = make_block_data(n, m, 555);
1757        let config = CoClusterConfig {
1758            n_row_blocks: 2,
1759            n_col_blocks: 2,
1760            ncomp: 3,
1761            n_init: 1,
1762            ..Default::default()
1763        };
1764        let result = co_cluster(&data, &argvals, &config).unwrap();
1765
1766        assert_eq!(result.row_labels.len(), n, "row_labels.len() != n");
1767        assert_eq!(result.col_labels.len(), m, "col_labels.len() != m");
1768        assert_eq!(
1769            result.block_params.len(),
1770            result.n_row_blocks * result.n_col_blocks,
1771            "block_params.len() != K*L"
1772        );
1773        assert_eq!(result.row_props.len(), result.n_row_blocks);
1774        assert_eq!(result.col_props.len(), result.n_col_blocks);
1775
1776        // All block_params have consistent lengths
1777        for bp in &result.block_params {
1778            assert!(!bp.mean.is_empty(), "block_param.mean is empty");
1779            assert_eq!(
1780                bp.mean.len(),
1781                bp.variance.len(),
1782                "mean/variance length mismatch"
1783            );
1784        }
1785    }
1786}