Skip to main content

antecedent_kernels/
parcorr.rs

1//! Partial-correlation kernels.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_precision_loss, clippy::needless_range_loop, clippy::similar_names)]
6
7/// Scratch for residualization and Pearson correlation.
8#[derive(Clone, Debug, Default)]
9pub struct ParCorrWorkspace {
10    /// Design matrix column-major `[Z…]` (`n * p`); no intercept (pinned baseline parity).
11    pub design: Vec<f64>,
12    /// `XtX` / Gram (`p^2`).
13    pub gram: Vec<f64>,
14    /// RHS / coefficients (`p`).
15    pub beta: Vec<f64>,
16    /// Residual of X.
17    pub rx: Vec<f64>,
18    /// Residual of Y.
19    pub ry: Vec<f64>,
20    capacity_n: usize,
21    capacity_p: usize,
22}
23
24impl ParCorrWorkspace {
25    /// Ensure capacity for `n` rows and `p` covariates (no intercept column).
26    pub fn prepare(&mut self, n: usize, p: usize) {
27        let ncols = p.max(1);
28        let need_design = n.saturating_mul(ncols);
29        if self.design.len() < need_design {
30            self.design.resize(need_design, 0.0);
31        }
32        let need_gram = ncols.saturating_mul(ncols);
33        if self.gram.len() < need_gram {
34            self.gram.resize(need_gram, 0.0);
35        }
36        if self.beta.len() < ncols {
37            self.beta.resize(ncols, 0.0);
38        }
39        if self.rx.len() < n {
40            self.rx.resize(n, 0.0);
41        }
42        if self.ry.len() < n {
43            self.ry.resize(n, 0.0);
44        }
45        self.capacity_n = self.capacity_n.max(n);
46        self.capacity_p = self.capacity_p.max(p);
47    }
48
49    /// Retained row capacity.
50    #[must_use]
51    pub const fn capacity_n(&self) -> usize {
52        self.capacity_n
53    }
54
55    /// Retained covariate capacity.
56    #[must_use]
57    pub const fn capacity_p(&self) -> usize {
58        self.capacity_p
59    }
60}
61
62/// One batch query: column indexes into a shared column list.
63#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
64pub struct ParCorrQuery {
65    /// Index of X column.
66    pub x: usize,
67    /// Index of Y column.
68    pub y: usize,
69    /// Start index into a shared flat conditioning-index buffer.
70    pub z_start: usize,
71    /// Number of conditioning columns.
72    pub z_len: usize,
73}
74
75/// Pearson correlation of two equal-length slices (population formula).
76#[must_use]
77pub fn pearson(x: &[f64], y: &[f64]) -> Option<f64> {
78    debug_assert_eq!(x.len(), y.len());
79    let n = x.len();
80    if n < 2 {
81        return None;
82    }
83    let nf = n as f64;
84    let mut mx = 0.0;
85    let mut my = 0.0;
86    for i in 0..n {
87        mx += x[i];
88        my += y[i];
89    }
90    mx /= nf;
91    my /= nf;
92    let mut cxx = 0.0;
93    let mut cyy = 0.0;
94    let mut cxy = 0.0;
95    for i in 0..n {
96        let dx = x[i] - mx;
97        let dy = y[i] - my;
98        cxx += dx * dx;
99        cyy += dy * dy;
100        cxy += dx * dy;
101    }
102    if constant_column(cxx, mx, nf) || constant_column(cyy, my, nf) {
103        return None;
104    }
105    Some(cxy / (cxx * cyy).sqrt())
106}
107
108/// Effectively-constant test on a centered sum of squares, relative to the column's
109/// magnitude so the verdict does not depend on the data's units.
110fn constant_column(css: f64, mean: f64, nf: f64) -> bool {
111    let tol = nf * (f64::EPSILON * (1.0 + mean.abs())).powi(2);
112    !(css.is_finite() && css > tol)
113}
114
115fn build_design(z_cols: &[&[f64]], n: usize, design: &mut [f64]) {
116    for (j, z) in z_cols.iter().enumerate() {
117        let base = j * n;
118        let mean = z.iter().sum::<f64>() / n as f64;
119        for r in 0..n {
120            design[base + r] = z[r] - mean;
121        }
122    }
123}
124
125fn form_gram(design: &[f64], n: usize, ncols: usize, gram: &mut [f64]) {
126    gram.fill(0.0);
127    for c1 in 0..ncols {
128        for c2 in c1..ncols {
129            let mut acc = 0.0;
130            let a = &design[c1 * n..(c1 + 1) * n];
131            let b = &design[c2 * n..(c2 + 1) * n];
132            for r in 0..n {
133                acc += a[r] * b[r];
134            }
135            gram[c1 * ncols + c2] = acc;
136            gram[c2 * ncols + c1] = acc;
137        }
138    }
139}
140
141fn form_xty(design: &[f64], y: &[f64], n: usize, ncols: usize, out: &mut [f64]) {
142    let y_mean = y.iter().sum::<f64>() / n as f64;
143    for c in 0..ncols {
144        let mut acc = 0.0;
145        let col = &design[c * n..(c + 1) * n];
146        for r in 0..n {
147            acc += col[r] * (y[r] - y_mean);
148        }
149        out[c] = acc;
150    }
151}
152
153/// Gauss–Jordan with partial pivoting; singularity is judged relative to the Gram's
154/// largest diagonal so the verdict does not depend on the data's units.
155fn solve_inplace(gram: &mut [f64], rhs: &mut [f64], ncols: usize) -> bool {
156    let mut scale = 0.0_f64;
157    for d in 0..ncols {
158        scale = scale.max(gram[d * ncols + d].abs());
159    }
160    if !(scale.is_finite() && scale > 0.0) {
161        return false;
162    }
163    let tol = 1e-12 * scale;
164    for col in 0..ncols {
165        let mut best_row = col;
166        let mut best = gram[col * ncols + col].abs();
167        for row in (col + 1)..ncols {
168            let v = gram[row * ncols + col].abs();
169            if v > best {
170                best = v;
171                best_row = row;
172            }
173        }
174        if best <= tol {
175            return false;
176        }
177        if best_row != col {
178            for j in 0..ncols {
179                gram.swap(col * ncols + j, best_row * ncols + j);
180            }
181            rhs.swap(col, best_row);
182        }
183        let pivot = gram[col * ncols + col];
184        for j in 0..ncols {
185            gram[col * ncols + j] /= pivot;
186        }
187        rhs[col] /= pivot;
188        for row in 0..ncols {
189            if row == col {
190                continue;
191            }
192            let factor = gram[row * ncols + col];
193            for j in 0..ncols {
194                gram[row * ncols + j] -= factor * gram[col * ncols + j];
195            }
196            rhs[row] -= factor * rhs[col];
197        }
198    }
199    true
200}
201
202/// Relative ridge added to the Gram diagonal when the plain solve reports a singular
203/// system (exactly collinear conditioning columns). The regularized projection keeps
204/// residualization well defined — matching least-squares-based reference stacks — with
205/// an O(1e-8) relative perturbation.
206const SINGULAR_RIDGE: f64 = 1e-8;
207
208/// Solve the normal equations for `y` on `design`, retrying once with a scaled ridge
209/// when the Gram is singular. Reforms `gram`/`rhs` internally.
210fn solve_normal_equations(
211    design: &[f64],
212    y: &[f64],
213    n: usize,
214    ncols: usize,
215    gram: &mut [f64],
216    beta: &mut [f64],
217) -> bool {
218    form_gram(design, n, ncols, gram);
219    form_xty(design, y, n, ncols, beta);
220    if solve_inplace(gram, beta, ncols) {
221        return true;
222    }
223    form_gram(design, n, ncols, gram);
224    form_xty(design, y, n, ncols, beta);
225    let mut scale = 0.0_f64;
226    for d in 0..ncols {
227        scale = scale.max(gram[d * ncols + d].abs());
228    }
229    if !(scale.is_finite() && scale > 0.0) {
230        return false;
231    }
232    for d in 0..ncols {
233        gram[d * ncols + d] += SINGULAR_RIDGE * scale;
234    }
235    solve_inplace(gram, beta, ncols)
236}
237
238fn residualize_into_scalar(
239    y: &[f64],
240    z_cols: &[&[f64]],
241    design: &mut [f64],
242    gram: &mut [f64],
243    beta: &mut [f64],
244    out: &mut [f64],
245) -> bool {
246    let n = y.len();
247    let p = z_cols.len();
248    for col in z_cols {
249        if col.len() != n {
250            return false;
251        }
252    }
253    let ncols = p;
254    build_design(z_cols, n, design);
255    if !solve_normal_equations(design, y, n, ncols, gram, beta) {
256        return false;
257    }
258    let y_mean = y.iter().sum::<f64>() / n as f64;
259    for r in 0..n {
260        let mut pred = 0.0;
261        for c in 0..ncols {
262            pred += design[c * n + r] * beta[c];
263        }
264        out[r] = (y[r] - y_mean) - pred;
265    }
266    true
267}
268
269/// Scalar reference: residualize X and Y independently (correctness path).
270fn partial_correlation_scalar_impl(
271    x: &[f64],
272    y: &[f64],
273    z_cols: &[&[f64]],
274    workspace: &mut ParCorrWorkspace,
275) -> Option<f64> {
276    if x.len() != y.len() || x.len() < 3 {
277        return None;
278    }
279    let n = x.len();
280    if z_cols.is_empty() {
281        return pearson(x, y);
282    }
283    workspace.prepare(n, z_cols.len());
284    let ncols = z_cols.len();
285    let design = &mut workspace.design[..n * ncols];
286    let gram = &mut workspace.gram[..ncols * ncols];
287    let beta = &mut workspace.beta[..ncols];
288    let rx = &mut workspace.rx[..n];
289    if !residualize_into_scalar(x, z_cols, design, gram, beta, rx) {
290        return None;
291    }
292    let design = &mut workspace.design[..n * ncols];
293    let gram = &mut workspace.gram[..ncols * ncols];
294    let beta = &mut workspace.beta[..ncols];
295    let ry = &mut workspace.ry[..n];
296    if !residualize_into_scalar(y, z_cols, design, gram, beta, ry) {
297        return None;
298    }
299    // A zero-variance residual means Z explains that variable exactly: nothing is left
300    // to correlate, so conditional independence holds trivially (r = 0) rather than the
301    // statistic being an error.
302    pearson(&workspace.rx[..n], &workspace.ry[..n]).or(Some(0.0))
303}
304
305/// Portable optimized path: design built once, Gram reformed once between X/Y
306/// solves, fused Pearson on residuals (chunked contiguous loops).
307fn partial_correlation_portable_impl(
308    x: &[f64],
309    y: &[f64],
310    z_cols: &[&[f64]],
311    workspace: &mut ParCorrWorkspace,
312) -> Option<f64> {
313    if x.len() != y.len() || x.len() < 3 {
314        return None;
315    }
316    let n = x.len();
317    if z_cols.is_empty() {
318        return pearson_fused(x, y);
319    }
320    for col in z_cols {
321        if col.len() != n {
322            return None;
323        }
324    }
325    workspace.prepare(n, z_cols.len());
326    let ncols = z_cols.len();
327    {
328        let design = &mut workspace.design[..n * ncols];
329        build_design(z_cols, n, design);
330        let gram = &mut workspace.gram[..ncols * ncols];
331        let beta = &mut workspace.beta[..ncols];
332        if !solve_normal_equations(design, x, n, ncols, gram, beta) {
333            return None;
334        }
335        let rx = &mut workspace.rx[..n];
336        residual_from_beta(x, design, beta, n, ncols, rx);
337    }
338    {
339        let design = &mut workspace.design[..n * ncols];
340        let gram = &mut workspace.gram[..ncols * ncols];
341        let beta = &mut workspace.beta[..ncols];
342        if !solve_normal_equations(design, y, n, ncols, gram, beta) {
343            return None;
344        }
345        let ry = &mut workspace.ry[..n];
346        residual_from_beta(y, design, beta, n, ncols, ry);
347    }
348    // See the scalar path: zero-variance residual ⇒ trivial conditional independence.
349    pearson_fused(&workspace.rx[..n], &workspace.ry[..n]).or(Some(0.0))
350}
351
352fn residual_from_beta(
353    y: &[f64],
354    design: &[f64],
355    beta: &[f64],
356    n: usize,
357    ncols: usize,
358    out: &mut [f64],
359) {
360    let y_mean = y.iter().sum::<f64>() / n as f64;
361    for r in 0..n {
362        let mut pred = 0.0;
363        for c in 0..ncols {
364            pred += design[c * n + r] * beta[c];
365        }
366        out[r] = (y[r] - y_mean) - pred;
367    }
368}
369
370/// Fused two-pass Pearson favoring contiguous auto-vectorization.
371fn pearson_fused(x: &[f64], y: &[f64]) -> Option<f64> {
372    const CHUNK: usize = 8;
373    debug_assert_eq!(x.len(), y.len());
374    let n = x.len();
375    if n < 2 {
376        return None;
377    }
378    let nf = n as f64;
379    let (mut mx, mut my) = (0.0, 0.0);
380    let mut i = 0;
381    while i + CHUNK <= n {
382        let mut sx = 0.0;
383        let mut sy = 0.0;
384        for k in 0..CHUNK {
385            sx += x[i + k];
386            sy += y[i + k];
387        }
388        mx += sx;
389        my += sy;
390        i += CHUNK;
391    }
392    while i < n {
393        mx += x[i];
394        my += y[i];
395        i += 1;
396    }
397    mx /= nf;
398    my /= nf;
399    let (mut cxx, mut cyy, mut cxy) = (0.0, 0.0, 0.0);
400    i = 0;
401    while i + CHUNK <= n {
402        let mut sxx = 0.0;
403        let mut syy = 0.0;
404        let mut sxy = 0.0;
405        for k in 0..CHUNK {
406            let dx = x[i + k] - mx;
407            let dy = y[i + k] - my;
408            sxx += dx * dx;
409            syy += dy * dy;
410            sxy += dx * dy;
411        }
412        cxx += sxx;
413        cyy += syy;
414        cxy += sxy;
415        i += CHUNK;
416    }
417    while i < n {
418        let dx = x[i] - mx;
419        let dy = y[i] - my;
420        cxx += dx * dx;
421        cyy += dy * dy;
422        cxy += dx * dy;
423        i += 1;
424    }
425    if constant_column(cxx, mx, nf) || constant_column(cyy, my, nf) {
426        return None;
427    }
428    Some(cxy / (cxx * cyy).sqrt())
429}
430
431/// Scalar reference partial correlation.
432#[must_use]
433pub fn partial_correlation_scalar(
434    x: &[f64],
435    y: &[f64],
436    z_cols: &[&[f64]],
437    workspace: &mut ParCorrWorkspace,
438) -> Option<f64> {
439    partial_correlation_scalar_impl(x, y, z_cols, workspace)
440}
441
442/// Portable optimized partial correlation (shared Gram, fused Pearson).
443#[must_use]
444pub fn partial_correlation_portable(
445    x: &[f64],
446    y: &[f64],
447    z_cols: &[&[f64]],
448    workspace: &mut ParCorrWorkspace,
449) -> Option<f64> {
450    partial_correlation_portable_impl(x, y, z_cols, workspace)
451}
452
453/// Run a batch of [`ParCorrQuery`] items against shared columns (deterministic order).
454///
455/// Kernel path for batch partial correlation.
456#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
457pub enum ParCorrMode {
458    /// Scalar reference implementation.
459    Native,
460    /// Portable-optimized path.
461    Portable,
462}
463
464impl ParCorrMode {
465    /// Whether this selects the portable kernel.
466    #[must_use]
467    pub const fn is_portable(self) -> bool {
468        matches!(self, Self::Portable)
469    }
470}
471
472impl From<bool> for ParCorrMode {
473    fn from(portable: bool) -> Self {
474        if portable { Self::Portable } else { Self::Native }
475    }
476}
477
478/// `z_flat` holds concatenated conditioning indexes for all queries.
479///
480/// # Panics
481///
482/// Panics if `out.len() != queries.len()`.
483pub fn partial_correlation_batch(
484    columns: &[&[f64]],
485    queries: &[ParCorrQuery],
486    z_flat: &[usize],
487    out: &mut [Option<f64>],
488    workspace: &mut ParCorrWorkspace,
489    mode: ParCorrMode,
490) {
491    assert_eq!(out.len(), queries.len());
492    let mut z_bufs: Vec<&[f64]> = Vec::new();
493    for (qi, q) in queries.iter().enumerate() {
494        z_bufs.clear();
495        let end = q.z_start + q.z_len;
496        for &zi in &z_flat[q.z_start..end] {
497            z_bufs.push(columns[zi]);
498        }
499        let r = if mode.is_portable() {
500            partial_correlation_portable(columns[q.x], columns[q.y], &z_bufs, workspace)
501        } else {
502            partial_correlation_scalar(columns[q.x], columns[q.y], &z_bufs, workspace)
503        };
504        out[qi] = r;
505    }
506}
507
508#[cfg(test)]
509#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
510mod tests {
511    use antecedent_core::{KernelPolicy, ToleranceClass};
512
513    use super::*;
514    use crate::dispatch::{KernelImpl, partial_correlation, select_impl};
515
516    #[test]
517    fn pearson_perfect() {
518        let x = [1.0, 2.0, 3.0, 4.0];
519        let y = [2.0, 4.0, 6.0, 8.0];
520        assert!(ToleranceClass::StableFloat.close(pearson(&x, &y).unwrap(), 1.0));
521    }
522
523    #[test]
524    fn parcorr_removes_confounder() {
525        let n = 200usize;
526        let z: Vec<f64> = (0..n).map(|i| ((i as f64) - 99.5) / 50.0).collect();
527        let x: Vec<f64> = (0..n).map(|i| z[i] + ((i % 3) as f64 - 1.0) * 0.1).collect();
528        let y: Vec<f64> = (0..n).map(|i| z[i] + ((i % 5) as f64 - 2.0) * 0.1).collect();
529        let mut ws = ParCorrWorkspace::default();
530        let raw = pearson(&x, &y).unwrap();
531        let partial = partial_correlation_scalar(&x, &y, &[&z], &mut ws).unwrap();
532        assert!(raw > 0.9);
533        assert!(partial.abs() < 0.2, "partial={partial}");
534    }
535
536    fn one_z_intercept_oracle(x: &[f64], y: &[f64], z: &[f64]) -> f64 {
537        let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
538        let mx = mean(x);
539        let my = mean(y);
540        let mz = mean(z);
541        let z_ss = z.iter().map(|v| (v - mz).powi(2)).sum::<f64>();
542        let bx = x.iter().zip(z).map(|(a, b)| (a - mx) * (b - mz)).sum::<f64>() / z_ss;
543        let by = y.iter().zip(z).map(|(a, b)| (a - my) * (b - mz)).sum::<f64>() / z_ss;
544        let rx: Vec<_> = x.iter().zip(z).map(|(a, b)| (a - mx) - bx * (b - mz)).collect();
545        let ry: Vec<_> = y.iter().zip(z).map(|(a, b)| (a - my) - by * (b - mz)).collect();
546        pearson(&rx, &ry).unwrap()
547    }
548
549    #[test]
550    fn parcorr_is_translation_invariant_and_matches_intercept_oracle() {
551        let x = [
552            -0.427_007, -1.147_988, 1.561_491, -1.892_812, 1.154_907, 0.455_615, -1.952_965,
553            0.823_206,
554        ];
555        let y = [
556            -2.391_322, -2.619_455, -1.515_701, -3.279_834, -1.518_526, -2.738_805, -2.950_828,
557            -2.147_493,
558        ];
559        let z = [
560            -0.704_669, -1.396_603, 0.603_738, -1.710_255, 0.143_528, -0.537_244, -1.768_004,
561            0.029_743,
562        ];
563        let z_shifted: Vec<_> = z.iter().map(|v| v + 100.0).collect();
564        let oracle = one_z_intercept_oracle(&x, &y, &z);
565        let mut ws_scalar = ParCorrWorkspace::default();
566        let mut ws_portable = ParCorrWorkspace::default();
567        let scalar = partial_correlation_scalar(&x, &y, &[&z], &mut ws_scalar).unwrap();
568        let translated = partial_correlation_scalar(&x, &y, &[&z_shifted], &mut ws_scalar).unwrap();
569        let portable =
570            partial_correlation_portable(&x, &y, &[&z_shifted], &mut ws_portable).unwrap();
571        assert!((scalar - translated).abs() <= 1e-12, "{scalar} vs {translated}");
572        assert!((scalar - oracle).abs() <= 1e-12, "{scalar} vs {oracle}");
573        assert!((portable - oracle).abs() <= 1e-12, "{portable} vs {oracle}");
574    }
575
576    #[test]
577    fn parcorr_batch_is_translation_invariant() {
578        let n = 100usize;
579        let z: Vec<_> = (0..n).map(|i| (i as f64 * 0.17).sin() + 3.0).collect();
580        let z_shifted: Vec<_> = z.iter().map(|v| v - 10_000.0).collect();
581        let x: Vec<_> = (0..n).map(|i| 1.5 * z[i] + (i as f64 * 0.31).cos()).collect();
582        let y: Vec<_> = (0..n).map(|i| -0.7 * z[i] + (i as f64 * 0.23).sin()).collect();
583        let queries = [ParCorrQuery { x: 0, y: 1, z_start: 0, z_len: 1 }];
584        let z_flat = [2usize];
585        let mut base = [None];
586        let mut shifted = [None];
587        let mut ws = ParCorrWorkspace::default();
588        partial_correlation_batch(
589            &[&x, &y, &z],
590            &queries,
591            &z_flat,
592            &mut base,
593            &mut ws,
594            ParCorrMode::Native,
595        );
596        partial_correlation_batch(
597            &[&x, &y, &z_shifted],
598            &queries,
599            &z_flat,
600            &mut shifted,
601            &mut ws,
602            ParCorrMode::Portable,
603        );
604        assert!((base[0].unwrap() - shifted[0].unwrap()).abs() <= 1e-10);
605    }
606
607    #[test]
608    fn parcorr_is_invariant_across_seeded_random_offsets() {
609        let n = 96usize;
610        let z: Vec<_> = (0..n).map(|i| (i as f64 * 0.19).sin() + 0.01 * i as f64).collect();
611        let x: Vec<_> = (0..n).map(|i| 0.8 * z[i] + (i as f64 * 0.37).cos() - 2.0).collect();
612        let y: Vec<_> = (0..n).map(|i| -1.2 * z[i] + (i as f64 * 0.29).sin() + 4.0).collect();
613        let mut ws = ParCorrWorkspace::default();
614        let reference = partial_correlation_scalar(&x, &y, &[&z], &mut ws).unwrap();
615        let mut state = 0x5eed_cafe_f00d_beefu64;
616        for _ in 0..100 {
617            state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
618            let unit = (state >> 11) as f64 / (1u64 << 53) as f64;
619            let offset = -10_000.0 + 20_000.0 * unit;
620            let shifted: Vec<_> = z.iter().map(|value| value + offset).collect();
621            let scalar = partial_correlation_scalar(&x, &y, &[&shifted], &mut ws).unwrap();
622            let portable = partial_correlation_portable(&x, &y, &[&shifted], &mut ws).unwrap();
623            assert!((scalar - reference).abs() <= 1e-10, "offset={offset}");
624            assert!((portable - reference).abs() <= 1e-10, "offset={offset}");
625        }
626    }
627
628    #[test]
629    fn scalar_portable_differential() {
630        let n = 128usize;
631        let z: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
632        let x: Vec<f64> = (0..n).map(|i| z[i] + 0.1 * (i as f64)).collect();
633        let y: Vec<f64> = (0..n).map(|i| 2.0 * z[i] + 0.05 * (i as f64)).collect();
634        let mut ws_s = ParCorrWorkspace::default();
635        let mut ws_p = ParCorrWorkspace::default();
636        let s = partial_correlation_scalar(&x, &y, &[&z], &mut ws_s).unwrap();
637        let p = partial_correlation_portable(&x, &y, &[&z], &mut ws_p).unwrap();
638        assert!(ToleranceClass::StableFloat.close(s, p));
639    }
640
641    #[test]
642    fn batch_reuses_workspace() {
643        let n = 64usize;
644        let c0: Vec<f64> = (0..n).map(|i| i as f64).collect();
645        let c1: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5).collect();
646        let c2: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
647        let columns: [&[f64]; 3] = [&c0, &c1, &c2];
648        let queries = [
649            ParCorrQuery { x: 0, y: 1, z_start: 0, z_len: 1 },
650            ParCorrQuery { x: 0, y: 2, z_start: 1, z_len: 0 },
651        ];
652        let z_flat = [2usize];
653        let mut out = [None; 2];
654        let mut ws = ParCorrWorkspace::default();
655        partial_correlation_batch(
656            &columns,
657            &queries,
658            &z_flat,
659            &mut out,
660            &mut ws,
661            ParCorrMode::Native,
662        );
663        let cap_n = ws.capacity_n();
664        let cap_p = ws.capacity_p();
665        for _ in 0..20 {
666            partial_correlation_batch(
667                &columns,
668                &queries,
669                &z_flat,
670                &mut out,
671                &mut ws,
672                ParCorrMode::Portable,
673            );
674            assert_eq!(ws.capacity_n(), cap_n);
675            assert_eq!(ws.capacity_p(), cap_p);
676        }
677        assert!(out[0].is_some());
678    }
679
680    #[test]
681    fn dispatch_force_scalar() {
682        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
683        let y = [2.0, 3.0, 4.0, 5.0, 6.0];
684        let mut ws = ParCorrWorkspace::default();
685        let policy = KernelPolicy::scalar_only();
686        assert_eq!(select_impl(&policy), KernelImpl::Scalar);
687        let r = partial_correlation(&policy, &x, &y, &[], &mut ws).unwrap();
688        assert!(ToleranceClass::StableFloat.close(r, 1.0));
689    }
690}