1use std::collections::{HashMap, HashSet};
40use std::io::{BufRead, BufReader};
41use std::path::Path;
42
43use crate::error::{ProfileError, Result};
44
45type CramTable = HashMap<(u32, u32), usize>;
48type 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#[derive(Debug, Clone)]
57pub struct StreamingOptions {
58 pub reservoir_capacity: usize,
62 pub distinct_cap: usize,
66 pub compute_duplicates: bool,
69 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
84pub fn profile_csv_streaming(path: &Path) -> Result<DatasetProfile> {
87 profile_csv_streaming_with(path, &StreamingOptions::default())
88}
89
90pub 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
96pub(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 let mut welford: Vec<Welford> = Vec::new();
107 let mut cat_counts: Vec<Option<CatCounts>> = Vec::new();
110 let mut numeric_active: Vec<bool> = Vec::new();
111 let mut cross: HashMap<(usize, usize), CoMoment> = HashMap::new();
113 let mut cramers: HashMap<(usize, usize), HashMap<(u32, u32), usize>> = HashMap::new();
115 let mut cat_levels: Vec<LevelTable> = Vec::new();
116 let mut pb: HashMap<(usize, usize), PbPair> = HashMap::new();
118 let mut dup = DupTracker::new(opts.compute_duplicates);
119 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 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 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 Ok(_) => welford[j].skipped += 1,
188 _ => {
189 numeric_active[j] = false;
193 cat_counts[j] = Some(CatCounts::default());
194 }
195 }
196 }
197 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 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 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 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 let mut ingest = |records: &mut Vec<(Vec<String>, usize)>| -> Result<()> {
270 for (rec, line) in records.drain(..) {
271 if cols.is_none() {
272 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 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 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 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 welford[j].n + welford[j].skipped
351 } else {
352 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 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 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 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
456struct Welford {
458 n: usize,
459 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 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 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 fn finalize(&self) -> NumericStats {
533 let n_f = self.n as f64;
534 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
592struct 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 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 Some(r.clamp(-1.0, 1.0))
643 }
644 }
645}
646
647struct LevelTable {
649 map: HashMap<String, u32>,
650 next: u32,
651 capped: bool,
652}
653
654impl LevelTable {
655 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#[derive(Default)]
675struct CatCounts {
676 present: usize,
677 map: HashMap<u32, usize>,
678}
679
680struct 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 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
764struct 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 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
793struct 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#[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 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, };
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 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 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
923fn 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 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 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 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 assert_eq!(c.unique, 3);
1035 assert!(c.top_values.iter().any(|(v, _)| v == "oops"));
1036 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 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 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 assert!((na.mean - 25_000.5).abs() < 1e-6);
1117 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 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 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 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 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 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 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 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 assert_eq!(streamed.duplicate_rows, 1);
1302 drop(dir);
1303 }
1304
1305 #[test]
1306 fn non_finite_parses_stay_numeric_and_missing() {
1307 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}