Skip to main content

gam_terms/basis/
center_selection.rs

1use super::*;
2use std::collections::HashSet;
3
4#[derive(Debug, Clone)]
5pub struct CollocationOperatorMatrices {
6    pub d0: Array2<f64>,
7    pub d1: Array2<f64>,
8    pub d2: Array2<f64>,
9    pub collocation_points: Array2<f64>,
10    /// Kernel-constraint nullspace transform `Z` applied internally to the
11    /// raw kernel-basis K×K operator matrices (Some for Duchon, None for
12    /// Matérn which uses a different basis).
13    pub kernel_nullspace_transform: Option<Array2<f64>>,
14    /// Polynomial block columns appended after the kernel block (Duchon
15    /// polynomial null space). Zero for Matérn.
16    pub polynomial_block_cols: usize,
17    /// The kernel chart amplitude `α` the collocation blocks carry (gam#979).
18    ///
19    /// The shipped Duchon design is `α·K` (`duchon_kernel_chart`), so the
20    /// operator quadratures of that basis are `α·D_q`; the closed-form Grams
21    /// that replace a quadrature must be scaled by `α²` to sit in the same
22    /// chart. `1.0` for Matérn and for every un-amplified kernel.
23    pub kernel_amplification: f64,
24}
25
26#[derive(Debug, Clone)]
27pub struct DuchonOperatorPenaltyMatrices {
28    pub mass: Array2<f64>,
29    pub tension: Array2<f64>,
30    pub stiffness: Array2<f64>,
31}
32
33#[derive(Debug, Clone)]
34pub struct ThinPlatePenaltyMatrix {
35    pub penalty: Array2<f64>,
36}
37
38pub(crate) fn validate_center_count(num_centers: usize) -> Result<(), BasisError> {
39    if num_centers == 0 {
40        crate::bail_invalid_basis!("center count must be positive");
41    }
42    Ok(())
43}
44
45/// R 4.2's Mersenne-Twister stream.
46///
47/// This deliberately implements the historical R initialization, rather than
48/// Rust's standard `Mt19937`: `set.seed()` first scrambles the seed with the
49/// 69069 LCG and stores 624 resulting words directly as the MT state.  Keeping
50/// this tiny private implementation lets the spectral Duchon landmark
51/// experiment reproduce mgcv's `set.seed(1); sample(...)` exactly without
52/// linking an R runtime into the model builder.
53struct R42MersenneTwister {
54    state: [u32; 624],
55    index: usize,
56}
57
58impl R42MersenneTwister {
59    fn seeded(seed: u32) -> Self {
60        let mut word = seed;
61        for _ in 0..50 {
62            word = word.wrapping_mul(69_069).wrapping_add(1);
63        }
64
65        // R allocates 625 seed words. Word zero is the cursor and is replaced
66        // with 624 by FixupSeeds; words 1..=624 are the MT state.
67        word = word.wrapping_mul(69_069).wrapping_add(1);
68        let mut state = [0_u32; 624];
69        for slot in &mut state {
70            word = word.wrapping_mul(69_069).wrapping_add(1);
71            *slot = word;
72        }
73        Self { state, index: 624 }
74    }
75
76    fn next_word(&mut self) -> u32 {
77        const M: usize = 397;
78        const MATRIX_A: u32 = 0x9908_b0df;
79        const UPPER_MASK: u32 = 0x8000_0000;
80        const LOWER_MASK: u32 = 0x7fff_ffff;
81
82        if self.index >= self.state.len() {
83            for k in 0..(self.state.len() - M) {
84                let y = (self.state[k] & UPPER_MASK) | (self.state[k + 1] & LOWER_MASK);
85                self.state[k] =
86                    self.state[k + M] ^ (y >> 1) ^ if y & 1 == 0 { 0 } else { MATRIX_A };
87            }
88            for k in (self.state.len() - M)..(self.state.len() - 1) {
89                let y = (self.state[k] & UPPER_MASK) | (self.state[k + 1] & LOWER_MASK);
90                self.state[k] = self.state[k + M - self.state.len()]
91                    ^ (y >> 1)
92                    ^ if y & 1 == 0 { 0 } else { MATRIX_A };
93            }
94            let last = self.state.len() - 1;
95            let y = (self.state[last] & UPPER_MASK) | (self.state[0] & LOWER_MASK);
96            self.state[last] = self.state[M - 1] ^ (y >> 1) ^ if y & 1 == 0 { 0 } else { MATRIX_A };
97            self.index = 0;
98        }
99
100        let mut y = self.state[self.index];
101        self.index += 1;
102        y ^= y >> 11;
103        y ^= (y << 7) & 0x9d2c_5680;
104        y ^= (y << 15) & 0xefc6_0000;
105        y ^= y >> 18;
106        y
107    }
108
109    /// R 3.6+'s rejection sampler for a uniform integer in `0..upper`.
110    fn uniform_index(&mut self, upper: usize) -> usize {
111        assert!(upper > 0, "uniform-index population must be positive");
112        let bits = usize::BITS as usize - (upper - 1).leading_zeros() as usize;
113        loop {
114            let mut value = 0_u128;
115            for _ in (0..bits).step_by(16) {
116                value = (value << 16) | u128::from(self.next_word() >> 16);
117            }
118            let mask = if bits == 0 { 0 } else { (1_u128 << bits) - 1 };
119            let candidate = (value & mask) as usize;
120            if candidate < upper {
121                return candidate;
122            }
123        }
124    }
125}
126
127/// Canonical dedup key for one coordinate row, shared by the knot BUDGET and
128/// by the sampler that has to satisfy it.
129///
130/// It exists so the two cannot drift: a budget derived from a different notion
131/// of "distinct row" than `select_r_uniform_subsample_centers` uses is a budget
132/// that function may be unable to fill.
133fn coordinate_row_key<'a>(values: impl Iterator<Item = &'a f64>) -> Vec<u64> {
134    values
135        .map(|&value| gam_data::canonical_level_bits(value))
136        .collect()
137}
138
139/// Number of distinct coordinate rows over `cols`, keyed exactly as
140/// [`select_r_uniform_subsample_centers`] keys them.
141///
142/// This is mgcv's `uniquecombs` count. Its Duchon constructor deduplicates
143/// FIRST and only then caps at `max.knots`, so the reference budget is
144/// `min(n_unique, max.knots)`. A budget taken from the raw row count instead
145/// can exceed what the sampler can supply on any data carrying a repeated
146/// coordinate row — which is a hard refusal, not a degraded fit (#2623:
147/// `prostate_gamair` asked for 523 centers from 522 unique rows and the whole
148/// scenario failed).
149pub(crate) fn count_unique_coordinate_rows(values: ArrayView2<'_, f64>, cols: &[usize]) -> usize {
150    let mut seen = HashSet::<Vec<u64>>::with_capacity(values.nrows());
151    let mut unique = 0usize;
152    for row in 0..values.nrows() {
153        if seen.insert(coordinate_row_key(cols.iter().map(|&col| &values[[row, col]]))) {
154            unique += 1;
155        }
156    }
157    unique
158}
159
160/// Select the same fixed-seed uniform landmark experiment used by mgcv's
161/// Duchon smoother (`max.knots=2000`, `seed=1`).
162///
163/// Rows are first deduplicated in encounter order, matching `uniquecombs`.
164/// When the unique count exceeds the budget, sampling is without replacement
165/// and byte-for-byte compatible with R 4.2's
166/// `set.seed(seed); sample(seq_len(n), k, replace=FALSE)`.  Exact experimental
167/// parity matters here: a different deterministic space-filling design changes
168/// the finite-sample kernel eigenspace, so comparing two rank-k smooths would
169/// otherwise conflate the spectral method with a different knot experiment.
170pub(crate) fn select_r_uniform_subsample_centers(
171    data: ArrayView2<'_, f64>,
172    num_centers: usize,
173    seed: u32,
174) -> Result<Array2<f64>, BasisError> {
175    validate_center_count(num_centers)?;
176    if data.ncols() == 0 {
177        crate::bail_invalid_basis!("uniform subsampling requires at least one column");
178    }
179
180    let mut seen = HashSet::<Vec<u64>>::with_capacity(data.nrows());
181    let mut unique_rows = Vec::<usize>::with_capacity(data.nrows().min(num_centers));
182    for row in 0..data.nrows() {
183        if seen.insert(coordinate_row_key(data.row(row).iter())) {
184            unique_rows.push(row);
185        }
186    }
187    if unique_rows.len() < num_centers {
188        crate::bail_invalid_basis!(
189            "uniform subsampling requested {num_centers} centers but data has only {} unique rows",
190            unique_rows.len()
191        );
192    }
193
194    let selected = if unique_rows.len() == num_centers {
195        unique_rows
196    } else {
197        let mut rng = R42MersenneTwister::seeded(seed);
198        let mut remaining = unique_rows.len();
199        let mut selected = Vec::with_capacity(num_centers);
200        for _ in 0..num_centers {
201            let choice = rng.uniform_index(remaining);
202            selected.push(unique_rows[choice]);
203            remaining -= 1;
204            unique_rows[choice] = unique_rows[remaining];
205        }
206        selected
207    };
208
209    let mut centers = Array2::<f64>::zeros((num_centers, data.ncols()));
210    for (center, row) in selected.into_iter().enumerate() {
211        centers.row_mut(center).assign(&data.row(row));
212    }
213    Ok(centers)
214}
215
216pub(crate) fn select_equal_mass_centers(
217    data: ArrayView2<'_, f64>,
218    num_centers: usize,
219) -> Result<Array2<f64>, BasisError> {
220    validate_center_count(num_centers)?;
221    let n = data.nrows();
222    let d = data.ncols();
223    if num_centers > n {
224        crate::bail_invalid_basis!(
225            "equal-mass center selection requested {num_centers} centers but data has {n} rows"
226        );
227    }
228    if d == 0 {
229        crate::bail_invalid_basis!("equal-mass center selection requires at least one column");
230    }
231    #[derive(Clone, Copy)]
232    struct Leaf {
233        pub(crate) start: usize,
234        pub(crate) end: usize,
235    }
236
237    // Recursive equal-mass partition that splits each leaf along its PRINCIPAL
238    // axis (the leading eigen-direction of the leaf covariance), rather than along
239    // its widest *coordinate* axis. An axis-aligned k-d-tree split is NOT
240    // rotation-equivariant: under a rigid rotation of the inputs the per-leaf
241    // coordinate spans change, "the widest coordinate" can flip, and a different
242    // center set results — which is the root cause of #1456 (default
243    // `thinplate(x,z)` drifting under rotation). The principal axis rotates with
244    // the data, so projecting onto it and splitting at the equal-mass median
245    // along that axis selects the SAME points (up to the rotation), making the
246    // low-rank center set rotation-equivariant while staying deterministic and
247    // permutation-invariant. The 2-D principal direction is taken from the
248    // closed-form covariance angle (continuous through near-isotropic leaves)
249    // rather than from `eigh`, whose eigenvalue ordering swaps discontinuously at
250    // near-degeneracy and would flip the split by 90° under rotation. Keep all row
251    // indices in a single buffer and sort subranges in-place so center selection
252    // stays exact without allocating fresh index vectors at every split.
253    let mut order: Vec<usize> = (0..n).collect();
254    let mut leaves = vec![Leaf { start: 0, end: n }];
255
256    // Leading-eigenvector ("principal") axis of the leaf covariance. The sign of
257    // an eigenvector is arbitrary; we canonicalise it deterministically (largest
258    // |component| made positive, lowest index breaking magnitude ties) so the
259    // sort order — and hence the chosen split — is reproducible. The median
260    // split itself is sign-invariant, but canonicalisation also pins the
261    // tie-break ordering used for points with equal projections. Returns `None`
262    // when the leaf has no usable spread (all eigenvalues ~0), in which case the
263    // caller falls back to a deterministic coordinate-lexicographic order.
264    let principal_axis = |slice: &[usize]| -> Option<Vec<f64>> {
265        let m = slice.len();
266        if m < 2 {
267            return None;
268        }
269        let mut centroid = vec![0.0_f64; d];
270        for &idx in slice {
271            for j in 0..d {
272                centroid[j] += data[[idx, j]];
273            }
274        }
275        let inv = 1.0 / m as f64;
276        for v in &mut centroid {
277            *v *= inv;
278        }
279        // Covariance (d×d, small): symmetric accumulation of centred outer
280        // products. d is the covariate dimension (typically 2 for thinplate).
281        let mut cov = Array2::<f64>::zeros((d, d));
282        for &idx in slice {
283            for a in 0..d {
284                let da = data[[idx, a]] - centroid[a];
285                for b in a..d {
286                    let db = data[[idx, b]] - centroid[b];
287                    cov[[a, b]] += da * db;
288                }
289            }
290        }
291        for a in 0..d {
292            cov[[a, a]] *= inv;
293            for b in (a + 1)..d {
294                cov[[a, b]] *= inv;
295                cov[[b, a]] = cov[[a, b]];
296            }
297        }
298        if cov.iter().any(|v| !v.is_finite()) {
299            return None;
300        }
301        // Leading principal direction of the leaf covariance. For the 2-D case
302        // (the thin-plate `(x, z)` smooth of #1456) use the CLOSED-FORM principal
303        // angle of the 2×2 symmetric covariance rather than `eigh`. `eigh` returns
304        // eigenvectors ordered by eigenvalue, so when a leaf is near-isotropic
305        // (the two eigenvalues nearly equal) an arbitrarily small perturbation —
306        // such as the rounding introduced by rotating the inputs — can SWAP the
307        // ordering and flip the chosen axis by 90°, producing a completely
308        // different partition (the #1456 failure: residual ~1.5, a different
309        // center set). The closed form
310        //     θ = ½·atan2(2·S_xy, S_xx − S_yy)
311        // is CONTINUOUS in the covariance entries, so it tracks the major axis
312        // smoothly through near-degeneracy, and it is rotation-equivariant: under
313        // a rotation by φ the pair (2·S_xy, S_xx − S_yy) rotates by 2φ, so θ
314        // rotates by exactly φ. It is undefined only at EXACT isotropy
315        // (S_xy == 0 and S_xx == S_yy), where there is no preferred axis and we
316        // fall back to the coordinate-lexicographic order. Higher dimensions keep
317        // the `eigh` path (the rotation-invariance regression is 2-D).
318        let mut axis: Vec<f64> = if d == 2 {
319            let sxx = cov[[0, 0]];
320            let syy = cov[[1, 1]];
321            let sxy = cov[[0, 1]];
322            if sxy == 0.0 && sxx == syy {
323                return None;
324            }
325            let angle = 0.5 * (2.0 * sxy).atan2(sxx - syy);
326            vec![angle.cos(), angle.sin()]
327        } else {
328            // `eigh` returns eigenvalues in ascending order, so the principal axis
329            // is the LAST column. Fall back to coordinate order if it cannot factor.
330            let (evals, evecs) = cov.eigh(Side::Lower).ok()?;
331            let last = evals.len().checked_sub(1)?;
332            if !(evals[last] > 0.0) {
333                return None;
334            }
335            (0..d).map(|r| evecs[[r, last]]).collect()
336        };
337        if axis.iter().any(|v| !v.is_finite()) {
338            return None;
339        }
340        // Canonical, rotation-EQUIVARIANT orientation: point the axis toward the
341        // leaf member farthest from the centroid. Distance-to-centroid is
342        // rotation-invariant and the lowest-index tie-break is rotation-stable, so
343        // the SAME row is chosen for the rotated leaf and the axis SIGN is
344        // therefore equivariant (it rotates with the data). A canonical
345        // orientation — not merely a canonical line — is required because an
346        // equal-mass split of an ODD-sized leaf is asymmetric (the extra point
347        // falls on one side of the median): a bare sign flip of the axis would
348        // reassign the median point and produce a different partition under
349        // rotation. Orienting by the farthest point removes that ambiguity.
350        let mut far_idx = slice[0];
351        let mut far_d2 = f64::NEG_INFINITY;
352        for &idx in slice {
353            let mut d2 = 0.0_f64;
354            for j in 0..d {
355                let delta = data[[idx, j]] - centroid[j];
356                d2 += delta * delta;
357            }
358            if d2 > far_d2 || (d2 == far_d2 && idx < far_idx) {
359                far_d2 = d2;
360                far_idx = idx;
361            }
362        }
363        let mut proj = 0.0_f64;
364        for j in 0..d {
365            proj += (data[[far_idx, j]] - centroid[j]) * axis[j];
366        }
367        if proj < 0.0 {
368            for v in &mut axis {
369                *v = -*v;
370            }
371        } else if proj == 0.0 {
372            // Farthest point sits orthogonal to the axis (no orientation cue):
373            // fall back to the deterministic magnitude-pivot sign so the split is
374            // still reproducible.
375            let mut pivot = 0usize;
376            for r in 1..d {
377                if axis[r].abs() > axis[pivot].abs() {
378                    pivot = r;
379                }
380            }
381            if axis[pivot] < 0.0 {
382                for v in &mut axis {
383                    *v = -*v;
384                }
385            }
386        }
387        Some(axis)
388    };
389
390    while leaves.len() < num_centers {
391        let mut split_pos = None;
392        let mut split_size = 0usize;
393        for (i, leaf) in leaves.iter().enumerate() {
394            let leaf_size = leaf.end - leaf.start;
395            if leaf_size > split_size && leaf_size > 1 {
396                split_size = leaf_size;
397                split_pos = Some(i);
398            }
399        }
400        let Some(pos) = split_pos else {
401            break;
402        };
403
404        let leaf = leaves.swap_remove(pos);
405        let axis = principal_axis(&order[leaf.start..leaf.end]);
406        match axis {
407            Some(axis) => {
408                // Project each row onto the principal axis and sort by the scalar
409                // projection (index tie-break for determinism). The projection
410                // rotates with the data, so this split is rotation-equivariant.
411                order[leaf.start..leaf.end].sort_by(|&a, &b| {
412                    let mut pa = 0.0_f64;
413                    let mut pb = 0.0_f64;
414                    for j in 0..d {
415                        pa += data[[a, j]] * axis[j];
416                        pb += data[[b, j]] * axis[j];
417                    }
418                    let ord = pa.total_cmp(&pb);
419                    if ord.is_eq() { a.cmp(&b) } else { ord }
420                });
421            }
422            None => {
423                // Degenerate leaf (no spread / non-finite covariance): fall back
424                // to a deterministic coordinate-lexicographic order so the split
425                // is still well defined and permutation-invariant.
426                order[leaf.start..leaf.end].sort_by(|&a, &b| {
427                    for j in 0..d {
428                        let ord = data[[a, j]].total_cmp(&data[[b, j]]);
429                        if !ord.is_eq() {
430                            return ord;
431                        }
432                    }
433                    a.cmp(&b)
434                });
435            }
436        }
437        let mid = leaf.start + (split_size / 2);
438
439        if mid == leaf.start || mid == leaf.end {
440            leaves.push(leaf);
441            break;
442        }
443
444        leaves.push(Leaf {
445            start: leaf.start,
446            end: mid,
447        });
448        leaves.push(Leaf {
449            start: mid,
450            end: leaf.end,
451        });
452    }
453
454    if leaves.len() < num_centers {
455        crate::bail_invalid_basis!(
456            "equal-mass partition produced {} leaves, expected {num_centers}",
457            leaves.len()
458        );
459    }
460
461    let mut centers = Array2::<f64>::zeros((num_centers, d));
462    for (c, leaf) in leaves.iter().take(num_centers).enumerate() {
463        let slice = &order[leaf.start..leaf.end];
464        let m = slice.len() as f64;
465        let mut centroid = vec![0.0_f64; d];
466        for &idx in slice {
467            for j in 0..d {
468                centroid[j] += data[[idx, j]];
469            }
470        }
471        for v in &mut centroid {
472            *v /= m.max(1.0);
473        }
474
475        let best_idx = slice
476            .par_iter()
477            .filter_map(|&idx| {
478                let mut d2 = 0.0;
479                for j in 0..d {
480                    let delta = data[[idx, j]] - centroid[j];
481                    d2 += delta * delta;
482                }
483                if d2.is_finite() {
484                    Some((idx, d2))
485                } else {
486                    None
487                }
488            })
489            .reduce_with(|a, b| {
490                if b.1 < a.1 || (b.1 == a.1 && b.0 < a.0) {
491                    b
492                } else {
493                    a
494                }
495            })
496            .map(|(idx, _)| idx)
497            .unwrap_or(slice[0]);
498        centers.row_mut(c).assign(&data.row(best_idx));
499    }
500    Ok(centers)
501}
502
503pub(crate) fn select_equal_mass_covar_representative_centers(
504    data: ArrayView2<'_, f64>,
505    num_centers: usize,
506) -> Result<Array2<f64>, BasisError> {
507    validate_center_count(num_centers)?;
508    let n = data.nrows();
509    let d = data.ncols();
510    if num_centers > n {
511        crate::bail_invalid_basis!(
512            "equal-mass covariate-representative center selection requested {num_centers} centers but data has {n} rows"
513        );
514    }
515    if d == 0 {
516        crate::bail_invalid_basis!(
517            "equal-mass covariate-representative center selection requires at least one column"
518                .to_string(),
519        );
520    }
521
522    let mut split_dim = 0usize;
523    let mut best_span = f64::NEG_INFINITY;
524    for j in 0..d {
525        let mut minv = f64::INFINITY;
526        let mut maxv = f64::NEG_INFINITY;
527        for i in 0..n {
528            let v = data[[i, j]];
529            if v < minv {
530                minv = v;
531            }
532            if v > maxv {
533                maxv = v;
534            }
535        }
536        let span = maxv - minv;
537        if span > best_span {
538            best_span = span;
539            split_dim = j;
540        }
541    }
542
543    let mut sorted: Vec<usize> = (0..n).collect();
544    sorted.sort_by(|&a, &b| {
545        let ord = data[[a, split_dim]].total_cmp(&data[[b, split_dim]]);
546        if ord.is_eq() { a.cmp(&b) } else { ord }
547    });
548
549    let mut centers = Array2::<f64>::zeros((num_centers, d));
550    for c in 0..num_centers {
551        let lo = (c * n) / num_centers;
552        let hi = ((c + 1) * n) / num_centers;
553        let chunk = &sorted[lo..hi.max(lo + 1)];
554        let mid = chunk[chunk.len() / 2];
555        centers.row_mut(c).assign(&data.row(mid));
556    }
557    Ok(centers)
558}
559
560pub(crate) fn select_kmeans_centers(
561    data: ArrayView2<'_, f64>,
562    num_centers: usize,
563    max_iter: usize,
564) -> Result<Array2<f64>, BasisError> {
565    validate_center_count(num_centers)?;
566    let n = data.nrows();
567    let d = data.ncols();
568    if num_centers > n {
569        crate::bail_invalid_basis!("kmeans requested {num_centers} centers but data has {n} rows");
570    }
571    const KMEANS_PILOT_MAX_ROWS: usize = 20_000;
572    if n > KMEANS_PILOT_MAX_ROWS {
573        let pilot_n = KMEANS_PILOT_MAX_ROWS.max(num_centers);
574        // log::info! rather than warn! — this is a deliberate performance
575        // choice (O(n·k·iter) kmeans scales badly past ~20K rows), not a
576        // problem the user can act on. Surfacing it as a warning adds
577        // noise to CI output and mislabels normal operation.
578        log::info!(
579            "kmeans center selection using {}-row pilot subsample instead of full {} rows",
580            pilot_n,
581            n
582        );
583        let pilot = select_equal_mass_covar_representative_centers(data, pilot_n)?;
584        return select_kmeans_centers(pilot.view(), num_centers, max_iter);
585    }
586    let mut centers = select_thin_plate_knots(data, num_centers)?;
587    let mut assign = vec![0usize; n];
588    let iters = max_iter.max(1);
589
590    // For large n (large-scale), parallelize the assignment step.
591    // Each observation's nearest-center query is independent.
592    let use_parallel = n >= 10_000;
593
594    for _ in 0..iters {
595        // Assignment: find nearest center for each observation.
596        if use_parallel {
597            const KMEANS_CHUNK: usize = 4096;
598            assign
599                .par_chunks_mut(KMEANS_CHUNK)
600                .enumerate()
601                .for_each(|(ci, chunk)| {
602                    let base = ci * KMEANS_CHUNK;
603                    for (local, slot) in chunk.iter_mut().enumerate() {
604                        let i = base + local;
605                        let mut best = 0usize;
606                        let mut best_d2 = f64::INFINITY;
607                        for k in 0..num_centers {
608                            let mut d2 = 0.0;
609                            for c in 0..d {
610                                let delta = data[[i, c]] - centers[[k, c]];
611                                d2 += delta * delta;
612                            }
613                            if d2 < best_d2 {
614                                best_d2 = d2;
615                                best = k;
616                            }
617                        }
618                        *slot = best;
619                    }
620                });
621        } else {
622            for i in 0..n {
623                let mut best = 0usize;
624                let mut best_d2 = f64::INFINITY;
625                for k in 0..num_centers {
626                    let mut d2 = 0.0;
627                    for c in 0..d {
628                        let delta = data[[i, c]] - centers[[k, c]];
629                        d2 += delta * delta;
630                    }
631                    if d2 < best_d2 {
632                        best_d2 = d2;
633                        best = k;
634                    }
635                }
636                assign[i] = best;
637            }
638        }
639        // Update: recompute centroids from assignments.
640        let mut sums = Array2::<f64>::zeros((num_centers, d));
641        let mut counts = vec![0usize; num_centers];
642        for i in 0..n {
643            let k = assign[i];
644            counts[k] += 1;
645            for c in 0..d {
646                sums[[k, c]] += data[[i, c]];
647            }
648        }
649        for k in 0..num_centers {
650            if counts[k] == 0 {
651                continue;
652            }
653            let inv = 1.0 / counts[k] as f64;
654            for c in 0..d {
655                centers[[k, c]] = sums[[k, c]] * inv;
656            }
657        }
658    }
659    Ok(centers)
660}
661
662pub(crate) fn cartesian_grid_axes(axes: &[Array1<f64>]) -> Result<Array2<f64>, BasisError> {
663    if axes.is_empty() {
664        crate::bail_invalid_basis!("uniform grid requires at least one axis");
665    }
666    let d = axes.len();
667    let total = axes.iter().try_fold(1usize, |acc, axis| {
668        acc.checked_mul(axis.len())
669            .ok_or_else(|| BasisError::DimensionMismatch("uniform grid is too large".to_string()))
670    })?;
671    let mut out = Array2::<f64>::zeros((total, d));
672    for r in 0..total {
673        let mut q = r;
674        for c in (0..d).rev() {
675            let len = axes[c].len();
676            let idx = q % len;
677            q /= len;
678            out[[r, c]] = axes[c][idx];
679        }
680    }
681    Ok(out)
682}
683
684pub(crate) fn select_uniform_grid_centers(
685    data: ArrayView2<'_, f64>,
686    points_per_dim: usize,
687) -> Result<Array2<f64>, BasisError> {
688    if points_per_dim == 0 {
689        crate::bail_invalid_basis!("uniform-grid points_per_dim must be positive");
690    }
691    let d = data.ncols();
692    if d == 0 {
693        crate::bail_invalid_basis!("uniform-grid center selection requires at least one column");
694    }
695    let mut axes = Vec::with_capacity(d);
696    for c in 0..d {
697        let col = data.column(c);
698        let minv = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
699        let maxv = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
700        axes.push(Array::linspace(minv, maxv, points_per_dim));
701    }
702    cartesian_grid_axes(&axes)
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    /// #2623: the spectral-Duchon knot budget was `min(n_rows, 2000)` while the
710    /// sampler that fills it deduplicates first, so a single repeated
711    /// coordinate row made the budget unsatisfiable and the fit hard-refused
712    /// (`prostate_gamair`: "requested 523 centers but data has only 522 unique
713    /// rows"). This pins the invariant the fix rests on — a budget taken from
714    /// `count_unique_coordinate_rows` is always fillable, and one row more
715    /// never is — so the two notions of "distinct row" cannot drift apart
716    /// again without failing here.
717    #[test]
718    fn unique_row_count_is_exactly_the_budget_the_sampler_can_fill_2623() {
719        // 523 rows, 522 distinct: row 0 is repeated at the end, which is the
720        // shape that killed the scenario. Duplicates are placed at both ends so
721        // an off-by-one in either direction of the scan is caught.
722        let rows = 523usize;
723        let mut data = Array2::<f64>::zeros((rows, 2));
724        for row in 0..rows - 1 {
725            data[[row, 0]] = row as f64;
726            data[[row, 1]] = (row * 2) as f64;
727        }
728        data[[rows - 1, 0]] = 0.0;
729        data[[rows - 1, 1]] = 0.0;
730
731        let cols = [0usize, 1usize];
732        let unique = count_unique_coordinate_rows(data.view(), &cols);
733        assert_eq!(
734            unique,
735            rows - 1,
736            "one repeated coordinate row must reduce the distinct count by exactly one"
737        );
738
739        // The budget the fix installs is satisfiable...
740        select_r_uniform_subsample_centers(data.view(), unique, 1)
741            .expect("a budget equal to the distinct-row count must always be fillable");
742
743        // ...and the budget the defect installed is not. This is the exact
744        // failure the benchmark hit, reproduced in milliseconds.
745        let err = select_r_uniform_subsample_centers(data.view(), rows, 1)
746            .expect_err("asking for more centers than distinct rows must still refuse");
747        assert!(
748            format!("{err}").contains("522 unique rows"),
749            "the refusal must name the distinct-row count it measured, got: {err}"
750        );
751    }
752
753    #[test]
754    fn r_uniform_subsample_matches_r_4_2_sample_without_replacement() {
755        let data = Array2::from_shape_fn((4800, 1), |(row, _)| (row + 1) as f64);
756        let centers =
757            select_r_uniform_subsample_centers(data.view(), 20, 1).expect("sample centers");
758        assert_eq!(
759            centers.column(0).to_vec(),
760            vec![
761                1017.0, 4775.0, 2177.0, 1533.0, 4567.0, 2347.0, 270.0, 4050.0, 3379.0, 4065.0,
762                597.0, 1301.0, 330.0, 1799.0, 3913.0, 1749.0, 37.0, 1129.0, 729.0, 878.0,
763            ]
764        );
765    }
766
767    #[test]
768    fn r_uniform_subsample_matches_r_when_integer_sampling_needs_multiple_words() {
769        let data = Array2::from_shape_fn((70_000, 1), |(row, _)| (row + 1) as f64);
770        let centers =
771            select_r_uniform_subsample_centers(data.view(), 20, 1).expect("sample centers");
772        assert_eq!(
773            centers.column(0).to_vec(),
774            vec![
775                24_388.0, 59_521.0, 43_307.0, 69_586.0, 11_571.0, 25_173.0, 32_618.0, 13_903.0,
776                8_229.0, 25_305.0, 22_306.0, 12_204.0, 43_809.0, 36_244.0, 45_399.0, 6_519.0,
777                19_242.0, 21_875.0, 58_472.0, 62_956.0,
778            ]
779        );
780    }
781
782    #[test]
783    fn r_uniform_subsample_deduplicates_in_encounter_order() {
784        let data = Array2::from_shape_vec(
785            (5, 2),
786            vec![1.0, 2.0, 3.0, 4.0, 1.0, 2.0, -0.0, 5.0, 0.0, 5.0],
787        )
788        .expect("duplicate-row fixture");
789        let centers =
790            select_r_uniform_subsample_centers(data.view(), 3, 1).expect("unique centers");
791        assert_eq!(centers, data.select(Axis(0), &[0, 1, 3]));
792    }
793
794    #[test]
795    fn one_dimensional_uniform_grid_is_the_interval_minimax_mesh() {
796        let data = Array2::from_shape_vec((5, 1), vec![-3.0, -2.4, -0.1, 1.7, 3.0])
797            .expect("one-dimensional fixture");
798        let centers = select_uniform_grid_centers(data.view(), 4).expect("uniform centers");
799
800        assert_eq!(centers.column(0).to_vec(), vec![-3.0, -1.0, 1.0, 3.0]);
801        let gaps: Vec<f64> = centers
802            .column(0)
803            .windows(2)
804            .into_iter()
805            .map(|window| window[1] - window[0])
806            .collect();
807        assert_eq!(gaps, vec![2.0, 2.0, 2.0]);
808    }
809
810    /// Deterministic 2-D scatter with a clear, off-axis anisotropy so the
811    /// principal axis is well separated from both coordinate axes. A small
812    /// lattice perturbed by a reproducible pseudo-random jitter; no RNG crate
813    /// needed, fully deterministic across runs.
814    fn make_points() -> Array2<f64> {
815        let n_side = 11usize;
816        let n = n_side * n_side;
817        let mut pts = Array2::<f64>::zeros((n, 2));
818        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
819        let mut next = || {
820            // xorshift64* — deterministic, no external dependency.
821            state ^= state >> 12;
822            state ^= state << 25;
823            state ^= state >> 27;
824            let v = state.wrapping_mul(0x2545_F491_4F6C_DD1D);
825            ((v >> 11) as f64) / ((1u64 << 53) as f64)
826        };
827        let mut r = 0usize;
828        for i in 0..n_side {
829            for j in 0..n_side {
830                let x = i as f64;
831                // Shear the lattice so its spread is genuinely off both axes,
832                // making the "widest coordinate" choice fragile under rotation.
833                let y = 0.35 * i as f64 + 1.7 * j as f64;
834                pts[[r, 0]] = x + 0.05 * (next() - 0.5);
835                pts[[r, 1]] = y + 0.05 * (next() - 0.5);
836                r += 1;
837            }
838        }
839        pts
840    }
841
842    /// Assert two center sets are equal up to ordering, by greedily matching each
843    /// row of `expected` to its nearest row of `actual` and requiring the match
844    /// residual to be below `tol`. Both sets must have the same number of rows.
845    fn assert_center_sets_match(
846        expected: ArrayView2<'_, f64>,
847        actual: ArrayView2<'_, f64>,
848        tol: f64,
849    ) {
850        assert_eq!(expected.nrows(), actual.nrows(), "center counts differ");
851        let k = expected.nrows();
852        let mut used = vec![false; k];
853        let mut worst = 0.0_f64;
854        for ei in 0..k {
855            let mut best = usize::MAX;
856            let mut best_d2 = f64::INFINITY;
857            for ai in 0..k {
858                if used[ai] {
859                    continue;
860                }
861                let dx = expected[[ei, 0]] - actual[[ai, 0]];
862                let dy = expected[[ei, 1]] - actual[[ai, 1]];
863                let d2 = dx * dx + dy * dy;
864                if d2 < best_d2 {
865                    best_d2 = d2;
866                    best = ai;
867                }
868            }
869            assert!(best != usize::MAX, "no unmatched center available");
870            used[best] = true;
871            worst = worst.max(best_d2.sqrt());
872        }
873        assert!(
874            worst <= tol,
875            "rotation-equivariance violated: worst center match residual {worst:.3e} > tol {tol:.3e}"
876        );
877    }
878
879    /// Existing invariant we must preserve: center selection is invariant to row
880    /// permutation of the inputs (the selected SET is unchanged when rows are
881    /// reordered). Locks the determinism/permutation-invariance the fix must keep.
882    #[test]
883    fn equal_mass_centers_are_permutation_invariant() {
884        let pts = make_points();
885        let num_centers = 16usize;
886        let base = select_equal_mass_centers(pts.view(), num_centers).unwrap();
887
888        let n = pts.nrows();
889        let mut perm: Vec<usize> = (0..n).collect();
890        // Deterministic shuffle.
891        let mut state: u64 = 0xD1B5_4A32_D192_ED03;
892        for i in (1..n).rev() {
893            state ^= state >> 12;
894            state ^= state << 25;
895            state ^= state >> 27;
896            let j = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) % (i as u64 + 1)) as usize;
897            perm.swap(i, j);
898        }
899        let mut permuted = Array2::<f64>::zeros((n, 2));
900        for (new_r, &old_r) in perm.iter().enumerate() {
901            permuted[[new_r, 0]] = pts[[old_r, 0]];
902            permuted[[new_r, 1]] = pts[[old_r, 1]];
903        }
904        let permuted_centers = select_equal_mass_centers(permuted.view(), num_centers).unwrap();
905        assert_center_sets_match(base.view(), permuted_centers.view(), 1e-13);
906    }
907
908    /// #1456: the low-rank equal-mass center selector must be rotation
909    /// EQUIVARIANT — rigidly rotating the inputs about their centroid rotates
910    /// the selected center SET by the same rotation (so the un-rotated centers
911    /// coincide with the base selection). The axis-aligned k-d split was
912    /// rotation-SENSITIVE (the "widest coordinate" flipped under rotation,
913    /// picking a different equal-mass set and drifting the fitted thin-plate
914    /// surface ~2% of the signal range); the principal-axis (closed-form
915    /// covariance-angle) split restores the invariant. Exercises an exact 90°
916    /// rotation (no floating rounding of its own) and a generic 0.7-rad angle.
917    #[test]
918    fn equal_mass_centers_are_rotation_equivariant() {
919        // ~300 deterministic points on an anisotropic cloud (a sheared lattice
920        // plus jitter), so leaves have a well-defined principal axis.
921        let n = 300usize;
922        let mut pts = Array2::<f64>::zeros((n, 2));
923        let mut state: u64 = 0x1234_5678_9ABC_DEF0;
924        let mut next = || {
925            state ^= state >> 12;
926            state ^= state << 25;
927            state ^= state >> 27;
928            let v = state.wrapping_mul(0x2545_F491_4F6C_DD1D);
929            ((v >> 11) as f64) / ((1u64 << 53) as f64)
930        };
931        for r in 0..n {
932            let u = next();
933            let v = next();
934            // Shear to break isotropy (anisotropic principal axes per leaf).
935            pts[[r, 0]] = 2.0 * u - 1.0 + 0.6 * (2.0 * v - 1.0);
936            pts[[r, 1]] = 2.0 * v - 1.0;
937        }
938        let num_centers = 48usize;
939        let base = select_equal_mass_centers(pts.view(), num_centers).unwrap();
940
941        // Centroid (rotation pivot).
942        let mut cx = 0.0;
943        let mut cy = 0.0;
944        for r in 0..n {
945            cx += pts[[r, 0]];
946            cy += pts[[r, 1]];
947        }
948        cx /= n as f64;
949        cy /= n as f64;
950
951        for &(ca, sa) in &[(0.0_f64, 1.0_f64), (0.7f64.cos(), 0.7f64.sin())] {
952            let mut rot = Array2::<f64>::zeros((n, 2));
953            for r in 0..n {
954                let x = pts[[r, 0]] - cx;
955                let y = pts[[r, 1]] - cy;
956                rot[[r, 0]] = ca * x - sa * y + cx;
957                rot[[r, 1]] = sa * x + ca * y + cy;
958            }
959            let rotated_centers = select_equal_mass_centers(rot.view(), num_centers).unwrap();
960            // Un-rotate the centers selected in the rotated frame and require the
961            // SET to coincide with the base selection (equivariance).
962            let mut unrotated = Array2::<f64>::zeros((num_centers, 2));
963            for r in 0..num_centers {
964                let x = rotated_centers[[r, 0]] - cx;
965                let y = rotated_centers[[r, 1]] - cy;
966                unrotated[[r, 0]] = ca * x + sa * y + cx;
967                unrotated[[r, 1]] = -sa * x + ca * y + cy;
968            }
969            assert_center_sets_match(base.view(), unrotated.view(), 1e-9);
970        }
971    }
972}