Skip to main content

dataprof_runtime/
streaming_stats.rs

1use rand::rngs::SmallRng;
2use rand::{Rng, SeedableRng};
3use std::collections::{HashMap, HashSet};
4use std::fmt::Write as _;
5
6use crate::{ValueHintBindingAccumulator, profile_builder::infer_data_type_streaming};
7use dataprof_core::{SemanticHintBinding, SemanticHintKind, SemanticHints};
8use dataprof_metrics::analysis::inference::is_null_like_token;
9use dataprof_metrics::{
10    CardinalityEstimator, HyperLogLog, RowCompletenessSummary, RowDuplicateSummary,
11    value_matches_hint,
12};
13
14/// Incremental statistics computation for streaming data processing.
15///
16/// This module provides bounded-memory statistical computation using:
17/// - **Welford's algorithm** for numerically stable variance/stddev (O(1) memory)
18/// - **HyperLogLog** for approximate distinct counts (~16 KB fixed registers)
19/// - **Reservoir sampling** for unbiased samples (fixed capacity; total memory
20///   depends on the capacity and the length of sampled strings)
21/// - **Streaming text-length tracking** with min/max/mean/histogram (O(1) memory)
22
23#[derive(Debug, Clone)]
24pub struct WelfordAccumulator {
25    count: u64,
26    mean: f64,
27    m2: f64,
28}
29
30impl WelfordAccumulator {
31    pub fn new() -> Self {
32        Self {
33            count: 0,
34            mean: 0.0,
35            m2: 0.0,
36        }
37    }
38
39    #[inline]
40    pub fn update(&mut self, value: f64) {
41        self.count += 1;
42        let delta = value - self.mean;
43        self.mean += delta / self.count as f64;
44        let delta2 = value - self.mean;
45        self.m2 += delta * delta2;
46    }
47
48    #[inline]
49    pub fn mean(&self) -> f64 {
50        if self.count == 0 { 0.0 } else { self.mean }
51    }
52
53    /// Number of values folded into this accumulator.
54    #[inline]
55    pub fn count(&self) -> u64 {
56        self.count
57    }
58
59    pub fn variance(&self) -> f64 {
60        if self.count < 2 {
61            0.0
62        } else {
63            self.m2 / self.count as f64
64        }
65    }
66
67    pub fn std_dev(&self) -> f64 {
68        self.variance().sqrt()
69    }
70
71    /// Unbiased sample variance (n-1 denominator), matching the convention of
72    /// the batch numeric stats in `dataprof-metrics`.
73    pub fn sample_variance(&self) -> f64 {
74        if self.count < 2 {
75            0.0
76        } else {
77            (self.m2 / (self.count - 1) as f64).max(0.0)
78        }
79    }
80
81    /// Standard deviation derived from [`Self::sample_variance`].
82    pub fn sample_std_dev(&self) -> f64 {
83        self.sample_variance().sqrt()
84    }
85
86    pub fn merge(&mut self, other: &WelfordAccumulator) {
87        if other.count == 0 {
88            return;
89        }
90        if self.count == 0 {
91            *self = other.clone();
92            return;
93        }
94
95        let combined_count = self.count + other.count;
96        let delta = other.mean - self.mean;
97        let new_mean = self.mean + delta * (other.count as f64 / combined_count as f64);
98        let new_m2 = self.m2
99            + other.m2
100            + delta * delta * (self.count as f64 * other.count as f64 / combined_count as f64);
101
102        self.count = combined_count;
103        self.mean = new_mean;
104        self.m2 = new_m2;
105    }
106}
107
108impl Default for WelfordAccumulator {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114#[derive(Debug, Clone)]
115pub struct StreamReservoirSampler {
116    reservoir: Vec<String>,
117    capacity: usize,
118    count: u64,
119    rng: SmallRng,
120}
121
122impl StreamReservoirSampler {
123    const DEFAULT_SEED: u64 = 0xDA7A_900D_F00D_5EED;
124
125    pub fn new(capacity: usize) -> Self {
126        let capacity = capacity.max(1);
127        Self {
128            reservoir: Vec::with_capacity(capacity.min(1024)),
129            capacity,
130            count: 0,
131            // Profiling the same ordered source must produce the same report.
132            // Callers that need randomized admission have an explicit sampling
133            // strategy; this internal bounded-memory sample is deterministic.
134            rng: SmallRng::seed_from_u64(Self::DEFAULT_SEED),
135        }
136    }
137
138    #[cfg(test)]
139    pub fn seed(capacity: usize, seed: u64) -> Self {
140        Self {
141            reservoir: Vec::with_capacity(capacity.min(1024)),
142            capacity,
143            count: 0,
144            rng: SmallRng::seed_from_u64(seed),
145        }
146    }
147
148    #[inline]
149    pub fn offer(&mut self, value: String) {
150        self.count += 1;
151        if self.reservoir.len() < self.capacity {
152            self.reservoir.push(value);
153        } else {
154            let index = self.rng.random_range(0..self.count as usize);
155            if index < self.capacity {
156                self.reservoir[index] = value;
157            }
158        }
159    }
160
161    pub fn shrink_to(&mut self, new_capacity: usize) {
162        let new_capacity = new_capacity.max(1);
163        self.capacity = new_capacity;
164        self.reservoir.truncate(new_capacity);
165        self.reservoir.shrink_to_fit();
166    }
167
168    pub fn samples(&self) -> &[String] {
169        &self.reservoir
170    }
171
172    pub fn memory_usage_bytes(&self) -> usize {
173        self.reservoir
174            .iter()
175            .map(|value| std::mem::size_of::<String>() + value.capacity())
176            .sum()
177    }
178
179    pub fn merge(&mut self, other: &StreamReservoirSampler) {
180        if other.count == 0 {
181            return;
182        }
183
184        let mut combined: Vec<String> = std::mem::take(&mut self.reservoir);
185        combined.extend(other.reservoir.iter().cloned());
186
187        let total = combined.len();
188        if total <= self.capacity {
189            self.reservoir = combined;
190        } else {
191            for index in 0..self.capacity {
192                let swap_with = self.rng.random_range(index..total);
193                combined.swap(index, swap_with);
194            }
195            combined.truncate(self.capacity);
196            self.reservoir = combined;
197        }
198
199        self.count += other.count;
200    }
201}
202
203#[derive(Debug, Clone)]
204pub struct TextLengthStats {
205    pub min_length: usize,
206    pub max_length: usize,
207    pub avg_length: f64,
208    welford: WelfordAccumulator,
209    histogram: [u64; 32],
210}
211
212impl TextLengthStats {
213    pub fn new() -> Self {
214        Self {
215            min_length: usize::MAX,
216            max_length: 0,
217            avg_length: 0.0,
218            welford: WelfordAccumulator::new(),
219            histogram: [0u64; 32],
220        }
221    }
222
223    pub fn update(&mut self, length: usize) {
224        self.min_length = self.min_length.min(length);
225        self.max_length = self.max_length.max(length);
226        self.welford.update(length as f64);
227        self.avg_length = self.welford.mean();
228
229        let bucket = if length == 0 {
230            0
231        } else {
232            (usize::BITS - length.leading_zeros()).min(31) as usize
233        };
234        self.histogram[bucket] += 1;
235    }
236
237    pub fn merge(&mut self, other: &TextLengthStats) {
238        if other.welford.count == 0 {
239            return;
240        }
241        if self.welford.count == 0 {
242            *self = other.clone();
243            return;
244        }
245
246        self.min_length = self.min_length.min(other.min_length);
247        self.max_length = self.max_length.max(other.max_length);
248        self.welford.merge(&other.welford);
249        self.avg_length = self.welford.mean();
250
251        for (left, right) in self.histogram.iter_mut().zip(other.histogram.iter()) {
252            *left += *right;
253        }
254    }
255
256    pub fn empty() -> Self {
257        Self {
258            min_length: 0,
259            max_length: 0,
260            avg_length: 0.0,
261            welford: WelfordAccumulator::new(),
262            histogram: [0u64; 32],
263        }
264    }
265}
266
267impl Default for TextLengthStats {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273#[derive(Debug, Clone)]
274pub struct StreamingStatistics {
275    pub count: usize,
276    pub null_count: usize,
277    pub min: f64,
278    pub max: f64,
279    welford: WelfordAccumulator,
280    hll: HyperLogLog,
281    sampler: StreamReservoirSampler,
282    text_length_tracker: TextLengthStats,
283    date_match_count: usize,
284}
285
286impl StreamingStatistics {
287    pub fn new() -> Self {
288        Self {
289            count: 0,
290            null_count: 0,
291            min: f64::INFINITY,
292            max: f64::NEG_INFINITY,
293            welford: WelfordAccumulator::new(),
294            hll: HyperLogLog::new(),
295            sampler: StreamReservoirSampler::new(10_000),
296            text_length_tracker: TextLengthStats::new(),
297            date_match_count: 0,
298        }
299    }
300
301    pub fn with_sample_capacity(max_sample: usize) -> Self {
302        Self {
303            sampler: StreamReservoirSampler::new(max_sample),
304            ..Self::new()
305        }
306    }
307
308    pub fn update(&mut self, value: &str) {
309        self.count += 1;
310
311        if is_null_like_token(value) {
312            self.null_count += 1;
313            return;
314        }
315
316        self.hll.insert(value);
317        self.sampler.offer(value.to_string());
318        self.text_length_tracker.update(value.len());
319        if value_matches_hint(value, SemanticHintKind::Temporal) {
320            self.date_match_count += 1;
321        }
322
323        if let Some(number) = value.parse::<f64>().ok().filter(|num| num.is_finite()) {
324            self.welford.update(number);
325            self.min = self.min.min(number);
326            self.max = self.max.max(number);
327        }
328    }
329
330    pub fn merge(&mut self, other: &StreamingStatistics) {
331        self.count += other.count;
332        self.null_count += other.null_count;
333
334        if other.min < self.min {
335            self.min = other.min;
336        }
337        if other.max > self.max {
338            self.max = other.max;
339        }
340
341        self.welford.merge(&other.welford);
342        self.hll.merge(&other.hll);
343        self.sampler.merge(&other.sampler);
344        self.text_length_tracker.merge(&other.text_length_tracker);
345        self.date_match_count += other.date_match_count;
346    }
347
348    pub fn mean(&self) -> f64 {
349        self.welford.mean()
350    }
351
352    pub fn variance(&self) -> f64 {
353        self.welford.variance()
354    }
355
356    pub fn std_dev(&self) -> f64 {
357        self.welford.std_dev()
358    }
359
360    pub fn unique_count(&self) -> usize {
361        if !self.unique_count_is_approximate() {
362            return self.sampler.samples().iter().collect::<HashSet<_>>().len();
363        }
364        self.hll.count() as usize
365    }
366
367    pub fn unique_count_is_approximate(&self) -> bool {
368        (self.sampler.samples().len() as u64) < self.sampler.count
369    }
370
371    pub fn sample_values(&self) -> &[String] {
372        self.sampler.samples()
373    }
374
375    /// Values over the full stream accepted by the temporal calculator.
376    pub fn date_match_count(&self) -> usize {
377        self.date_match_count
378    }
379
380    /// Exact aggregates over every numeric value this column has streamed,
381    /// or `None` when no value parsed as a finite number.
382    ///
383    /// These come from the O(1)-memory min/max fields and the Welford
384    /// accumulator, so they cover the full stream even when the reservoir
385    /// sample no longer does.
386    pub fn exact_numeric_aggregates(
387        &self,
388    ) -> Option<crate::profile_builder::ExactNumericAggregates> {
389        let count = self.welford.count();
390        if count == 0 {
391            return None;
392        }
393        Some(crate::profile_builder::ExactNumericAggregates {
394            min: self.min,
395            max: self.max,
396            mean: self.welford.mean(),
397            std_dev: self.welford.sample_std_dev(),
398            variance: self.welford.sample_variance(),
399            count: count as usize,
400        })
401    }
402
403    pub fn text_length_stats(&self) -> TextLengthStats {
404        if self.text_length_tracker.welford.count == 0 {
405            return TextLengthStats::empty();
406        }
407        self.text_length_tracker.clone()
408    }
409
410    pub fn reduce_sample_capacity(&mut self) {
411        self.sampler.shrink_to(self.sampler.capacity / 2);
412    }
413
414    pub fn memory_usage_bytes(&self) -> usize {
415        let struct_size = std::mem::size_of::<Self>();
416        let hll_size = self.hll.memory_usage_bytes();
417        let reservoir_size = self.sampler.memory_usage_bytes();
418
419        struct_size + hll_size + reservoir_size
420    }
421}
422
423impl Default for StreamingStatistics {
424    fn default() -> Self {
425        Self::new()
426    }
427}
428
429/// Full-stream row-duplicate tracking with bounded memory.
430///
431/// Every record's fields are folded into a canonical length-prefixed
432/// signature and fed to a [`CardinalityEstimator`]: duplicates are counted
433/// exactly while the distinct-row signatures fit the estimator's exact set,
434/// and estimated (flagged approximate) once it spills to its HLL sketch.
435/// Unlike the per-column reservoirs, this sees whole rows — including
436/// null-like values — so the count is row-aligned by construction.
437#[derive(Debug, Clone, Default)]
438pub struct RowUniquenessTracker {
439    rows_seen: usize,
440    distinct: CardinalityEstimator,
441}
442
443impl RowUniquenessTracker {
444    pub fn observe(&mut self, signature: String) {
445        self.rows_seen += 1;
446        self.distinct.insert_owned(signature);
447    }
448
449    pub fn rows_seen(&self) -> usize {
450        self.rows_seen
451    }
452
453    /// Rows minus distinct rows; exact until the estimator spills.
454    pub fn duplicate_rows(&self) -> usize {
455        self.rows_seen.saturating_sub(self.distinct.estimate())
456    }
457
458    pub fn is_approximate(&self) -> bool {
459        self.distinct.is_approximate()
460    }
461
462    pub fn merge(&mut self, other: &RowUniquenessTracker) {
463        self.rows_seen += other.rows_seen;
464        self.distinct.merge(&other.distinct);
465    }
466
467    pub fn memory_usage_bytes(&self) -> usize {
468        self.distinct.memory_usage_bytes()
469    }
470
471    /// Summary for quality metrics, or `None` when no rows were observed
472    /// (e.g. an engine that never fed whole records through this tracker).
473    pub fn summary(&self) -> Option<RowDuplicateSummary> {
474        if self.rows_seen == 0 {
475            return None;
476        }
477        Some(RowDuplicateSummary {
478            duplicate_rows: self.duplicate_rows(),
479            rows_checked: self.rows_seen,
480            approximate: self.is_approximate(),
481        })
482    }
483}
484
485/// Full-stream count of records in which every field is present.
486///
487/// Completeness of a *record* is not recoverable from per-column null
488/// totals: those say how many nulls exist, not whether two of them shared a
489/// row. One counter fed whole rows answers it exactly, at any scale and
490/// regardless of sampling.
491#[derive(Debug, Clone, Default)]
492pub struct RowCompletenessTracker {
493    rows_seen: usize,
494    complete_rows: usize,
495}
496
497impl RowCompletenessTracker {
498    /// Record one row. `had_null` is true when any of its fields was absent.
499    pub fn observe(&mut self, had_null: bool) {
500        self.rows_seen += 1;
501        if !had_null {
502            self.complete_rows += 1;
503        }
504    }
505
506    /// A column appeared after rows had already been counted, so every row
507    /// counted so far is missing it and none of them was complete after all.
508    pub fn invalidate_completed_rows(&mut self) {
509        self.complete_rows = 0;
510    }
511
512    /// Count complete records across columns that are already row-aligned
513    /// and hold every value, as the database and ad-hoc input paths do.
514    ///
515    /// A row shorter than `total_rows` in some column is missing that field,
516    /// which is the same as holding a null there — the reading
517    /// [`StreamingColumnCollection::process_record`] gives ragged records.
518    pub fn observe_aligned_columns(&mut self, columns: &[&[String]], total_rows: usize) {
519        if columns.is_empty() {
520            return;
521        }
522        for row_index in 0..total_rows {
523            let had_null = columns.iter().any(|cells| {
524                cells
525                    .get(row_index)
526                    .is_none_or(|value| is_null_like_token(value))
527            });
528            self.observe(had_null);
529        }
530    }
531
532    pub fn merge(&mut self, other: &RowCompletenessTracker) {
533        self.rows_seen += other.rows_seen;
534        self.complete_rows += other.complete_rows;
535    }
536
537    /// Exact complete-record counts, or `None` when no rows were observed.
538    pub fn summary(&self) -> Option<RowCompletenessSummary> {
539        if self.rows_seen == 0 {
540            return None;
541        }
542        Some(RowCompletenessSummary {
543            complete_rows: self.complete_rows,
544            rows_checked: self.rows_seen,
545        })
546    }
547}
548
549pub struct StreamingColumnCollection {
550    columns: HashMap<String, StreamingStatistics>,
551    ordered_names: Vec<String>,
552    memory_limit_bytes: usize,
553    row_tracker: RowUniquenessTracker,
554    completeness_tracker: RowCompletenessTracker,
555    hint_bindings: ValueHintBindingAccumulator,
556}
557
558impl StreamingColumnCollection {
559    pub fn new() -> Self {
560        Self {
561            columns: HashMap::new(),
562            ordered_names: Vec::new(),
563            memory_limit_bytes: 100 * 1024 * 1024,
564            row_tracker: RowUniquenessTracker::default(),
565            completeness_tracker: RowCompletenessTracker::default(),
566            hint_bindings: ValueHintBindingAccumulator::default(),
567        }
568    }
569
570    pub fn memory_limit(limit_mb: usize) -> Self {
571        Self {
572            columns: HashMap::new(),
573            ordered_names: Vec::new(),
574            memory_limit_bytes: limit_mb * 1024 * 1024,
575            row_tracker: RowUniquenessTracker::default(),
576            completeness_tracker: RowCompletenessTracker::default(),
577            hint_bindings: ValueHintBindingAccumulator::default(),
578        }
579    }
580
581    /// Configure value-driven semantic hints before records are processed.
582    pub fn with_semantic_hints(mut self, hints: &SemanticHints) -> Self {
583        self.hint_bindings = ValueHintBindingAccumulator::new(hints);
584        self
585    }
586
587    pub fn init_columns(&mut self, headers: &[String]) {
588        for header in headers {
589            if !self.columns.contains_key(header) {
590                self.columns
591                    .insert(header.clone(), StreamingStatistics::default());
592                self.ordered_names.push(header.clone());
593            }
594        }
595    }
596
597    /// Add a column discovered after `prior_rows` records have already passed.
598    ///
599    /// JSON objects may introduce keys at any point in a stream. Those earlier
600    /// objects are missing the new key, so the column must start with matching
601    /// total/null counters rather than looking shorter and more complete than
602    /// the dataset.
603    pub fn init_column_with_missing(&mut self, header: &str, prior_rows: usize) {
604        if self.columns.contains_key(header) {
605            return;
606        }
607
608        let stats = StreamingStatistics {
609            count: prior_rows,
610            null_count: prior_rows,
611            ..Default::default()
612        };
613        self.columns.insert(header.to_string(), stats);
614        self.ordered_names.push(header.to_string());
615        if prior_rows > 0 {
616            self.completeness_tracker.invalidate_completed_rows();
617        }
618    }
619
620    pub fn process_record<I>(&mut self, headers: &[String], values: I)
621    where
622        I: IntoIterator<Item = String>,
623    {
624        // Length-prefixed so field boundaries are unambiguous:
625        // ["ab", "c"] and ["a", "bc"] must produce different signatures.
626        let mut row_signature = String::new();
627        let mut row_has_null = false;
628        let mut values = values.into_iter();
629
630        // Headers define the row schema. Normalize a missing trailing field to
631        // the profiler's empty/null representation so ragged flexible records
632        // update every column and hash identically to an explicit empty field.
633        for header in headers {
634            let value = values.next().unwrap_or_default();
635            let _ = write!(row_signature, "{}:", value.len());
636            row_signature.push_str(&value);
637
638            if !self.columns.contains_key(header) {
639                self.ordered_names.push(header.clone());
640            }
641            let stats = self.columns.entry(header.to_string()).or_default();
642            stats.update(&value);
643            // Same null definition the column counters use, so the record
644            // count and the cell counts always describe the same nulls.
645            row_has_null |= is_null_like_token(&value);
646            self.hint_bindings.observe(header, &value);
647        }
648
649        if !headers.is_empty() {
650            self.row_tracker.observe(row_signature);
651            self.completeness_tracker.observe(row_has_null);
652        }
653    }
654
655    /// Full-stream row-duplicate counts, or `None` when no rows were seen.
656    pub fn row_duplicate_summary(&self) -> Option<RowDuplicateSummary> {
657        self.row_tracker.summary()
658    }
659
660    /// Full-stream complete-record counts, or `None` when no rows were seen.
661    pub fn row_completeness_summary(&self) -> Option<RowCompletenessSummary> {
662        self.completeness_tracker.summary()
663    }
664
665    /// Exact value-driven semantic-hint evidence over every processed record.
666    pub fn semantic_hint_bindings(&self) -> Vec<SemanticHintBinding> {
667        self.hint_bindings
668            .bindings(self.ordered_names.iter().map(String::as_str))
669    }
670
671    pub fn get_column_stats(&self, column_name: &str) -> Option<&StreamingStatistics> {
672        self.columns.get(column_name)
673    }
674
675    pub fn column_names(&self) -> Vec<String> {
676        self.ordered_names.clone()
677    }
678
679    pub fn memory_usage_bytes(&self) -> usize {
680        self.columns
681            .values()
682            .map(|stats| stats.memory_usage_bytes())
683            .sum::<usize>()
684            + self.row_tracker.memory_usage_bytes()
685    }
686
687    pub fn is_memory_pressure(&self) -> bool {
688        self.memory_usage_bytes() > (self.memory_limit_bytes * 80 / 100)
689    }
690
691    pub fn reduce_memory_usage(&mut self) {
692        for stats in self.columns.values_mut() {
693            stats.reduce_sample_capacity();
694        }
695    }
696
697    /// Fingerprint of each column's currently inferred data type.
698    ///
699    /// Returns a `u64` hash suitable for cheap comparison in a schema
700    /// stability tracker.
701    pub fn column_type_fingerprint(&self) -> u64 {
702        use std::collections::hash_map::DefaultHasher;
703        use std::hash::{Hash, Hasher};
704
705        let mut hasher = DefaultHasher::new();
706        let mut names: Vec<&String> = self.columns.keys().collect();
707        names.sort();
708        for name in names {
709            let stats = &self.columns[name];
710            let data_type = infer_data_type_streaming(stats);
711            name.hash(&mut hasher);
712            std::mem::discriminant(&data_type).hash(&mut hasher);
713        }
714        hasher.finish()
715    }
716
717    pub fn merge(&mut self, other: StreamingColumnCollection) {
718        for (column_name, other_stats) in other.columns {
719            match self.columns.get_mut(&column_name) {
720                Some(existing_stats) => existing_stats.merge(&other_stats),
721                None => {
722                    self.columns.insert(column_name, other_stats);
723                }
724            }
725        }
726        self.row_tracker.merge(&other.row_tracker);
727        self.hint_bindings.merge(&other.hint_bindings);
728    }
729}
730
731impl Default for StreamingColumnCollection {
732    fn default() -> Self {
733        Self::new()
734    }
735}
736
737#[cfg(test)]
738mod row_tracker_tests {
739    use super::*;
740
741    fn record(collection: &mut StreamingColumnCollection, headers: &[String], values: &[&str]) {
742        collection.process_record(headers, values.iter().map(|v| v.to_string()));
743    }
744
745    fn completeness(collection: &StreamingColumnCollection) -> (usize, usize) {
746        let summary = collection
747            .row_completeness_summary()
748            .expect("rows were observed");
749        (summary.complete_rows, summary.rows_checked)
750    }
751
752    #[test]
753    fn test_complete_rows_count_rows_not_null_cells() {
754        let headers = vec!["a".to_string(), "b".to_string()];
755        let mut collection = StreamingColumnCollection::new();
756        // Both nulls land in the same row, so 3 of 4 rows are complete. Per
757        // column the nulls total 2, which the cell-based lower bound would
758        // read as only 2 complete rows.
759        record(&mut collection, &headers, &["", ""]);
760        record(&mut collection, &headers, &["x", "1"]);
761        record(&mut collection, &headers, &["y", "2"]);
762        record(&mut collection, &headers, &["z", "3"]);
763
764        assert_eq!(completeness(&collection), (3, 4));
765    }
766
767    #[test]
768    fn test_null_like_tokens_make_a_row_incomplete() {
769        let headers = vec!["a".to_string(), "b".to_string()];
770        let mut collection = StreamingColumnCollection::new();
771        // The column counters treat these as nulls, so the record count must
772        // too, or the two halves of the dimension describe different data.
773        record(&mut collection, &headers, &["x", "NULL"]);
774        record(&mut collection, &headers, &["y", "NaN"]);
775        record(&mut collection, &headers, &["z", "  "]);
776        record(&mut collection, &headers, &["w", "3"]);
777
778        assert_eq!(completeness(&collection), (1, 4));
779    }
780
781    #[test]
782    fn test_ragged_rows_are_incomplete() {
783        let headers = vec!["a".to_string(), "b".to_string()];
784        let mut collection = StreamingColumnCollection::new();
785        record(&mut collection, &headers, &["x"]);
786        record(&mut collection, &headers, &["y", "1"]);
787
788        assert_eq!(completeness(&collection), (1, 2));
789    }
790
791    #[test]
792    fn test_a_late_column_makes_earlier_rows_incomplete() {
793        let mut collection = StreamingColumnCollection::new();
794        let first = vec!["a".to_string()];
795        record(&mut collection, &first, &["x"]);
796        record(&mut collection, &first, &["y"]);
797        assert_eq!(completeness(&collection), (2, 2));
798
799        // A JSON object introduces `b` on the third record. The first two
800        // records never carried it, so neither of them was complete.
801        collection.init_column_with_missing("b", 2);
802        let both = vec!["a".to_string(), "b".to_string()];
803        record(&mut collection, &both, &["z", "1"]);
804
805        assert_eq!(completeness(&collection), (1, 3));
806    }
807
808    #[test]
809    fn test_no_rows_means_no_completeness_summary() {
810        let collection = StreamingColumnCollection::new();
811        assert!(collection.row_completeness_summary().is_none());
812    }
813
814    #[test]
815    fn test_aligned_columns_count_the_same_complete_rows() {
816        let a: Vec<String> = ["", "x", "y"].iter().map(|v| v.to_string()).collect();
817        let b: Vec<String> = ["", "1", "2"].iter().map(|v| v.to_string()).collect();
818        let mut tracker = RowCompletenessTracker::default();
819        tracker.observe_aligned_columns(&[a.as_slice(), b.as_slice()], 3);
820
821        let summary = tracker.summary().expect("rows were observed");
822        assert_eq!((summary.complete_rows, summary.rows_checked), (2, 3));
823    }
824
825    #[test]
826    fn test_aligned_columns_treat_a_short_column_as_missing() {
827        let a: Vec<String> = ["x", "y"].iter().map(|v| v.to_string()).collect();
828        let b: Vec<String> = ["1"].iter().map(|v| v.to_string()).collect();
829        let mut tracker = RowCompletenessTracker::default();
830        tracker.observe_aligned_columns(&[a.as_slice(), b.as_slice()], 2);
831
832        let summary = tracker.summary().expect("rows were observed");
833        assert_eq!((summary.complete_rows, summary.rows_checked), (1, 2));
834    }
835
836    #[test]
837    fn test_exact_duplicates_including_null_rows() {
838        let headers = vec!["a".to_string(), "b".to_string()];
839        let mut collection = StreamingColumnCollection::new();
840        record(&mut collection, &headers, &["x", ""]);
841        record(&mut collection, &headers, &["x", ""]);
842        record(&mut collection, &headers, &["x", "1"]);
843        record(&mut collection, &headers, &["", ""]);
844
845        let summary = collection
846            .row_duplicate_summary()
847            .expect("rows were observed");
848        assert_eq!(summary.rows_checked, 4);
849        // Null-like values are part of the row identity: the per-column
850        // reservoirs drop them, but the row tracker must not.
851        assert_eq!(summary.duplicate_rows, 1);
852        assert!(!summary.approximate);
853    }
854
855    #[test]
856    fn test_field_boundaries_are_unambiguous() {
857        let headers = vec!["a".to_string(), "b".to_string()];
858        let mut collection = StreamingColumnCollection::new();
859        record(&mut collection, &headers, &["ab", "c"]);
860        record(&mut collection, &headers, &["a", "bc"]);
861
862        let summary = collection
863            .row_duplicate_summary()
864            .expect("rows were observed");
865        assert_eq!(
866            summary.duplicate_rows, 0,
867            "different field splits must not collide"
868        );
869    }
870
871    #[test]
872    fn test_ragged_rows_normalize_missing_trailing_fields() {
873        let headers = vec!["a".to_string(), "b".to_string()];
874        let mut collection = StreamingColumnCollection::new();
875        record(&mut collection, &headers, &["x"]);
876        record(&mut collection, &headers, &["x", ""]);
877
878        let summary = collection
879            .row_duplicate_summary()
880            .expect("rows were observed");
881        assert_eq!(summary.rows_checked, 2);
882        assert_eq!(summary.duplicate_rows, 1);
883        assert_eq!(
884            collection
885                .get_column_stats("b")
886                .expect("column b")
887                .null_count,
888            2
889        );
890    }
891
892    #[test]
893    fn test_no_rows_means_no_summary() {
894        let collection = StreamingColumnCollection::new();
895        assert!(collection.row_duplicate_summary().is_none());
896    }
897
898    #[test]
899    fn test_spills_to_approximate_beyond_distinct_threshold() {
900        let headers = vec!["n".to_string()];
901        let mut collection = StreamingColumnCollection::new();
902        let distinct = dataprof_metrics::EXACT_CARDINALITY_THRESHOLD + 500;
903        for i in 0..distinct {
904            record(&mut collection, &headers, &[&i.to_string()]);
905        }
906        // Every row twice: duplicates == distinct.
907        for i in 0..distinct {
908            record(&mut collection, &headers, &[&i.to_string()]);
909        }
910
911        let summary = collection
912            .row_duplicate_summary()
913            .expect("rows were observed");
914        assert!(summary.approximate, "past the threshold the count is HLL");
915        assert_eq!(summary.rows_checked, distinct * 2);
916        let error = (summary.duplicate_rows as f64 - distinct as f64).abs() / distinct as f64;
917        assert!(
918            error < 0.05,
919            "estimated {} duplicates for {distinct} true, off by {error:.4}",
920            summary.duplicate_rows
921        );
922    }
923
924    #[test]
925    fn test_merge_combines_row_trackers() {
926        let headers = vec!["a".to_string()];
927        let mut left = StreamingColumnCollection::new();
928        let mut right = StreamingColumnCollection::new();
929        record(&mut left, &headers, &["x"]);
930        record(&mut left, &headers, &["y"]);
931        record(&mut right, &headers, &["x"]);
932
933        left.merge(right);
934        let summary = left.row_duplicate_summary().expect("rows were observed");
935        assert_eq!(summary.rows_checked, 3);
936        assert_eq!(summary.duplicate_rows, 1);
937    }
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943
944    #[test]
945    fn test_streaming_statistics() {
946        let mut stats = StreamingStatistics::new();
947
948        stats.update("10.5");
949        stats.update("20.0");
950        stats.update("15.5");
951        stats.update("");
952
953        assert_eq!(stats.count, 4);
954        assert_eq!(stats.null_count, 1);
955        assert_eq!(stats.unique_count(), 3);
956        assert!(!stats.unique_count_is_approximate());
957        assert!((stats.mean() - 15.333333333333334).abs() < 1e-10);
958        assert_eq!(stats.min, 10.5);
959        assert_eq!(stats.max, 20.0);
960    }
961
962    #[test]
963    fn test_streaming_statistics_merge() {
964        let mut stats1 = StreamingStatistics::new();
965        stats1.update("10");
966        stats1.update("20");
967
968        let mut stats2 = StreamingStatistics::new();
969        stats2.update("30");
970        stats2.update("40");
971
972        stats1.merge(&stats2);
973
974        assert_eq!(stats1.count, 4);
975        assert_eq!(stats1.unique_count(), 4);
976        assert!(!stats1.unique_count_is_approximate());
977        assert!((stats1.mean() - 25.0).abs() < 1e-10);
978        assert_eq!(stats1.min, 10.0);
979        assert_eq!(stats1.max, 40.0);
980    }
981
982    #[test]
983    fn test_column_collection() {
984        let mut collection = StreamingColumnCollection::new();
985        let headers = vec!["name".to_string(), "age".to_string()];
986
987        collection.process_record(&headers, vec!["Alice".to_string(), "25".to_string()]);
988        collection.process_record(&headers, vec!["Bob".to_string(), "30".to_string()]);
989
990        let age_stats = collection.get_column_stats("age").unwrap();
991        assert_eq!(age_stats.count, 2);
992        assert!((age_stats.mean() - 27.5).abs() < 1e-10);
993    }
994
995    #[test]
996    fn test_unique_count_becomes_approximate_only_after_reservoir_truncation() {
997        let mut stats = StreamingStatistics::with_sample_capacity(2);
998        stats.update("a");
999        stats.update("b");
1000        assert_eq!(stats.unique_count(), 2);
1001        assert!(!stats.unique_count_is_approximate());
1002
1003        stats.update("c");
1004        assert!(stats.unique_count_is_approximate());
1005    }
1006
1007    #[test]
1008    fn test_default_reservoir_sampling_is_deterministic() {
1009        let mut left = StreamReservoirSampler::new(10);
1010        let mut right = StreamReservoirSampler::new(10);
1011        for value in 0..1_000 {
1012            left.offer(value.to_string());
1013            right.offer(value.to_string());
1014        }
1015
1016        assert_eq!(left.samples(), right.samples());
1017    }
1018
1019    #[test]
1020    fn test_reservoir_zero_capacity_still_retains_a_sample() {
1021        let mut sampler = StreamReservoirSampler::new(0);
1022        sampler.offer("value".to_string());
1023
1024        assert_eq!(sampler.samples(), ["value"]);
1025    }
1026
1027    #[test]
1028    fn test_late_column_is_backfilled_as_missing() {
1029        let mut collection = StreamingColumnCollection::new();
1030        collection.init_column_with_missing("late", 3);
1031        collection.process_record(&["late".to_string()], ["value".to_string()]);
1032
1033        let stats = collection.get_column_stats("late").unwrap();
1034        assert_eq!(stats.count, 4);
1035        assert_eq!(stats.null_count, 3);
1036        assert_eq!(stats.unique_count(), 1);
1037    }
1038
1039    #[test]
1040    fn test_welford_accuracy() {
1041        let mut accumulator = WelfordAccumulator::new();
1042        for value in 1..=1000 {
1043            accumulator.update(value as f64);
1044        }
1045        let expected_mean = 500.5;
1046        let expected_variance = (1000.0 * 1000.0 - 1.0) / 12.0;
1047        assert!((accumulator.mean() - expected_mean).abs() < 1e-6);
1048        assert!((accumulator.variance() - expected_variance).abs() < 1.0);
1049    }
1050
1051    #[test]
1052    fn test_welford_merge() {
1053        let mut left = WelfordAccumulator::new();
1054        let mut right = WelfordAccumulator::new();
1055        let mut full = WelfordAccumulator::new();
1056
1057        for value in 1..=500 {
1058            left.update(value as f64);
1059            full.update(value as f64);
1060        }
1061        for value in 501..=1000 {
1062            right.update(value as f64);
1063            full.update(value as f64);
1064        }
1065
1066        left.merge(&right);
1067        assert!((left.mean() - full.mean()).abs() < 1e-10);
1068        assert!((left.variance() - full.variance()).abs() < 1e-6);
1069    }
1070
1071    #[test]
1072    fn test_hll_cardinality() {
1073        let mut counter = HyperLogLog::new();
1074        let total = 100_000;
1075        for index in 0..total {
1076            counter.insert(&format!("item_{index}"));
1077        }
1078        let estimate = counter.count();
1079        let error = (estimate as f64 - total as f64).abs() / total as f64;
1080        assert!(error < 0.05);
1081    }
1082
1083    #[test]
1084    fn test_reservoir_uniformity() {
1085        let mut sampler = StreamReservoirSampler::seed(1000, 42);
1086        let total = 100_000;
1087        for index in 0..total {
1088            sampler.offer(index.to_string());
1089        }
1090
1091        assert_eq!(sampler.samples().len(), 1000);
1092        let values: Vec<usize> = sampler
1093            .samples()
1094            .iter()
1095            .map(|value| value.parse().unwrap())
1096            .collect();
1097        let max_value = *values.iter().max().unwrap();
1098        assert!(max_value > total / 2);
1099    }
1100
1101    #[test]
1102    fn test_text_length_stats_streaming() {
1103        let mut stats = TextLengthStats::new();
1104        for &length in &[3, 5, 10, 1, 7] {
1105            stats.update(length);
1106        }
1107        assert_eq!(stats.min_length, 1);
1108        assert_eq!(stats.max_length, 10);
1109        assert!((stats.avg_length - 5.2).abs() < 1e-10);
1110    }
1111
1112    #[test]
1113    fn test_memory_usage_bounded() {
1114        let mut stats = StreamingStatistics::new();
1115        for index in 0..50_000 {
1116            stats.update(&format!("value_{index}"));
1117        }
1118        let usage = stats.memory_usage_bytes();
1119        assert!(usage < 1_000_000);
1120    }
1121}