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