Skip to main content

datarust_profile/profile/
relationships.rs

1//! Pairwise relationship statistics: Pearson correlation, Cramér's V, and point-biserial correlation.
2
3use crate::infer;
4use std::collections::HashMap;
5
6/// A square symmetric correlation or association matrix between named variables.
7#[derive(Debug, Clone, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize))]
9pub struct CorrelationMatrix {
10    /// Variable names corresponding to rows and columns.
11    pub labels: Vec<String>,
12    /// Symmetric `p × p` matrix of values.
13    pub values: Vec<Vec<f64>>,
14}
15
16/// A point-biserial correlation entry between a binary categorical column and a numeric column.
17#[derive(Debug, Clone, PartialEq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize))]
19pub struct PointBiserialEntry {
20    /// Name of the binary categorical column.
21    pub categorical: String,
22    /// Name of the continuous numeric column.
23    pub numeric: String,
24    /// Point-biserial correlation coefficient `r` in `[-1.0, 1.0]`.
25    pub correlation: f64,
26}
27
28/// Container for all pairwise column relationships in a dataset.
29#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize))]
31pub struct Relationships {
32    /// Pearson correlation matrix over numeric columns, if there are 2 or more numeric columns.
33    pub pearson: Option<CorrelationMatrix>,
34    /// Cramér's V matrix over categorical columns, if there are 2 or more categorical columns.
35    pub cramers_v: Option<CorrelationMatrix>,
36    /// Point-biserial correlations between binary categorical columns and numeric columns.
37    pub point_biserial: Vec<PointBiserialEntry>,
38}
39
40impl Relationships {
41    /// Computes relationships across numeric columns and string/categorical columns.
42    pub fn compute<T: AsRef<str>>(
43        numeric_cols: &[(&str, &[f64])],
44        categorical_cols: &[(&str, &[T])],
45    ) -> Option<Self> {
46        let pearson = compute_pearson(numeric_cols);
47        let cramers_v = compute_cramers_v(categorical_cols);
48        let point_biserial = compute_point_biserial(numeric_cols, categorical_cols);
49
50        if pearson.is_none() && cramers_v.is_none() && point_biserial.is_empty() {
51            None
52        } else {
53            Some(Relationships {
54                pearson,
55                cramers_v,
56                point_biserial,
57            })
58        }
59    }
60}
61
62/// Computes Pearson correlation matrix for numeric columns using `datarust::stats::correlation_matrix`.
63fn compute_pearson(cols: &[(&str, &[f64])]) -> Option<CorrelationMatrix> {
64    if cols.len() < 2 {
65        return None;
66    }
67    let n_rows = cols[0].1.len();
68    if n_rows == 0 {
69        return None;
70    }
71
72    // Build row-oriented data matrix for datarust::stats::correlation_matrix.
73    // NOTE: Switch to `correlation_matrix_flat` (flat row-major buffer) after
74    // datarust 0.6.6 is published — it streams contiguous memory and is
75    // significantly faster on wide tables.
76    let mut rows_data: Vec<Vec<f64>> = vec![vec![0.0; cols.len()]; n_rows];
77    for (j, (_, values)) in cols.iter().enumerate() {
78        if values.len() != n_rows {
79            return None;
80        }
81        for i in 0..n_rows {
82            rows_data[i][j] = values[i];
83        }
84    }
85
86    let labels: Vec<String> = cols.iter().map(|(name, _)| name.to_string()).collect();
87    let values = datarust::stats::correlation_matrix(&rows_data);
88
89    Some(CorrelationMatrix { labels, values })
90}
91
92/// Encodes a categorical column into compact integer level codes so that
93/// pairwise Cramér's V can be computed with `usize` keys instead of hashing
94/// string tuples for every row of every column pair.
95struct CodedColumn<'a> {
96    /// Distinct non-missing trimmed levels, in first-seen order.
97    levels: Vec<&'a str>,
98    /// Per-row level code; `usize::MAX` marks a missing/empty cell.
99    codes: Vec<usize>,
100}
101
102/// Builds a [`CodedColumn`] from raw string cells in one pass.
103fn encode_column<'a, T: AsRef<str> + 'a>(cells: &'a [T]) -> CodedColumn<'a> {
104    let mut map: HashMap<&'a str, usize> = HashMap::new();
105    let mut levels: Vec<&'a str> = Vec::new();
106    let mut codes = Vec::with_capacity(cells.len());
107    for cell in cells {
108        let cell = cell.as_ref();
109        if infer::is_missing(cell) {
110            codes.push(usize::MAX);
111            continue;
112        }
113        let trimmed = cell.trim();
114        let next = levels.len();
115        let code = *map.entry(trimmed).or_insert_with(|| {
116            levels.push(trimmed);
117            next
118        });
119        codes.push(code);
120    }
121    CodedColumn { levels, codes }
122}
123
124/// Computes Cramér's V matrix for categorical columns.
125fn compute_cramers_v<T: AsRef<str>>(cols: &[(&str, &[T])]) -> Option<CorrelationMatrix> {
126    if cols.len() < 2 {
127        return None;
128    }
129    let n_rows = cols[0].1.len();
130    if n_rows == 0 {
131        return None;
132    }
133
134    let encoded: Vec<CodedColumn> = cols
135        .iter()
136        .map(|(_, values)| encode_column(values))
137        .collect();
138
139    let p = cols.len();
140    let labels: Vec<String> = cols.iter().map(|(name, _)| name.to_string()).collect();
141    let mut values = vec![vec![0.0; p]; p];
142
143    for i in 0..p {
144        values[i][i] = 1.0;
145        for j in (i + 1)..p {
146            let v = cramers_v_from_codes(&encoded[i], &encoded[j]);
147            values[i][j] = v;
148            values[j][i] = v;
149        }
150    }
151
152    Some(CorrelationMatrix { labels, values })
153}
154
155/// Computes Cramér's V between two pre-encoded columns.
156///
157/// Only rows where both columns are present contribute. The chi-squared
158/// statistic is computed with the `Σ O²/E − N` identity, which sums over every
159/// cell of the contingency table without materialising the empty ones. Small
160/// tables (typical for categorical profiling) use a flat array; large tables
161/// fall back to a hash map keyed by `usize`.
162fn cramers_v_from_codes(a: &CodedColumn, b: &CodedColumn) -> f64 {
163    let n = a.codes.len().min(b.codes.len());
164    let a_r = a.levels.len();
165    let a_c = b.levels.len();
166    let mut total = 0usize;
167    let mut row_totals = vec![0usize; a_r];
168    let mut col_totals = vec![0usize; a_c];
169
170    let flat_limit = 1 << 16;
171    let mut flat: Option<Vec<usize>> = if a_r * a_c <= flat_limit {
172        Some(vec![0; a_r * a_c])
173    } else {
174        None
175    };
176    let mut counts: HashMap<usize, usize> = HashMap::new();
177
178    for i in 0..n {
179        let ri = a.codes[i];
180        let ci = b.codes[i];
181        if ri == usize::MAX || ci == usize::MAX {
182            continue;
183        }
184        row_totals[ri] += 1;
185        col_totals[ci] += 1;
186        let key = ri * a_c + ci;
187        match flat.as_mut() {
188            Some(t) => t[key] += 1,
189            None => *counts.entry(key).or_insert(0) += 1,
190        }
191        total += 1;
192    }
193
194    if total == 0 {
195        return 0.0;
196    }
197    // Distinct levels restricted to rows where both columns are present.
198    let r = row_totals.iter().filter(|&&t| t > 0).count();
199    let c = col_totals.iter().filter(|&&t| t > 0).count();
200    if r <= 1 || c <= 1 {
201        return 0.0;
202    }
203
204    let n_f = total as f64;
205    let mut chi2 = 0.0;
206    match flat {
207        Some(t) => {
208            for (key, &obs) in t.iter().enumerate() {
209                if obs == 0 {
210                    continue;
211                }
212                let ri = key / a_c;
213                let ci = key % a_c;
214                let expected = row_totals[ri] as f64 * col_totals[ci] as f64 / n_f;
215                if expected > 0.0 {
216                    let obs_f = obs as f64;
217                    chi2 += obs_f * obs_f / expected;
218                }
219            }
220        }
221        None => {
222            for (&key, &obs) in &counts {
223                let ri = key / a_c;
224                let ci = key % a_c;
225                let expected = row_totals[ri] as f64 * col_totals[ci] as f64 / n_f;
226                if expected > 0.0 {
227                    let obs_f = obs as f64;
228                    chi2 += obs_f * obs_f / expected;
229                }
230            }
231        }
232    }
233    chi2 -= n_f;
234
235    let min_dim = (r - 1).min(c - 1) as f64;
236    if min_dim == 0.0 {
237        0.0
238    } else {
239        let v = (chi2 / (n_f * min_dim)).sqrt();
240        if v.is_nan() {
241            0.0
242        } else {
243            v.min(1.0)
244        }
245    }
246}
247
248/// Computes point-biserial correlation between binary categorical columns and numeric columns.
249fn compute_point_biserial<T: AsRef<str>>(
250    numeric_cols: &[(&str, &[f64])],
251    categorical_cols: &[(&str, &[T])],
252) -> Vec<PointBiserialEntry> {
253    let mut entries = Vec::new();
254
255    for (cat_name, cat_values) in categorical_cols {
256        // Collect unique non-missing values in categorical column
257        let mut unique_vals: Vec<&str> = Vec::new();
258        for val in *cat_values {
259            let val = val.as_ref();
260            if infer::is_missing(val) {
261                continue;
262            }
263            let trimmed = val.trim();
264            if !unique_vals.contains(&trimmed) {
265                unique_vals.push(trimmed);
266            }
267            if unique_vals.len() > 2 {
268                break;
269            }
270        }
271
272        // Must be exactly binary
273        if unique_vals.len() != 2 {
274            continue;
275        }
276
277        let val_0 = unique_vals[0];
278
279        // Map cat values to 0.0 and 1.0 (or NaN if missing)
280        let binary_encoded: Vec<f64> = cat_values
281            .iter()
282            .map(|cell| {
283                let cell = cell.as_ref();
284                if infer::is_missing(cell) {
285                    f64::NAN
286                } else if cell.trim() == val_0 {
287                    0.0
288                } else {
289                    1.0
290                }
291            })
292            .collect();
293
294        for (num_name, num_values) in numeric_cols {
295            if let Some(r) = calculate_point_biserial(&binary_encoded, num_values) {
296                entries.push(PointBiserialEntry {
297                    categorical: cat_name.to_string(),
298                    numeric: num_name.to_string(),
299                    correlation: r,
300                });
301            }
302        }
303    }
304
305    entries
306}
307
308/// Calculates point-biserial correlation between a 0/1 indicator vector and a numeric vector.
309///
310/// Computed in a single allocation-free pass: counts and sums per group are
311/// accumulated alongside the overall sum-of-squares via the identity
312/// `SS = Σy² − (Σy)² / N`. The previous version materialised `group_0`,
313/// `group_1` and `all_y` `Vec`s for every (categorical, numeric) pair, which
314/// dominated the profile cost on wide tables with many binary columns.
315fn calculate_point_biserial(binary: &[f64], numeric: &[f64]) -> Option<f64> {
316    let n = binary.len().min(numeric.len());
317    if n == 0 {
318        return None;
319    }
320
321    // Single pass: count, sum, and sum-of-squares per group, filtering pairs
322    // where either value is missing or the indicator is neither 0 nor 1.
323    let (mut n0, mut n1, mut sum0, mut sum1) = (0usize, 0usize, 0.0, 0.0);
324    let mut sum_sq = 0.0;
325    for i in 0..n {
326        let b = binary[i];
327        let y = numeric[i];
328        if b.is_finite() && y.is_finite() {
329            if b == 0.0 {
330                n0 += 1;
331                sum0 += y;
332                sum_sq += y * y;
333            } else if b == 1.0 {
334                n1 += 1;
335                sum1 += y;
336                sum_sq += y * y;
337            }
338        }
339    }
340
341    let total_n = n0 + n1;
342    if n0 == 0 || n1 == 0 || total_n < 3 {
343        return None;
344    }
345
346    let m0 = sum0 / n0 as f64;
347    let m1 = sum1 / n1 as f64;
348    let total_sum = sum0 + sum1;
349
350    // Pooled sample variance via the computational identity: SS = Σy² − (Σy)² / N.
351    let ss = sum_sq - (total_sum * total_sum) / total_n as f64;
352    let s_y = (ss / (total_n - 1) as f64).sqrt();
353
354    if s_y == 0.0 {
355        return Some(0.0);
356    }
357
358    let r = ((m1 - m0) / s_y) * ((n0 * n1) as f64 / (total_n * (total_n - 1)) as f64).sqrt();
359
360    if r.is_nan() {
361        None
362    } else {
363        Some(r.clamp(-1.0, 1.0))
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn compute_pearson_returns_none_for_less_than_two_cols() {
373        let cols = vec![("a", &[1.0, 2.0][..])];
374        assert!(compute_pearson(&cols).is_none());
375    }
376
377    #[test]
378    fn compute_pearson_returns_none_for_empty() {
379        let cols = vec![("a", &[][..]), ("b", &[][..])];
380        assert!(compute_pearson(&cols).is_none());
381    }
382
383    #[test]
384    fn compute_pearson_perfect_correlation() {
385        let cols = vec![("x", &[1.0, 2.0, 3.0][..]), ("y", &[2.0, 4.0, 6.0][..])];
386        let mat = compute_pearson(&cols).unwrap();
387        assert_eq!(mat.labels, vec!["x", "y"]);
388        assert!((mat.values[0][1] - 1.0).abs() < 1e-6);
389        assert!((mat.values[1][0] - 1.0).abs() < 1e-6);
390        assert!((mat.values[0][0] - 1.0).abs() < 1e-6);
391        assert!((mat.values[1][1] - 1.0).abs() < 1e-6);
392    }
393
394    #[test]
395    fn compute_pearson_negative_correlation() {
396        let cols = vec![("x", &[1.0, 2.0, 3.0][..]), ("y", &[3.0, 2.0, 1.0][..])];
397        let mat = compute_pearson(&cols).unwrap();
398        assert!((mat.values[0][1] + 1.0).abs() < 1e-6);
399    }
400
401    #[test]
402    fn compute_pearson_uncorrelated() {
403        let cols = vec![("x", &[1.0, 2.0, 3.0][..]), ("y", &[2.0, 1.0, 3.0][..])];
404        let mat = compute_pearson(&cols).unwrap();
405        assert!(mat.values[0][1].abs() < 1.0);
406    }
407
408    #[test]
409    fn compute_cramers_v_returns_none_for_less_than_two_cols() {
410        let a = ["x".to_string()];
411        let cols = vec![("a", &a[..])];
412        assert!(compute_cramers_v(&cols).is_none());
413    }
414
415    #[test]
416    fn compute_cramers_v_perfect_association() {
417        let a = [
418            "x".to_string(),
419            "x".to_string(),
420            "y".to_string(),
421            "y".to_string(),
422        ];
423        let b = [
424            "p".to_string(),
425            "p".to_string(),
426            "q".to_string(),
427            "q".to_string(),
428        ];
429        let cols = vec![("a", &a[..]), ("b", &b[..])];
430        let mat = compute_cramers_v(&cols).unwrap();
431        assert_eq!(mat.labels, vec!["a", "b"]);
432        assert!((mat.values[0][1] - 1.0).abs() < 1e-6);
433    }
434
435    #[test]
436    fn compute_cramers_v_with_missing() {
437        let a = ["x".to_string(), "NA".to_string(), "y".to_string()];
438        let b = ["p".to_string(), "q".to_string(), "NA".to_string()];
439        let cols = vec![("a", &a[..]), ("b", &b[..])];
440        let mat = compute_cramers_v(&cols).unwrap();
441        // Should still compute with valid pairs
442        assert!(mat.values[0][1] >= 0.0);
443    }
444
445    #[test]
446    fn compute_cramers_v_single_level() {
447        let a = ["x".to_string(), "x".to_string(), "x".to_string()];
448        let b = ["p".to_string(), "q".to_string(), "r".to_string()];
449        let cols = vec![("a", &a[..]), ("b", &b[..])];
450        let mat = compute_cramers_v(&cols).unwrap();
451        // One column has only one level -> V = 0
452        assert_eq!(mat.values[0][1], 0.0);
453    }
454
455    #[test]
456    fn compute_point_biserial_binary_categorical() {
457        let numeric = vec![("num", &[1.0, 1.0, 5.0, 5.0][..])];
458        let cat = [
459            "low".to_string(),
460            "low".to_string(),
461            "high".to_string(),
462            "high".to_string(),
463        ];
464        let categorical = vec![("cat", &cat[..])];
465        let entries = compute_point_biserial(&numeric, &categorical);
466        assert_eq!(entries.len(), 1);
467        assert_eq!(entries[0].categorical, "cat");
468        assert_eq!(entries[0].numeric, "num");
469        assert!((entries[0].correlation.abs() - 1.0).abs() < 1e-6);
470    }
471
472    #[test]
473    fn compute_point_biserial_non_binary_skipped() {
474        let numeric = vec![("num", &[1.0, 2.0, 3.0][..])];
475        let cat = ["a".to_string(), "b".to_string(), "c".to_string()];
476        let categorical = vec![("cat", &cat[..])];
477        let entries = compute_point_biserial(&numeric, &categorical);
478        assert_eq!(entries.len(), 0);
479    }
480
481    #[test]
482    fn compute_point_biserial_with_missing() {
483        let numeric = vec![("num", &[1.0, f64::NAN, 5.0, 5.0][..])];
484        let cat = [
485            "low".to_string(),
486            "low".to_string(),
487            "high".to_string(),
488            "high".to_string(),
489        ];
490        let categorical = vec![("cat", &cat[..])];
491        let entries = compute_point_biserial(&numeric, &categorical);
492        // Should still work, missing in numeric is filtered
493        assert_eq!(entries.len(), 1);
494    }
495
496    #[test]
497    fn relationships_compute_all() {
498        let numeric = vec![
499            ("x", &[1.0, 2.0, 3.0, 4.0][..]),
500            ("y", &[2.0, 4.0, 6.0, 8.0][..]),
501        ];
502        let cat = [
503            "p".to_string(),
504            "p".to_string(),
505            "q".to_string(),
506            "q".to_string(),
507        ];
508        let categorical = vec![("a", &cat[..])];
509        let rels = Relationships::compute(&numeric, &categorical).unwrap();
510        assert!(rels.pearson.is_some());
511        assert!(rels.cramers_v.is_none()); // only one categorical
512        assert!(!rels.point_biserial.is_empty());
513    }
514
515    #[test]
516    fn relationships_compute_only_categorical() {
517        let numeric = vec![];
518        let a = [
519            "p".to_string(),
520            "p".to_string(),
521            "q".to_string(),
522            "q".to_string(),
523        ];
524        let b = [
525            "x".to_string(),
526            "x".to_string(),
527            "y".to_string(),
528            "y".to_string(),
529        ];
530        let categorical = vec![("a", &a[..]), ("b", &b[..])];
531        let rels = Relationships::compute(&numeric, &categorical).unwrap();
532        assert!(rels.pearson.is_none());
533        assert!(rels.cramers_v.is_some());
534    }
535
536    /// Reference: the previous two-pass implementation of `calculate_point_biserial`.
537    /// Used only in tests to verify that the single-pass rewrite (via the
538    /// computational identity `SS = Σy² − (Σy)²/N`) produces bit-identical results.
539    fn calculate_point_biserial_two_pass(binary: &[f64], numeric: &[f64]) -> Option<f64> {
540        let n = binary.len().min(numeric.len());
541        if n == 0 {
542            return None;
543        }
544
545        // Pass 1: counts and sums.
546        let (mut n0, mut n1, mut sum0, mut sum1) = (0usize, 0usize, 0.0, 0.0);
547        for i in 0..n {
548            let b = binary[i];
549            let y = numeric[i];
550            if b.is_finite() && y.is_finite() {
551                if b == 0.0 {
552                    n0 += 1;
553                    sum0 += y;
554                } else if b == 1.0 {
555                    n1 += 1;
556                    sum1 += y;
557                }
558            }
559        }
560
561        let total_n = n0 + n1;
562        if n0 == 0 || n1 == 0 || total_n < 3 {
563            return None;
564        }
565
566        let m0 = sum0 / n0 as f64;
567        let m1 = sum1 / n1 as f64;
568        let m_all = (sum0 + sum1) / total_n as f64;
569
570        // Pass 2: pooled sample variance (ddof = 1) via Σ(y − mean)².
571        let mut ss = 0.0;
572        for i in 0..n {
573            let b = binary[i];
574            let y = numeric[i];
575            if b.is_finite() && y.is_finite() && (b == 0.0 || b == 1.0) {
576                let d = y - m_all;
577                ss += d * d;
578            }
579        }
580        let s_y = (ss / (total_n - 1) as f64).sqrt();
581
582        if s_y == 0.0 {
583            return Some(0.0);
584        }
585
586        let r = ((m1 - m0) / s_y) * ((n0 * n1) as f64 / (total_n * (total_n - 1)) as f64).sqrt();
587
588        if r.is_nan() {
589            None
590        } else {
591            Some(r.clamp(-1.0, 1.0))
592        }
593    }
594
595    #[test]
596    fn point_biserial_single_pass_matches_two_pass() {
597        // Deterministic pseudo-random data with various edge cases:
598        // NaN, inf, non-binary indicators, short inputs, uniform values.
599        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
600        let mut next = || -> f64 {
601            state ^= state >> 12;
602            state ^= state << 25;
603            state ^= state >> 27;
604            (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 * 2.0
605                - 1.0
606        };
607
608        let cases: Vec<(Vec<f64>, Vec<f64>)> = (0..50)
609            .map(|_| {
610                let len = 20 + (next().abs() * 980.0) as usize; // 20..1000
611                let binary: Vec<f64> = (0..len)
612                    .map(|i| {
613                        let r = next();
614                        if i % 7 == 0 {
615                            f64::NAN
616                        } else if i % 13 == 0 {
617                            f64::INFINITY
618                        } else if r > 0.3 {
619                            1.0
620                        } else if r < -0.3 {
621                            0.0
622                        } else {
623                            2.0 // non-binary → filtered
624                        }
625                    })
626                    .collect();
627                let numeric: Vec<f64> = (0..len).map(|_| next() * 100.0).collect();
628                (binary, numeric)
629            })
630            .collect();
631
632        for (i, (binary, numeric)) in cases.iter().enumerate() {
633            let got = calculate_point_biserial(binary, numeric);
634            let want = calculate_point_biserial_two_pass(binary, numeric);
635            match (got, want) {
636                (Some(g), Some(w)) => {
637                    // Allow up to 1 ULP difference from floating-point
638                    // accumulation order (two-pass sums deviations from the
639                    // mean; single-pass uses the computational identity).
640                    assert!(
641                        (g - w).abs() < 1e-14
642                            || (g.to_bits() as i64 - w.to_bits() as i64).unsigned_abs() <= 1,
643                        "case {i}: single-pass {g} != two-pass {w}"
644                    );
645                }
646                (None, None) => {}
647                (g, w) => {
648                    panic!("case {i}: mismatch: single-pass={g:?}, two-pass={w:?}");
649                }
650            }
651        }
652    }
653
654    #[test]
655    fn point_biserial_single_pass_matches_two_pass_known_values() {
656        // Hand-picked cases where the result is known analytically.
657        let cases: &[(&[f64], &[f64], Option<f64>)] = &[
658            // Identical values → r = 0.
659            (
660                &[0.0, 0.0, 1.0, 1.0, 1.0],
661                &[5.0, 5.0, 5.0, 5.0, 5.0],
662                Some(0.0),
663            ),
664            // Too few samples → None.
665            (&[0.0, 1.0], &[1.0, 2.0], None),
666            // Only one group → None.
667            (&[0.0, 0.0, 0.0], &[1.0, 2.0, 3.0], None),
668            // NaN in binary → all group-1 rows filtered → only group 0 remains.
669            (&[0.0, f64::NAN, 0.0], &[1.0, 2.0, 3.0], None),
670            // Inf in numeric → all group-0 rows filtered → only group 1 remains.
671            (&[0.0, 1.0, 1.0], &[f64::INFINITY, 10.0, 11.0], None),
672            // Hand-computed: n0=2, n1=2, total_n=4
673            // group 0: {0, 10} → m0=5;  group 1: {20, 30} → m1=25
674            // m_all = 15;  SS = (0-15)²+(10-15)²+(20-15)²+(30-15)² = 500
675            // s_y = sqrt(500/3) ≈ 12.9099
676            // r = ((25-5)/12.9099) * sqrt(4/(4*3)) = 1.5492 * 0.5774 ≈ 0.8944
677            (
678                &[0.0, 0.0, 1.0, 1.0],
679                &[0.0, 10.0, 20.0, 30.0],
680                Some(0.8944271909999159),
681            ),
682        ];
683
684        for (i, (binary, numeric, expected)) in cases.iter().enumerate() {
685            let got_single = calculate_point_biserial(binary, numeric);
686            let got_two = calculate_point_biserial_two_pass(binary, numeric);
687            assert_eq!(got_single, got_two, "case {i}: methods disagree");
688            if let Some(exp) = expected {
689                let r = got_single.unwrap();
690                assert_eq!(
691                    r.to_bits(),
692                    exp.to_bits(),
693                    "case {i}: got {r}, expected {exp}"
694                );
695            } else {
696                assert!(
697                    got_single.is_none(),
698                    "case {i}: expected None, got {got_single:?}"
699                );
700            }
701        }
702    }
703}