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