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