Skip to main content

datarust_profile/
streaming.rs

1//! Streaming single-pass CSV profiling (the `streaming` feature).
2//!
3//! Profiles a CSV file in one forward pass with bounded memory: per-column
4//! Welford accumulators and raw central moments for exact mean/variance/
5//! skewness/kurtosis, reservoir-sampled quantiles + histogram, online Pearson
6//! covariances, hashed Cramér's V contingency tables, and point-biserial
7//! group sums — reusing the crate's interpretation helpers (skewness,
8//! kurtosis, histogram, IQR outlier fences) and quality checks unchanged.
9//!
10//! Fidelity contract:
11//! - **Exact**: row/cell counts, missing counts, mean, variance/std, min/max,
12//!   categorical frequencies/imbalance, and duplicate rows (when
13//!   [`StreamingOptions::compute_duplicates`] is enabled).
14//! - **Pairwise-complete Pearson**: correlations use every row where both
15//!   columns hold finite values. The in-memory path instead propagates `NaN`
16//!   (any missing cell on either side yields `NaN` for that pair), so values
17//!   differ on data with missing numeric cells; the streaming definition is
18//!   the more statistically useful of the two.
19//! - **Approximate**: quantiles and histogram (reservoir sampling with
20//!   capacity `StreamingOptions::reservoir_capacity` — *exact* whenever the
21//!   table has at most that many rows), IQR outlier counts/fractions
22//!   (derived from the same reservoir sample, so the count refers to the
23//!   sampled values, not the full column), skewness/kurtosis (single-pass
24//!   raw-moment accumulation; last-ULP differences vs the two-pass path are
25//!   possible on large inputs), and `unique` on categorical columns whose
26//!   distinct count exceeds `StreamingOptions::distinct_cap` (a documented
27//!   lower bound).
28//! - Column types are decided **online**: a numeric column permanently
29//!   converts to categorical at its first non-parseable non-missing cell
30//!   (parseable-but-non-finite cells such as `inf` stay numeric and are
31//!   skipped like missing values, matching the in-memory path).
32//!
33//! Memory is bounded **per row**, not globally: the accumulators are O(rows
34//! × columns × constant), but Cramér's V tables hold up to
35//! `min(rows, distinct_cap²)` entries per column pair, opt-in duplicate
36//! tracking stores one key per unique row, and a single physical line or
37//! field is buffered at its natural size.
38
39use std::collections::{HashMap, HashSet};
40use std::io::{BufRead, BufReader};
41use std::path::Path;
42
43use crate::error::{ProfileError, Result};
44
45/// Co-occurrence count table for one categorical column pair, keyed by the
46/// pair's interned level codes.
47type CramTable = HashMap<(u32, u32), usize>;
48/// Map from column-index pair to its co-occurrence table.
49type CramTables = HashMap<(usize, usize), CramTable>;
50use crate::profile::column::{CategoricalStats, ColumnProfile, FiveNumber, NumericStats};
51use crate::profile::dataset::DatasetProfile;
52use crate::profile::relationships::{CorrelationMatrix, PointBiserialEntry, Relationships};
53use crate::types::ColumnType;
54
55/// Options for [`profile_csv_streaming_with`].
56#[derive(Debug, Clone)]
57pub struct StreamingOptions {
58    /// Reservoir size for quantile/histogram sampling per column
59    /// (default `10_000`). Tables with at most this many rows are profiled
60    /// exactly.
61    pub reservoir_capacity: usize,
62    /// Maximum tracked distinct values per categorical column
63    /// (default `100_000`). Beyond this, `unique` becomes a documented lower
64    /// bound.
65    pub distinct_cap: usize,
66    /// Track exact duplicate rows (default `false`): costs one hash-set
67    /// entry per row.
68    pub compute_duplicates: bool,
69    /// Seed for reservoir sampling (default `0x9E37_79B9_7F4A_7C15`).
70    pub seed: u64,
71}
72
73impl Default for StreamingOptions {
74    fn default() -> Self {
75        StreamingOptions {
76            reservoir_capacity: 10_000,
77            distinct_cap: 100_000,
78            compute_duplicates: false,
79            seed: 0x9E37_79B9_7F4A_7C15,
80        }
81    }
82}
83
84/// Profiles a CSV file in a single streaming pass with
85/// [`StreamingOptions::default`].
86pub fn profile_csv_streaming(path: &Path) -> Result<DatasetProfile> {
87    profile_csv_streaming_with(path, &StreamingOptions::default())
88}
89
90/// Profiles a CSV file in a single streaming pass with explicit options.
91pub fn profile_csv_streaming_with(path: &Path, opts: &StreamingOptions) -> Result<DatasetProfile> {
92    let file = std::fs::File::open(path)?;
93    stream_from_reader(BufReader::new(file), opts)
94}
95
96/// Core streaming implementation over any [`BufRead`].
97pub(crate) fn stream_from_reader<R: BufRead>(
98    mut reader: R,
99    opts: &StreamingOptions,
100) -> Result<DatasetProfile> {
101    let read_opts = crate::csv::CsvReadOptions::default();
102    let mut parser = crate::csv::RecordParser::new(&read_opts);
103    let mut records: Vec<(Vec<String>, usize)> = Vec::new();
104
105    // Numeric per-column accumulators.
106    let mut welford: Vec<Welford> = Vec::new();
107    // Categorical per-column counts (columns flip numeric → categorical
108    // permanently at their first non-parseable cell).
109    let mut cat_counts: Vec<Option<CatCounts>> = Vec::new();
110    let mut numeric_active: Vec<bool> = Vec::new();
111    // Pearson cross-moment accumulators keyed by column pair.
112    let mut cross: HashMap<(usize, usize), CoMoment> = HashMap::new();
113    // Cramér's V co-occurrence tables keyed by column pair.
114    let mut cramers: HashMap<(usize, usize), HashMap<(u32, u32), usize>> = HashMap::new();
115    let mut cat_levels: Vec<LevelTable> = Vec::new();
116    // Point-biserial binary group sums keyed by (cat, num) pairs.
117    let mut pb: HashMap<(usize, usize), PbPair> = HashMap::new();
118    let mut dup = DupTracker::new(opts.compute_duplicates);
119    // Cells each column consumed numerically before a mid-stream flip; they
120    // count as present in the final categorical profile (no second pass).
121    let mut numeric_seen: Vec<usize> = Vec::new();
122
123    let mut n_rows: usize = 0;
124    let mut cols: Option<usize> = None;
125    let mut header: Option<Vec<String>> = None;
126
127    let observe_row = |row: &[String],
128                       line: usize,
129                       n_rows: &mut usize,
130                       cols: &Option<usize>,
131                       welford: &mut Vec<Welford>,
132                       cat_counts: &mut Vec<Option<CatCounts>>,
133                       numeric_active: &mut Vec<bool>,
134                       cross: &mut HashMap<(usize, usize), CoMoment>,
135                       cramers: &mut CramTables,
136                       cat_levels: &mut Vec<LevelTable>,
137                       pb: &mut HashMap<(usize, usize), PbPair>,
138                       numeric_seen: &mut Vec<usize>,
139                       dup: &mut DupTracker,
140                       opts: &StreamingOptions|
141     -> Result<()> {
142        let p = cols.expect("column count is set before data rows");
143        if row.len() != p {
144            return Err(ProfileError::InvalidInput(format!(
145                "CSV data row at line {line} has {} fields, expected {}",
146                row.len(),
147                p
148            )));
149        }
150        *n_rows += 1;
151        dup.observe(row);
152
153        // Reconcile lazily-grown accumulator vectors.
154        if welford.is_empty() {
155            *welford = (0..p).map(|_| Welford::new(opts.seed)).collect::<Vec<_>>();
156            *cat_counts = (0..p).map(|_| None).collect();
157            *numeric_active = vec![true; p];
158            *numeric_seen = vec![0; p];
159            *cat_levels = (0..p)
160                .map(|_| LevelTable {
161                    map: HashMap::new(),
162                    next: 0,
163                    capped: false,
164                })
165                .collect();
166        }
167
168        // Per-cell updates; collect (col, value) pairs for pairwise updates.
169        let mut row_num_values: Vec<(usize, f64)> = Vec::new();
170        let mut cat_seen: Vec<usize> = Vec::new();
171        for j in 0..p {
172            let cell = row[j].as_str();
173            if crate::infer::is_missing(cell) {
174                continue;
175            }
176            if numeric_active[j] {
177                match cell.trim().parse::<f64>() {
178                    Ok(v) if v.is_finite() => {
179                        numeric_seen[j] += 1;
180                        row_num_values.push((j, v));
181                        continue;
182                    }
183                    // Parseable but non-finite (`inf`, `NaN`, `1e999`): the
184                    // in-memory path keeps the column numeric, counts the
185                    // cell as present, and excludes the value from the
186                    // statistics; do the same instead of flipping.
187                    Ok(_) => welford[j].skipped += 1,
188                    _ => {
189                        // Flip to categorical; the numeric moments seen so far
190                        // become unusable (no second pass), so the column
191                        // degrades to a categorical profile.
192                        numeric_active[j] = false;
193                        cat_counts[j] = Some(CatCounts::default());
194                    }
195                }
196            }
197            // Categorical path.
198            let counts = cat_counts[j].get_or_insert_with(CatCounts::default);
199            let levels = &mut cat_levels[j];
200            let code = levels.code(cell.trim(), opts.distinct_cap);
201            counts.present += 1;
202            if let Some(code) = code {
203                *counts.map.entry(code).or_insert(0) += 1;
204            }
205            cat_seen.push(j);
206        }
207
208        // ── Numeric updates ────────────────────────────────────────────
209        for &(j, v) in &row_num_values {
210            welford[j].push(v, opts.reservoir_capacity);
211        }
212        for a in 0..row_num_values.len() {
213            let (i, xi) = row_num_values[a];
214            for &(j, yj) in row_num_values[(a + 1)..].iter() {
215                let key = if i < j { (i, j) } else { (j, i) };
216                let (x, y) = if i < j { (xi, yj) } else { (yj, xi) };
217                cross.entry(key).or_insert_with(CoMoment::new).update(x, y);
218            }
219        }
220
221        // ── Categorical pair updates ───────────────────────────────────
222        for a in 0..cat_seen.len() {
223            let i = cat_seen[a];
224            let ci = cat_levels[i]
225                .map
226                .get(row[i].trim())
227                .copied()
228                .unwrap_or(u32::MAX);
229            if ci == u32::MAX {
230                continue;
231            }
232            for &j in cat_seen[(a + 1)..].iter() {
233                let cj = cat_levels[j]
234                    .map
235                    .get(row[j].trim())
236                    .copied()
237                    .unwrap_or(u32::MAX);
238                if cj == u32::MAX {
239                    continue;
240                }
241                *cramers
242                    .entry((i.min(j), i.max(j)))
243                    .or_default()
244                    .entry((ci.min(cj), ci.max(cj)))
245                    .or_insert(0) += 1;
246            }
247        }
248
249        // ── Point-biserial updates ───────────────────────────────────
250        for &ci in &cat_seen {
251            let level = cat_levels[ci]
252                .map
253                .get(row[ci].trim())
254                .copied()
255                .unwrap_or(u32::MAX);
256            if level == u32::MAX {
257                continue;
258            }
259            for &(nj, v) in &row_num_values {
260                let pair = pb.entry((ci, nj)).or_insert_with(PbPair::new);
261                pair.observe(level, v);
262            }
263        }
264        Ok(())
265    };
266
267    // ── Stream all records through the shared CSV parser ───────────────
268    // Header/column handling is shared by the streamed and flushed tails.
269    let mut ingest = |records: &mut Vec<(Vec<String>, usize)>| -> Result<()> {
270        for (rec, line) in records.drain(..) {
271            if cols.is_none() {
272                // First physical record.
273                if read_opts.has_header {
274                    header = Some(rec);
275                    cols = Some(header.as_ref().map_or(0, |h| h.len()));
276                    continue;
277                }
278                cols = Some(rec.len());
279            }
280            observe_row(
281                &rec,
282                line,
283                &mut n_rows,
284                &cols,
285                &mut welford,
286                &mut cat_counts,
287                &mut numeric_active,
288                &mut cross,
289                &mut cramers,
290                &mut cat_levels,
291                &mut pb,
292                &mut numeric_seen,
293                &mut dup,
294                opts,
295            )?;
296        }
297        Ok(())
298    };
299
300    // `read_line` keeps each physical line's terminator — including a `\r`
301    // inside a quoted multi-line field — intact, matching `read_csv_reader`;
302    // `BufRead::lines` would strip a trailing `\r` from every physical line.
303    let mut buf = String::new();
304    loop {
305        buf.clear();
306        match reader.read_line(&mut buf) {
307            Ok(0) => break,
308            Ok(_) => {
309                parser.feed(&buf, &mut records)?;
310                ingest(&mut records)?;
311            }
312            Err(e) => return Err(ProfileError::from(e)),
313        }
314    }
315    parser.finish(&mut records)?;
316    ingest(&mut records)?;
317
318    let p = cols.ok_or_else(|| ProfileError::EmptyInput("CSV has no records".into()))?;
319    if p == 0 {
320        return Err(ProfileError::EmptyInput("CSV has no columns".into()));
321    }
322    if n_rows == 0 {
323        return Err(ProfileError::EmptyInput("CSV has no data rows".into()));
324    }
325
326    // Strip the UTF-8 BOM from the first header cell, matching `read_csv_reader`.
327    if let Some(h) = header.as_mut() {
328        if let Some(cell) = h.first_mut() {
329            if let Some(stripped) = cell.strip_prefix('\u{feff}') {
330                *cell = stripped.to_string();
331            }
332        }
333    }
334
335    let names: Vec<String> = match header {
336        Some(h) if h.len() == p => h,
337        _ => (0..p).map(|j| format!("x{j}")).collect(),
338    };
339
340    // ── Finalize columns ───────────────────────────────────────────────
341    let mut columns: Vec<ColumnProfile> = Vec::with_capacity(p);
342    let mut memory_bytes = 0usize;
343    let mut numeric_order: Vec<usize> = Vec::new();
344    let mut cat_order: Vec<usize> = Vec::new();
345
346    for j in 0..p {
347        let name = &names[j];
348        let present = if numeric_active[j] {
349            // Non-finite parses are present but excluded from the moments.
350            welford[j].n + welford[j].skipped
351        } else {
352            // Cells consumed numerically before a mid-stream flip still
353            // count as present (they were observed, just not categorically).
354            cat_counts[j].as_ref().map_or(0, |c| c.present) + numeric_seen[j]
355        };
356        let missing = n_rows - present;
357        let missing_fraction = missing as f64 / n_rows as f64;
358        if numeric_active[j] && present > 0 {
359            numeric_order.push(j);
360            let stats5 = welford[j].finalize();
361            columns.push(ColumnProfile {
362                name: name.clone(),
363                column_type: ColumnType::Numeric,
364                count: n_rows,
365                missing_count: missing,
366                missing_fraction,
367                numeric: Some(stats5),
368                categorical: None,
369            });
370            memory_bytes += n_rows * 8;
371        } else {
372            cat_order.push(j);
373            let counts = cat_counts[j].get_or_insert_with(CatCounts::default);
374            // An all-missing column mirrors the in-memory path: no
375            // `CategoricalStats` at all (`compute_categorical` returns `None`).
376            if counts.present == 0 && numeric_seen[j] == 0 {
377                columns.push(ColumnProfile {
378                    name: name.clone(),
379                    column_type: ColumnType::Categorical,
380                    count: n_rows,
381                    missing_count: missing,
382                    missing_fraction,
383                    numeric: None,
384                    categorical: None,
385                });
386                memory_bytes += n_rows * 24;
387                continue;
388            }
389            let mut entries: Vec<(u32, usize)> = counts.map.iter().map(|(&k, &c)| (k, c)).collect();
390            // Deterministic total order: count desc, then level code.
391            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
392            let decode = |code: u32| -> String {
393                cat_levels[j]
394                    .map
395                    .iter()
396                    .find(|(_, &c)| c == code)
397                    .map(|(k, _)| k.clone())
398                    .unwrap_or_default()
399            };
400            let top_values: Vec<(String, usize)> = entries
401                .iter()
402                .take(8)
403                .map(|&(code, c)| (decode(code), c))
404                .collect();
405            let (top, freq) = match top_values.first() {
406                Some((t, f)) => (t.clone(), *f),
407                None => (String::new(), 0),
408            };
409            columns.push(ColumnProfile {
410                name: name.clone(),
411                column_type: ColumnType::Categorical,
412                count: n_rows,
413                missing_count: missing,
414                missing_fraction,
415                numeric: None,
416                categorical: Some(CategoricalStats {
417                    unique: counts.map.len(),
418                    top,
419                    freq,
420                    imbalance_ratio: if counts.present == 0 {
421                        0.0
422                    } else {
423                        freq as f64 / counts.present as f64
424                    },
425                    top_values,
426                }),
427            });
428            memory_bytes += n_rows * 24;
429        }
430    }
431
432    // ── Relationships ──────────────────────────────────────────────────
433    let relationships = finalize_relationships(
434        &names,
435        &numeric_order,
436        &cat_order,
437        &cross,
438        &cramers,
439        &pb,
440        &cat_levels,
441    );
442
443    let duplicate_rows = dup.count;
444    Ok(DatasetProfile {
445        n_rows,
446        n_columns: p,
447        memory_bytes,
448        duplicate_rows,
449        duplicate_fraction: duplicate_rows as f64 / n_rows as f64,
450        target_column: None,
451        columns,
452        relationships,
453    })
454}
455
456/// Running mean/variance and raw central moments for one numeric column.
457struct Welford {
458    n: usize,
459    /// Cells that parsed as numeric but were non-finite (`inf`, `NaN`) —
460    /// present in the column's row count, excluded from the moments.
461    skipped: usize,
462    mean: f64,
463    m2: f64,
464    m3: f64,
465    m4: f64,
466    min: f64,
467    max: f64,
468    reservoir: Vec<f64>,
469    rng: SmallRng,
470}
471
472impl Welford {
473    fn new(seed: u64) -> Self {
474        Welford {
475            n: 0,
476            skipped: 0,
477            mean: 0.0,
478            m2: 0.0,
479            m3: 0.0,
480            m4: 0.0,
481            min: f64::INFINITY,
482            max: f64::NEG_INFINITY,
483            reservoir: Vec::new(),
484            rng: SmallRng::new(seed),
485        }
486    }
487
488    fn push(&mut self, v: f64, capacity: usize) {
489        self.n += 1;
490        let n_f = self.n as f64;
491        let delta = v - self.mean;
492        let mean_new = self.mean + delta / n_f;
493        let d2 = v - mean_new;
494        let m2_new = self.m2 + delta * d2;
495        let delta_n = delta / n_f;
496        // Incremental central moments (Pébay's single-value update formulas).
497        // `self.m2`/`self.m3`/`self.m4` accumulate *sums* of central moments
498        // (divided by `n` in `finalize`); the recurrences below use their
499        // pre-update values, which keeps them exact.
500        let term1 = delta * delta_n * (n_f - 1.0);
501        let m3_new = self.m3 + term1 * delta_n * (n_f - 2.0) - 3.0 * delta_n * self.m2;
502        let m4_new = self.m4
503            + term1 * delta_n * delta_n * (n_f * n_f - 3.0 * n_f + 3.0)
504            + 6.0 * delta_n * delta_n * self.m2
505            - 4.0 * delta_n * self.m3;
506        self.m4 = m4_new;
507        self.m3 = m3_new;
508        self.m2 = m2_new;
509        self.mean = mean_new;
510        if v < self.min {
511            self.min = v;
512        }
513        if v > self.max {
514            self.max = v;
515        }
516        // Reservoir sampling (Algorithm R): the value at 0-based global
517        // index i (== self.n - 1 here) enters the reservoir with
518        // probability capacity / (i + 1), so every value is equally
519        // likely to be in the final sample.
520        if self.reservoir.len() < capacity {
521            self.reservoir.push(v);
522        } else if capacity > 0 {
523            let j = self.rng.next_below(self.n);
524            if j < capacity {
525                self.reservoir[j] = v;
526            }
527        }
528    }
529
530    /// Builds the final [`NumericStats`] from the accumulated moments and
531    /// the reservoir sample.
532    fn finalize(&self) -> NumericStats {
533        let n_f = self.n as f64;
534        // `datarust::stats::variance` (the in-memory path) yields NaN when
535        // the sample is smaller than ddof + 1; single-value and empty
536        // columns match that instead of reporting a bogus 0.0.
537        let var = if self.n >= 2 {
538            self.m2 / (n_f - 1.0)
539        } else {
540            f64::NAN
541        };
542        let std = var.sqrt();
543        let mut sorted = self.reservoir.clone();
544        sorted.sort_by(f64::total_cmp);
545        let q = |p: f64| -> f64 {
546            if sorted.is_empty() {
547                return f64::NAN;
548            }
549            let pos = p * (sorted.len() - 1) as f64;
550            let lo = pos.floor() as usize;
551            let hi = pos.ceil() as usize;
552            if lo == hi {
553                sorted[lo]
554            } else {
555                let frac = pos - lo as f64;
556                sorted[lo] * (1.0 - frac) + sorted[hi] * frac
557            }
558        };
559        let five = FiveNumber {
560            min: self.min,
561            q1: q(0.25),
562            median: q(0.5),
563            q3: q(0.75),
564            max: self.max,
565        };
566        let skew = if self.n >= 3 && std > 0.0 {
567            (self.m3 / n_f) / std.powi(3)
568        } else {
569            0.0
570        };
571        let kurt = if self.n >= 4 && std > 0.0 {
572            (self.m4 / n_f) / std.powi(4) - 3.0
573        } else {
574            0.0
575        };
576        let histogram = crate::profile::distribution::histogram(&sorted, five.min, five.max);
577        let (outlier_count, outlier_fraction) =
578            crate::profile::distribution::outlier_count(&sorted, five.q1, five.q3);
579        NumericStats {
580            mean: self.mean,
581            std,
582            five,
583            skewness: skew,
584            kurtosis: kurt,
585            histogram,
586            outlier_count,
587            outlier_fraction,
588        }
589    }
590}
591
592/// Online co-moment accumulator for a numeric column pair (Welford-style
593/// covariance; exact Pearson up to floating-point accumulation order).
594///
595/// All accumulators — including the marginal sums of squared deviations —
596/// run over the *pairwise-complete* rows only, so [`CoMoment::correlation`]
597/// is the pairwise-complete Pearson correlation.
598struct CoMoment {
599    n: usize,
600    mean_x: f64,
601    mean_y: f64,
602    c: f64,
603    ss_x: f64,
604    ss_y: f64,
605}
606
607impl CoMoment {
608    fn new() -> Self {
609        CoMoment {
610            n: 0,
611            mean_x: 0.0,
612            mean_y: 0.0,
613            c: 0.0,
614            ss_x: 0.0,
615            ss_y: 0.0,
616        }
617    }
618
619    fn update(&mut self, x: f64, y: f64) {
620        self.n += 1;
621        let n = self.n as f64;
622        let dx = x - self.mean_x;
623        let dy = y - self.mean_y;
624        self.mean_x += dx / n;
625        self.mean_y += dy / n;
626        self.c += dx * (y - self.mean_y);
627        self.ss_x += dx * (x - self.mean_x);
628        self.ss_y += dy * (y - self.mean_y);
629    }
630
631    /// Sample Pearson correlation over the pairwise-complete rows. The
632    /// `n − 1` factors cancel, so `r = c / sqrt(ss_x · ss_y)` exactly.
633    fn correlation(&self) -> Option<f64> {
634        if self.n < 2 || self.ss_x <= 0.0 || self.ss_y <= 0.0 {
635            return None;
636        }
637        let r = self.c / (self.ss_x * self.ss_y).sqrt();
638        if r.is_nan() {
639            None
640        } else {
641            // Guard against floating-point overshoot of ±1 by a few ulps.
642            Some(r.clamp(-1.0, 1.0))
643        }
644    }
645}
646
647/// Interned string levels for one categorical column.
648struct LevelTable {
649    map: HashMap<String, u32>,
650    next: u32,
651    capped: bool,
652}
653
654impl LevelTable {
655    /// Returns the level code for `value`, assigning the next code. Returns
656    /// `None` once the table exceeds `cap` distinct values (the cell keeps
657    /// counting towards `present` but cannot join pairwise tables).
658    fn code(&mut self, value: &str, cap: usize) -> Option<u32> {
659        if let Some(&c) = self.map.get(value) {
660            return Some(c);
661        }
662        if self.next as usize >= cap {
663            self.capped = true;
664            return None;
665        }
666        let code = self.next;
667        self.next += 1;
668        self.map.insert(value.to_string(), code);
669        Some(code)
670    }
671}
672
673/// Per-column categorical counters (present cells + per-level counts).
674#[derive(Default)]
675struct CatCounts {
676    present: usize,
677    map: HashMap<u32, usize>,
678}
679
680/// Point-biserial accumulator for one (categorical, numeric) pair.
681///
682/// Group 0 is always the categorical column's first-seen level (interned
683/// code `0`), mirroring the in-memory implementation's `val_0` encoding;
684/// group 1 is the first other level observed. Observing a third level (the
685/// column is not binary) drops the pair.
686struct PbPair {
687    second: Option<u32>,
688    dropped: bool,
689    n0: usize,
690    sum0: f64,
691    n1: usize,
692    sum1: f64,
693    sum_sq: f64,
694}
695
696impl PbPair {
697    fn new() -> Self {
698        PbPair {
699            second: None,
700            dropped: false,
701            n0: 0,
702            sum0: 0.0,
703            n1: 0,
704            sum1: 0.0,
705            sum_sq: 0.0,
706        }
707    }
708
709    fn observe(&mut self, level: u32, value: f64) {
710        if self.dropped {
711            return;
712        }
713        if level == 0 {
714            self.n0 += 1;
715            self.sum0 += value;
716            self.sum_sq += value * value;
717            return;
718        }
719        match self.second {
720            Some(s) if s == level => {
721                self.n1 += 1;
722                self.sum1 += value;
723                self.sum_sq += value * value;
724            }
725            None => {
726                self.second = Some(level);
727                self.n1 += 1;
728                self.sum1 += value;
729                self.sum_sq += value * value;
730            }
731            _ => self.dropped = true,
732        }
733    }
734
735    /// Final point-biserial correlation, mirroring the two-pass
736    /// implementation's computational identity.
737    fn correlation(&self) -> Option<f64> {
738        if self.dropped || self.n0 == 0 || self.n1 == 0 {
739            return None;
740        }
741        let total_n = self.n0 + self.n1;
742        if total_n < 3 {
743            return None;
744        }
745        let m0 = self.sum0 / self.n0 as f64;
746        let m1 = self.sum1 / self.n1 as f64;
747        let total_sum = self.sum0 + self.sum1;
748        let ss = self.sum_sq - (total_sum * total_sum) / total_n as f64;
749        let s_y = (ss / (total_n - 1) as f64).sqrt();
750        let r = if s_y == 0.0 {
751            0.0
752        } else {
753            ((m1 - m0) / s_y)
754                * ((self.n0 * self.n1) as f64 / (total_n * (total_n - 1)) as f64).sqrt()
755        };
756        if r.is_nan() {
757            None
758        } else {
759            Some(r.clamp(-1.0, 1.0))
760        }
761    }
762}
763
764/// Duplicate-row tracker keyed on raw cells.
765struct DupTracker {
766    enabled: bool,
767    seen: Option<HashSet<Vec<String>>>,
768    count: usize,
769}
770
771impl DupTracker {
772    fn new(enabled: bool) -> Self {
773        DupTracker {
774            enabled,
775            seen: if enabled { Some(HashSet::new()) } else { None },
776            count: 0,
777        }
778    }
779    /// Keys rows by their raw cells, exactly like the in-memory CSV path's
780    /// `count_duplicate_str`: no trimming, no numeric canonicalization, no
781    /// missing-marker unification — `1` and `1.0` are different rows.
782    fn observe(&mut self, row: &[String]) {
783        if !self.enabled {
784            return;
785        }
786        let seen = self.seen.as_mut().expect("enabled tracker has a set");
787        if !seen.insert(row.to_vec()) {
788            self.count += 1;
789        }
790    }
791}
792
793/// Minimal deterministic PRNG for reservoir sampling (xorshift64*).
794struct SmallRng {
795    state: u64,
796}
797
798impl SmallRng {
799    fn new(seed: u64) -> Self {
800        SmallRng {
801            state: if seed == 0 {
802                0x9E37_79B9_7F4A_7C15
803            } else {
804                seed
805            },
806        }
807    }
808
809    fn next_u64(&mut self) -> u64 {
810        let mut x = self.state;
811        x ^= x >> 12;
812        x ^= x << 25;
813        x ^= x >> 27;
814        self.state = x;
815        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
816    }
817
818    fn next_below(&mut self, hi: usize) -> usize {
819        if hi == 0 {
820            return 0;
821        }
822        ((self.next_u64() >> 11) as usize) % hi
823    }
824}
825
826/// Builds the [`Relationships`] block from the online accumulators.
827#[allow(clippy::too_many_arguments)]
828fn finalize_relationships(
829    names: &[String],
830    numeric_order: &[usize],
831    cat_order: &[usize],
832    cross: &HashMap<(usize, usize), CoMoment>,
833    cramers: &CramTables,
834    pb: &HashMap<(usize, usize), PbPair>,
835    cat_levels: &[LevelTable],
836) -> Option<Relationships> {
837    // ── Pearson ────────────────────────────────────────────────────────
838    let pearson = if numeric_order.len() >= 2 {
839        let pos: HashMap<usize, usize> = numeric_order
840            .iter()
841            .enumerate()
842            .map(|(pos, &j)| (j, pos))
843            .collect();
844        let p = numeric_order.len();
845        let labels: Vec<String> = numeric_order.iter().map(|&j| names[j].clone()).collect();
846        let mut values = vec![vec![0.0; p]; p];
847        for (row_idx, diag) in values.iter_mut().enumerate() {
848            diag[row_idx] = 1.0;
849        }
850        for (&(i, j), com) in cross {
851            let (pi, pj) = match (pos.get(&i), pos.get(&j)) {
852                (Some(&a), Some(&b)) => (a, b),
853                _ => continue, // one side flipped to categorical mid-stream
854            };
855            if let Some(r) = com.correlation() {
856                values[pi][pj] = r;
857                values[pj][pi] = r;
858            }
859        }
860        Some(CorrelationMatrix { labels, values })
861    } else {
862        None
863    };
864
865    // ── Cramér's V ─────────────────────────────────────────────────────
866    let cramers_v = if cat_order.len() >= 2 && !cramers.is_empty() {
867        let pos: HashMap<usize, usize> = cat_order
868            .iter()
869            .enumerate()
870            .map(|(pos, &j)| (j, pos))
871            .collect();
872        let q = cat_order.len();
873        let labels: Vec<String> = cat_order.iter().map(|&j| names[j].clone()).collect();
874        let mut values = vec![vec![0.0; q]; q];
875        for (row_idx, diag) in values.iter_mut().enumerate() {
876            diag[row_idx] = 1.0;
877        }
878        for (&(i, j), table) in cramers {
879            let (pi, pj) = match (pos.get(&i), pos.get(&j)) {
880                (Some(&a), Some(&b)) => (a, b),
881                _ => continue,
882            };
883            let v = cramers_v_from_table(table);
884            values[pi][pj] = v;
885            values[pj][pi] = v;
886        }
887        Some(CorrelationMatrix { labels, values })
888    } else {
889        None
890    };
891
892    // ── Point-biserial ─────────────────────────────────────────────────
893    // Mirror the in-memory rule exactly: a pair is only reported when the
894    // categorical column has *exactly* two distinct levels column-wide (a
895    // one-level column is also skipped) and the numeric side is still
896    // numeric at the end of the stream (a mid-stream flip leaves stale
897    // partial sums behind). `LevelTable::next` is the number of distinct
898    // interned levels for the column.
899    let point_biserial: Vec<PointBiserialEntry> = pb
900        .iter()
901        .filter(|(&(ci, _), _)| cat_levels[ci].next == 2)
902        .filter(|&(&(ci, nj), _)| cat_order.contains(&ci) && numeric_order.contains(&nj))
903        .filter_map(|(&(ci, nj), pair)| {
904            pair.correlation().map(|r| PointBiserialEntry {
905                categorical: names[ci].clone(),
906                numeric: names[nj].clone(),
907                correlation: r,
908            })
909        })
910        .collect();
911
912    if pearson.is_none() && cramers_v.is_none() && point_biserial.is_empty() {
913        None
914    } else {
915        Some(Relationships {
916            pearson,
917            cramers_v,
918            point_biserial,
919        })
920    }
921}
922
923/// Cramér's V from a co-occurrence count table keyed by level codes.
924fn cramers_v_from_table(table: &HashMap<(u32, u32), usize>) -> f64 {
925    let mut row_tot: HashMap<u32, usize> = HashMap::new();
926    let mut col_tot: HashMap<u32, usize> = HashMap::new();
927    let mut n = 0usize;
928    for (&(r, c), &obs) in table {
929        *row_tot.entry(r).or_insert(0) += obs;
930        *col_tot.entry(c).or_insert(0) += obs;
931        n += obs;
932    }
933    if n == 0 {
934        return 0.0;
935    }
936    let r_levels = row_tot.values().copied().filter(|&t| t > 0).count();
937    let c_levels = col_tot.values().copied().filter(|&t| t > 0).count();
938    if r_levels <= 1 || c_levels <= 1 {
939        return 0.0;
940    }
941    let n_f = n as f64;
942    let mut chi2 = 0.0;
943    for (&(r, c), &obs) in table {
944        let expected = row_tot[&r] as f64 * col_tot[&c] as f64 / n_f;
945        if expected > 0.0 {
946            let o = obs as f64;
947            chi2 += o * o / expected;
948        }
949    }
950    chi2 -= n_f;
951    let min_dim = (r_levels - 1).min(c_levels - 1) as f64;
952    if min_dim == 0.0 {
953        return 0.0;
954    }
955    let v = (chi2 / (n_f * min_dim)).sqrt();
956    if v.is_nan() {
957        0.0
958    } else {
959        v.min(1.0)
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use std::io::Cursor;
967
968    fn write_csv(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
969        let dir = tempfile::tempdir().unwrap();
970        let path = dir.path().join("data.csv");
971        std::fs::write(&path, content).unwrap();
972        (dir, path)
973    }
974
975    #[test]
976    fn small_table_matches_in_memory_profile() {
977        // n_rows <= reservoir_capacity ⇒ exact quantiles; the streaming
978        // profile must match profile_str_matrix closely on all exact stats
979        // and within tolerance on moments.
980        let (dir, path) =
981            write_csv("x,y,c\n1,2,a\n2,4,b\n3,6,a\n4,8,b\n5,10,a\n6,12,b\n7,14,a\n8,16,b\n");
982        let streamed = profile_csv_streaming(&path).unwrap();
983        let owned = DatasetProfile::from_csv(&path).unwrap();
984
985        assert_eq!(streamed.n_rows, owned.n_rows);
986        assert_eq!(streamed.n_columns, owned.n_columns);
987        assert_eq!(streamed.columns.len(), owned.columns.len());
988        let sx = streamed.columns[0].numeric.as_ref().unwrap();
989        let ox = owned.columns[0].numeric.as_ref().unwrap();
990        assert_eq!(sx.mean.to_bits(), ox.mean.to_bits());
991        assert_eq!(sx.five.min.to_bits(), ox.five.min.to_bits());
992        assert_eq!(sx.five.max.to_bits(), ox.five.max.to_bits());
993        assert!((sx.std - ox.std).abs() < 1e-12);
994        assert!((sx.five.q1 - ox.five.q1).abs() < 1e-12);
995        assert!((sx.five.median - ox.five.median).abs() < 1e-12);
996        // Pearson x↔y is exactly 1.0 in both paths.
997        let pr = streamed
998            .relationships
999            .as_ref()
1000            .unwrap()
1001            .pearson
1002            .as_ref()
1003            .unwrap();
1004        assert!((pr.values[0][1] - 1.0).abs() < 1e-9);
1005        drop(dir);
1006    }
1007
1008    #[test]
1009    fn categorical_stats_match() {
1010        let (dir, path) = write_csv("c,n\na,1\nb,2\na,3\nb,4\na,5\na,6\n");
1011        let streamed = profile_csv_streaming(&path).unwrap();
1012        let owned = DatasetProfile::from_csv(&path).unwrap();
1013        let sc = streamed.columns[0].categorical.as_ref().unwrap();
1014        let oc = owned.columns[0].categorical.as_ref().unwrap();
1015        assert_eq!(sc.unique, oc.unique);
1016        assert_eq!(sc.freq, oc.freq);
1017        assert_eq!(sc.top, oc.top);
1018        assert!((sc.imbalance_ratio - oc.imbalance_ratio).abs() < 1e-12);
1019        drop(dir);
1020    }
1021
1022    #[test]
1023    fn numeric_flip_mid_stream() {
1024        // A non-parseable cell in a numeric column flips it to categorical.
1025        // Streaming never replays earlier rows: cells observed while the
1026        // column was numeric are not reclassified, so only the flip point
1027        // onward is counted categorically (documented fidelity contract).
1028        let (dir, path) = write_csv("x\n1\n2\noops\n3\n4\n");
1029        let streamed = profile_csv_streaming(&path).unwrap();
1030        assert_eq!(streamed.columns[0].column_type, ColumnType::Categorical);
1031        let c = streamed.columns[0].categorical.as_ref().unwrap();
1032        // "oops" lands as the single categorical level; 1/2 arrived numeric
1033        // and 3/4 arrive after the flip as categorical values.
1034        assert_eq!(c.unique, 3);
1035        assert!(c.top_values.iter().any(|(v, _)| v == "oops"));
1036        // Rows before the flip are not replayed; those cells neither count
1037        // as categorical nor re-appear in any numeric stat (a one-pass
1038        // trade-off, documented in the module docs).
1039        assert_eq!(streamed.columns[0].missing_count, 0);
1040        assert_eq!(streamed.columns[0].count, streamed.n_rows);
1041        drop(dir);
1042    }
1043
1044    #[test]
1045    fn missing_cells_counted() {
1046        let (dir, path) = write_csv("x,y\n1,2\nNA,4\n3,\n");
1047        let streamed = profile_csv_streaming(&path).unwrap();
1048        assert_eq!(streamed.columns[0].missing_count, 1);
1049        assert_eq!(streamed.columns[1].missing_count, 1);
1050        drop(dir);
1051    }
1052
1053    #[test]
1054    fn duplicates_counted_when_enabled() {
1055        let (dir, path) = write_csv("a,b\n1,2\n1,2\n3,4\n");
1056        let opts = StreamingOptions {
1057            compute_duplicates: true,
1058            ..StreamingOptions::default()
1059        };
1060        let streamed = profile_csv_streaming_with(&path, &opts).unwrap();
1061        assert_eq!(streamed.duplicate_rows, 1);
1062        let streamed_off = profile_csv_streaming(&path).unwrap();
1063        assert_eq!(streamed_off.duplicate_rows, 0);
1064        drop(dir);
1065    }
1066
1067    #[test]
1068    fn distinct_cap_produces_lower_bound() {
1069        let (dir, path) = write_csv("c\na\nb\nc\nd\n");
1070        let opts = StreamingOptions {
1071            distinct_cap: 2,
1072            ..StreamingOptions::default()
1073        };
1074        let streamed = profile_csv_streaming_with(&path, &opts).unwrap();
1075        let c = streamed.columns[0].categorical.as_ref().unwrap();
1076        // Only two levels are tracked (a, b in first-seen order); the rest
1077        // still count towards present.
1078        assert_eq!(c.unique, 2);
1079        drop(dir);
1080    }
1081
1082    #[test]
1083    fn empty_file_is_error() {
1084        let (dir, path) = write_csv("");
1085        let err = profile_csv_streaming(&path).unwrap_err();
1086        assert!(matches!(err, ProfileError::EmptyInput(_)));
1087        drop(dir);
1088    }
1089
1090    #[test]
1091    fn header_only_is_error() {
1092        let (dir, path) = write_csv("a,b\n");
1093        let err = profile_csv_streaming(&path).unwrap_err();
1094        assert!(matches!(err, ProfileError::EmptyInput(_)));
1095        drop(dir);
1096    }
1097
1098    #[test]
1099    fn reservoir_sampling_is_uniform_and_deterministic() {
1100        let body: String = (1..=50_000).map(|i| format!("{}\n", i)).collect();
1101        let (dir, path) = write_csv(&format!("x\n{body}"));
1102        let opts = StreamingOptions {
1103            reservoir_capacity: 1_000,
1104            ..StreamingOptions::default()
1105        };
1106        let a = profile_csv_streaming_with(&path, &opts).unwrap();
1107        let b = profile_csv_streaming_with(&path, &opts).unwrap();
1108        let (na, nb) = (
1109            a.columns[0].numeric.as_ref().unwrap(),
1110            b.columns[0].numeric.as_ref().unwrap(),
1111        );
1112        // Identical seeds → identical sample → identical stats.
1113        assert_eq!(na.five.q1.to_bits(), nb.five.q1.to_bits());
1114        assert_eq!(na.five.median.to_bits(), nb.five.median.to_bits());
1115        // Exact mean regardless of sampling.
1116        assert!((na.mean - 25_000.5).abs() < 1e-6);
1117        // Algorithm R keeps the reservoir uniform over the whole stream, so
1118        // the sample median of 1..=50 000 must sit near the true median
1119        // (sample-median σ ≈ 800 for 1 000 draws; 3 000 ≈ 4σ). A sampler
1120        // that always admits late values lands far above this band.
1121        assert!(
1122            (na.five.median - 25_000.5).abs() < 3_000.0,
1123            "reservoir median {}",
1124            na.five.median
1125        );
1126        assert!(na.five.q1 < na.five.median);
1127        assert!(na.five.median < na.five.q3);
1128        assert_eq!(na.five.min, 1.0);
1129        assert_eq!(na.five.max, 50_000.0);
1130        drop(dir);
1131    }
1132
1133    #[test]
1134    fn moments_match_in_memory_on_skewed_data() {
1135        // Regression: the Pébay m3/m4 recurrences once used wrong coefficients,
1136        // producing wildly wrong skewness/kurtosis in the streaming path.
1137        let body: String = (0..50)
1138            .map(|i| {
1139                let v = if i % 10 == 0 {
1140                    100.0 + i as f64
1141                } else {
1142                    i as f64
1143                };
1144                format!("{v}\n")
1145            })
1146            .collect();
1147        let (dir, path) = write_csv(&format!("x\n{body}"));
1148        let streamed = profile_csv_streaming(&path).unwrap();
1149        let owned = DatasetProfile::from_csv(&path).unwrap();
1150        let (s, o) = (
1151            streamed.columns[0].numeric.as_ref().unwrap(),
1152            owned.columns[0].numeric.as_ref().unwrap(),
1153        );
1154        assert!(
1155            (s.skewness - o.skewness).abs() < 1e-9,
1156            "{} vs {}",
1157            s.skewness,
1158            o.skewness
1159        );
1160        assert!(
1161            (s.kurtosis - o.kurtosis).abs() < 1e-9,
1162            "{} vs {}",
1163            s.kurtosis,
1164            o.kurtosis
1165        );
1166        drop(dir);
1167    }
1168
1169    #[test]
1170    fn point_biserial_group_zero_is_first_seen_level() {
1171        // Regression: the pair's group 0 must be the *column's* first-seen
1172        // level (`a`), not the first level seen with a numeric value (`b`).
1173        // Row 1 interns `a` but has a missing numeric cell, so the pair is
1174        // created on row 2 (`b`); the in-memory path encodes `a` → 0
1175        // regardless. With x small on `b` and large on `a`, r must be
1176        // negative.
1177        let (dir, path) = write_csv("g,x\na,\nb,1\nb,2\na,3\n");
1178        let streamed = profile_csv_streaming(&path).unwrap();
1179        let owned = DatasetProfile::from_csv(&path).unwrap();
1180        let s = streamed
1181            .relationships
1182            .as_ref()
1183            .unwrap()
1184            .point_biserial
1185            .first()
1186            .unwrap()
1187            .correlation;
1188        let o = owned
1189            .relationships
1190            .as_ref()
1191            .unwrap()
1192            .point_biserial
1193            .first()
1194            .unwrap()
1195            .correlation;
1196        assert!(s < 0.0, "streaming r {s} should be negative");
1197        assert!((s - o).abs() < 1e-9, "streaming {s} vs in-memory {o}");
1198        drop(dir);
1199    }
1200
1201    #[test]
1202    fn memory_bytes_match_in_memory_accounting() {
1203        let (dir, path) = write_csv("n,c\n1,a\n2,b\n3,a\n");
1204        let streamed = profile_csv_streaming(&path).unwrap();
1205        let owned = DatasetProfile::from_csv(&path).unwrap();
1206        assert_eq!(streamed.memory_bytes, owned.memory_bytes);
1207        drop(dir);
1208    }
1209
1210    #[test]
1211    fn all_missing_column_has_no_categorical_stats() {
1212        // Mirrors the in-memory path: `compute_categorical` returns `None`
1213        // when no non-missing cell exists.
1214        let (dir, path) = write_csv("c,n\nNA,1\nnull,2\n,3\n");
1215        let streamed = profile_csv_streaming(&path).unwrap();
1216        let owned = DatasetProfile::from_csv(&path).unwrap();
1217        assert!(streamed.columns[0].categorical.is_none());
1218        assert!(owned.columns[0].categorical.is_none());
1219        drop(dir);
1220    }
1221
1222    #[test]
1223    fn streaming_reader_over_cursor() {
1224        let data = "a,b\n1,2\n3,4\n";
1225        let profile =
1226            stream_from_reader(Cursor::new(data.to_string()), &StreamingOptions::default())
1227                .unwrap();
1228        assert_eq!(profile.n_rows, 2);
1229        assert_eq!(profile.columns[0].numeric.as_ref().unwrap().mean, 2.0);
1230    }
1231
1232    #[test]
1233    fn pearson_uses_pairwise_complete_variance() {
1234        // Regression: the streaming Pearson once divided the pairwise
1235        // covariance by the columns' *full* variances, so the `100` in x —
1236        // a row where y is missing — dragged r away from the true
1237        // pairwise-complete value of 1.0 (it produced ≈ 0.02).
1238        let (dir, path) = write_csv("x,y\n1,1\n2,2\n3,3\n100,\n");
1239        let streamed = profile_csv_streaming(&path).unwrap();
1240        let r = streamed
1241            .relationships
1242            .as_ref()
1243            .unwrap()
1244            .pearson
1245            .as_ref()
1246            .unwrap()
1247            .values[0][1];
1248        assert!((r - 1.0).abs() < 1e-12, "pairwise-complete r = {r}");
1249        drop(dir);
1250    }
1251
1252    #[test]
1253    fn point_biserial_drops_pairs_whose_numeric_column_flipped() {
1254        // Regression: a numeric column that flips to categorical mid-stream
1255        // left a stale point-biserial pair behind, reported with a
1256        // `numeric` label even though the column is categorical in the
1257        // final profile.
1258        let (dir, path) = write_csv("g,x\na,1\nb,2\na,3\nb,oops\n");
1259        let streamed = profile_csv_streaming(&path).unwrap();
1260        assert!(streamed.columns[1].categorical.is_some());
1261        let pb = streamed
1262            .relationships
1263            .as_ref()
1264            .map(|r| r.point_biserial.as_slice())
1265            .unwrap_or(&[]);
1266        assert!(
1267            !pb.iter().any(|e| e.numeric == "x"),
1268            "stale point-biserial entry for flipped column: {pb:?}"
1269        );
1270        drop(dir);
1271    }
1272
1273    #[test]
1274    fn crlf_inside_quoted_field_matches_in_memory() {
1275        // Regression: `BufRead::lines` stripped the trailing `\r` of every
1276        // physical line, corrupting CRLF content inside quoted fields;
1277        // `read_line` keeps it, matching `read_csv_reader`.
1278        let (dir, path) = write_csv("c\n\"a\r\nb\"\n\"a\r\nb\"\nz\n");
1279        let streamed = profile_csv_streaming(&path).unwrap();
1280        let owned = DatasetProfile::from_csv(&path).unwrap();
1281        let s = streamed.columns[0].categorical.as_ref().unwrap();
1282        let o = owned.columns[0].categorical.as_ref().unwrap();
1283        assert_eq!(s.top, o.top);
1284        assert_eq!(s.freq, o.freq);
1285        drop(dir);
1286    }
1287
1288    #[test]
1289    fn duplicates_use_raw_cells_like_in_memory() {
1290        // Regression: the tracker canonicalized numerics (`1` ≡ `1.0`) and
1291        // trimmed cells; the in-memory CSV path compares raw strings.
1292        let (dir, path) = write_csv("a,b\n1,x\n1.0,x\n1,x\n");
1293        let opts = StreamingOptions {
1294            compute_duplicates: true,
1295            ..StreamingOptions::default()
1296        };
1297        let streamed = profile_csv_streaming_with(&path, &opts).unwrap();
1298        let owned = DatasetProfile::from_csv(&path).unwrap();
1299        assert_eq!(streamed.duplicate_rows, owned.duplicate_rows);
1300        // Only the exact `1,x` repeat counts; `1.0,x` is a different row.
1301        assert_eq!(streamed.duplicate_rows, 1);
1302        drop(dir);
1303    }
1304
1305    #[test]
1306    fn non_finite_parses_stay_numeric_and_missing() {
1307        // Regression: `inf` parsed successfully but is non-finite; the
1308        // streaming path flipped the whole column to categorical, while
1309        // the in-memory path keeps it numeric and counts the cell missing.
1310        let (dir, path) = write_csv("x\n1\n2\ninf\n4\n");
1311        let streamed = profile_csv_streaming(&path).unwrap();
1312        let owned = DatasetProfile::from_csv(&path).unwrap();
1313        assert!(streamed.columns[0].numeric.is_some());
1314        assert_eq!(
1315            streamed.columns[0].missing_count,
1316            owned.columns[0].missing_count
1317        );
1318        assert_eq!(
1319            streamed.columns[0].numeric.as_ref().unwrap().mean,
1320            owned.columns[0].numeric.as_ref().unwrap().mean
1321        );
1322        drop(dir);
1323    }
1324}