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, char_len};
8use dataprof_metrics::analysis::inference::is_null_like_token;
9use dataprof_metrics::{
10    CardinalityEstimator, NumericAccumulator, 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/// - **[`NumericAccumulator`]** for numerically stable mean/variance/stddev
18///   (O(1) memory), shared with the batch paths so every engine reports the
19///   same numbers
20/// - **CardinalityEstimator** for distinct counts: exact up to
21///   `EXACT_CARDINALITY_THRESHOLD` distinct values, then a HyperLogLog
22///   estimate (~16 KB fixed registers)
23/// - **Reservoir sampling** for unbiased samples (fixed capacity; total memory
24///   depends on the capacity and the length of sampled strings)
25/// - **Streaming text-length tracking** with min/max/mean/histogram (O(1) memory)
26
27#[derive(Debug, Clone)]
28pub struct StreamReservoirSampler {
29    reservoir: Vec<String>,
30    capacity: usize,
31    count: u64,
32    rng: SmallRng,
33}
34
35impl StreamReservoirSampler {
36    const DEFAULT_SEED: u64 = 0xDA7A_900D_F00D_5EED;
37
38    pub fn new(capacity: usize) -> Self {
39        let capacity = capacity.max(1);
40        Self {
41            reservoir: Vec::with_capacity(capacity.min(1024)),
42            capacity,
43            count: 0,
44            // Profiling the same ordered source must produce the same report.
45            // Callers that need randomized admission have an explicit sampling
46            // strategy; this internal bounded-memory sample is deterministic.
47            rng: SmallRng::seed_from_u64(Self::DEFAULT_SEED),
48        }
49    }
50
51    #[cfg(test)]
52    pub fn seed(capacity: usize, seed: u64) -> Self {
53        Self {
54            reservoir: Vec::with_capacity(capacity.min(1024)),
55            capacity,
56            count: 0,
57            rng: SmallRng::seed_from_u64(seed),
58        }
59    }
60
61    #[inline]
62    pub fn offer(&mut self, value: String) {
63        self.count += 1;
64        if self.reservoir.len() < self.capacity {
65            self.reservoir.push(value);
66        } else {
67            let index = self.rng.random_range(0..self.count as usize);
68            if index < self.capacity {
69                self.reservoir[index] = value;
70            }
71        }
72    }
73
74    /// Keep a uniformly chosen `new_capacity` of the retained values.
75    ///
76    /// A reservoir is a uniform sample as a set, not slot by slot: the values
77    /// that filled it first keep their slots until replaced, so truncating
78    /// kept the head of the stream. Under early memory pressure that made the
79    /// quality sample the first rows of the source. A uniform subset of a
80    /// uniform sample is a uniform sample, and later offers keep it one.
81    pub fn shrink_to(&mut self, new_capacity: usize) {
82        let new_capacity = new_capacity.max(1);
83        let retained = self.reservoir.len();
84        if retained > new_capacity {
85            for slot in 0..new_capacity {
86                let pick = self.rng.random_range(slot..retained);
87                self.reservoir.swap(slot, pick);
88            }
89            self.reservoir.truncate(new_capacity);
90        }
91        self.capacity = new_capacity;
92        self.reservoir.shrink_to_fit();
93    }
94
95    pub fn samples(&self) -> &[String] {
96        &self.reservoir
97    }
98
99    pub fn memory_usage_bytes(&self) -> usize {
100        self.reservoir
101            .iter()
102            .map(|value| std::mem::size_of::<String>() + value.capacity())
103            .sum()
104    }
105
106    /// Fold another sampler in, leaving every value of the combined population
107    /// equally likely to have survived.
108    ///
109    /// The two reservoirs stand for populations of very different sizes: a
110    /// partition of a million rows and one of a hundred each hand over at most
111    /// `capacity` values. Pooling the reservoirs and subsampling uniformly
112    /// would let the small partition supply half the merged sample, so sample
113    /// values, and the patterns inferred from them, would depend on how the
114    /// input happened to be split. The number of survivors drawn from each
115    /// side follows the hypergeometric distribution over the two population
116    /// counts instead, which is the split a single pass would have produced.
117    ///
118    /// A sampler shrunk under memory pressure holds fewer values than its
119    /// population earns it. Nothing can be conjured in their place, so the
120    /// shortfall is filled from the other side rather than returning a sample
121    /// shorter than the capacity allows.
122    pub fn merge(&mut self, other: &StreamReservoirSampler) {
123        if other.count == 0 {
124            return;
125        }
126
127        let available_self = self.reservoir.len();
128        let available_other = other.reservoir.len();
129        let target = self.capacity.min(available_self + available_other);
130
131        let mut take_self = 0usize;
132        let mut take_other = 0usize;
133        let mut population_self = self.count;
134        let mut population_other = other.count;
135
136        for _ in 0..target {
137            // Fewer draws remain than the two sides hold, so at most one of
138            // them is exhausted here. Each side also keeps at least one
139            // unconsumed population row per unconsumed sample slot, so the
140            // range below is never empty.
141            let from_self = if take_self == available_self {
142                false
143            } else if take_other == available_other {
144                true
145            } else {
146                self.rng.random_range(0..population_self + population_other) < population_self
147            };
148
149            if from_self {
150                take_self += 1;
151                population_self -= 1;
152            } else {
153                take_other += 1;
154                population_other -= 1;
155            }
156        }
157
158        // Partial Fisher-Yates over each side: the first `take_*` slots become
159        // a uniform draw without replacement from that side's sample.
160        let mut merged: Vec<String> = std::mem::take(&mut self.reservoir);
161        for index in 0..take_self {
162            let swap_with = self.rng.random_range(index..available_self);
163            merged.swap(index, swap_with);
164        }
165        merged.truncate(take_self);
166
167        let mut indices: Vec<usize> = (0..available_other).collect();
168        for index in 0..take_other {
169            let swap_with = self.rng.random_range(index..available_other);
170            indices.swap(index, swap_with);
171        }
172        merged.extend(
173            indices[..take_other]
174                .iter()
175                .map(|&index| other.reservoir[index].clone()),
176        );
177
178        self.reservoir = merged;
179        self.count += other.count;
180    }
181}
182
183#[derive(Debug, Clone)]
184pub struct TextLengthStats {
185    pub min_length: usize,
186    pub max_length: usize,
187    pub avg_length: f64,
188    welford: NumericAccumulator,
189    histogram: [u64; 32],
190}
191
192impl TextLengthStats {
193    pub fn new() -> Self {
194        Self {
195            min_length: usize::MAX,
196            max_length: 0,
197            avg_length: 0.0,
198            welford: NumericAccumulator::new(),
199            histogram: [0u64; 32],
200        }
201    }
202
203    pub fn update(&mut self, length: usize) {
204        self.min_length = self.min_length.min(length);
205        self.max_length = self.max_length.max(length);
206        self.welford.update(length as f64);
207        self.avg_length = self.welford.mean();
208
209        let bucket = if length == 0 {
210            0
211        } else {
212            (usize::BITS - length.leading_zeros()).min(31) as usize
213        };
214        self.histogram[bucket] += 1;
215    }
216
217    pub fn merge(&mut self, other: &TextLengthStats) {
218        if other.welford.count() == 0 {
219            return;
220        }
221        if self.welford.count() == 0 {
222            *self = other.clone();
223            return;
224        }
225
226        self.min_length = self.min_length.min(other.min_length);
227        self.max_length = self.max_length.max(other.max_length);
228        self.welford.merge(&other.welford);
229        self.avg_length = self.welford.mean();
230
231        for (left, right) in self.histogram.iter_mut().zip(other.histogram.iter()) {
232            *left += *right;
233        }
234    }
235
236    pub fn empty() -> Self {
237        Self {
238            min_length: 0,
239            max_length: 0,
240            avg_length: 0.0,
241            welford: NumericAccumulator::new(),
242            histogram: [0u64; 32],
243        }
244    }
245}
246
247impl Default for TextLengthStats {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253#[derive(Debug, Clone)]
254pub struct StreamingStatistics {
255    pub count: usize,
256    pub null_count: usize,
257    pub min: f64,
258    pub max: f64,
259    welford: NumericAccumulator,
260    /// Distinct values over every non-null value, independent of the
261    /// reservoir: exact past the reservoir's capacity, the same estimator the
262    /// columnar and in-memory paths use.
263    distinct: CardinalityEstimator,
264    sampler: StreamReservoirSampler,
265    text_length_tracker: TextLengthStats,
266    date_match_count: usize,
267}
268
269impl StreamingStatistics {
270    pub fn new() -> Self {
271        Self {
272            count: 0,
273            null_count: 0,
274            min: f64::INFINITY,
275            max: f64::NEG_INFINITY,
276            welford: NumericAccumulator::new(),
277            distinct: CardinalityEstimator::new(),
278            sampler: StreamReservoirSampler::new(10_000),
279            text_length_tracker: TextLengthStats::new(),
280            date_match_count: 0,
281        }
282    }
283
284    pub fn with_sample_capacity(max_sample: usize) -> Self {
285        Self {
286            sampler: StreamReservoirSampler::new(max_sample),
287            ..Self::new()
288        }
289    }
290
291    pub fn update(&mut self, value: &str) {
292        self.count += 1;
293
294        if is_null_like_token(value) {
295            self.null_count += 1;
296            return;
297        }
298
299        self.distinct.insert(value);
300        self.sampler.offer(value.to_string());
301        // Unicode scalar values, not UTF-8 bytes: see `dataprof_core::text_units`.
302        self.text_length_tracker.update(char_len(value));
303        if value_matches_hint(value, SemanticHintKind::Temporal) {
304            self.date_match_count += 1;
305        }
306
307        if let Some(number) = value
308            .trim()
309            .parse::<f64>()
310            .ok()
311            .filter(|num| num.is_finite())
312        {
313            self.welford.update(number);
314            self.min = self.min.min(number);
315            self.max = self.max.max(number);
316        }
317    }
318
319    pub fn merge(&mut self, other: &StreamingStatistics) {
320        self.count += other.count;
321        self.null_count += other.null_count;
322
323        if other.min < self.min {
324            self.min = other.min;
325        }
326        if other.max > self.max {
327            self.max = other.max;
328        }
329
330        self.welford.merge(&other.welford);
331        self.distinct.merge(&other.distinct);
332        self.sampler.merge(&other.sampler);
333        self.text_length_tracker.merge(&other.text_length_tracker);
334        self.date_match_count += other.date_match_count;
335    }
336
337    pub fn mean(&self) -> f64 {
338        self.welford.mean()
339    }
340
341    pub fn variance(&self) -> f64 {
342        self.welford.population_variance()
343    }
344
345    pub fn std_dev(&self) -> f64 {
346        self.welford.population_std_dev()
347    }
348
349    /// Distinct non-null values, exact until the estimator spills.
350    ///
351    /// This used to count the reservoir when it held every value and fall
352    /// back to a sketch otherwise, so any column longer than the reservoir,
353    /// even one with a single value, reported an approximate count, and a
354    /// different one from the engines that use the shared estimator.
355    pub fn unique_count(&self) -> usize {
356        self.distinct.estimate()
357    }
358
359    pub fn unique_count_is_approximate(&self) -> bool {
360        self.distinct.is_approximate()
361    }
362
363    /// See [`CardinalityEstimator::distinct_lower_bound`].
364    pub fn unique_count_lower_bound(&self) -> usize {
365        self.distinct.distinct_lower_bound()
366    }
367
368    /// Answer distinct counts from the sketch from here on, freeing the exact
369    /// set. For memory pressure; the count is then reported approximate.
370    pub fn spill_distinct(&mut self) {
371        self.distinct.spill();
372    }
373
374    /// Heap held by the exact distinct set: zero once it has spilled, and zero
375    /// for a column that has seen no value, so spilling it would free nothing.
376    pub fn exact_distinct_bytes(&self) -> usize {
377        self.distinct.exact_bytes()
378    }
379
380    pub fn sample_values(&self) -> &[String] {
381        self.sampler.samples()
382    }
383
384    /// Values over the full stream accepted by the temporal calculator.
385    pub fn date_match_count(&self) -> usize {
386        self.date_match_count
387    }
388
389    /// Exact aggregates over every numeric value this column has streamed,
390    /// or `None` when no value parsed as a finite number.
391    ///
392    /// These come from the O(1)-memory min/max fields and the Welford
393    /// accumulator, so they cover the full stream even when the reservoir
394    /// sample no longer does.
395    pub fn exact_numeric_aggregates(
396        &self,
397    ) -> Option<crate::profile_builder::ExactNumericAggregates> {
398        let count = self.welford.count();
399        if count == 0 {
400            return None;
401        }
402        Some(crate::profile_builder::ExactNumericAggregates {
403            min: self.min,
404            max: self.max,
405            mean: self.welford.mean(),
406            std_dev: self.welford.sample_std_dev(),
407            variance: self.welford.sample_variance(),
408            count: count as usize,
409        })
410    }
411
412    pub fn text_length_stats(&self) -> TextLengthStats {
413        if self.text_length_tracker.welford.count() == 0 {
414            return TextLengthStats::empty();
415        }
416        self.text_length_tracker.clone()
417    }
418
419    pub fn reduce_sample_capacity(&mut self) {
420        self.sampler.shrink_to(self.sampler.capacity / 2);
421    }
422
423    pub fn memory_usage_bytes(&self) -> usize {
424        let struct_size = std::mem::size_of::<Self>();
425        let distinct_size = self.distinct.memory_usage_bytes();
426        let reservoir_size = self.sampler.memory_usage_bytes();
427
428        struct_size + distinct_size + reservoir_size
429    }
430}
431
432impl Default for StreamingStatistics {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438/// Canonical encoding of one row, for duplicate detection.
439///
440/// Fields are length-prefixed so their boundaries are unambiguous:
441/// `["ab", "c"]` and `["a", "bc"]` must not sign alike. Trailing absent
442/// fields are then dropped, which makes the signature independent of how wide
443/// the schema had grown when the row was read. A JSON key discovered
444/// mid-stream appends a column, and without the trim every row that predates
445/// it signs one field shorter than an identical row read afterwards, so the
446/// two count as distinct and the duplicate total comes up silently short.
447///
448/// The trim is safe because an absent field and an explicitly empty one are
449/// the same null to this profiler: it collapses only rows that already hold
450/// equal values in every column they share. On a fixed schema it applies
451/// uniformly to every row, so it changes no count there.
452#[derive(Debug, Default)]
453pub struct RowSignature {
454    buffer: String,
455    /// Length of `buffer` through the last field that held a value.
456    len_through_last_present: usize,
457}
458
459impl RowSignature {
460    /// Append one field. An absent field is the empty string, the same
461    /// representation [`StreamingColumnCollection::process_record`] gives a
462    /// missing trailing value and an Arrow null.
463    ///
464    /// Only an exactly empty value counts as absent here, deliberately: the
465    /// broader `is_null_like_token` rule that decides `null_count` also matches
466    /// `null`, `nan` and whitespace, and trimming on it would collapse a row
467    /// ending in one of those against a row that ends in no field at all,
468    /// inventing a duplicate.
469    pub fn push_field(&mut self, value: &str) {
470        let _ = write!(self.buffer, "{}:", value.len());
471        self.buffer.push_str(value);
472        if !value.is_empty() {
473            self.len_through_last_present = self.buffer.len();
474        }
475    }
476
477    /// The signature, with trailing absent fields dropped.
478    pub fn finish(mut self) -> String {
479        self.buffer.truncate(self.len_through_last_present);
480        self.buffer
481    }
482}
483
484/// Full-stream row-duplicate tracking with bounded memory.
485///
486/// Every record's fields are folded into a [`RowSignature`] and fed to a
487/// [`CardinalityEstimator`]: duplicates are counted exactly while the
488/// distinct-row signatures fit the estimator's exact set, and estimated
489/// (flagged approximate) once it spills to its HLL sketch. Unlike the
490/// per-column reservoirs, this sees whole rows — including null-like values —
491/// so the count is row-aligned by construction.
492#[derive(Debug, Clone, Default)]
493pub struct RowUniquenessTracker {
494    rows_seen: usize,
495    distinct: CardinalityEstimator,
496    /// Set once signatures made under different schemas have been folded in.
497    ///
498    /// A signature encodes length-prefixed values and nothing else, so a row
499    /// `{a: "x"}` and a row `{b: "x"}` both sign as `1:x` while the union
500    /// schema makes them different rows; a differing field *order* collides
501    /// the same way. The signatures are already folded into the estimator and
502    /// cannot be rebuilt against the union schema, so the count is withheld
503    /// rather than reported as a plausible number. Defaults to false, which is
504    /// what a tracker fed from a single schema is.
505    incomparable_signatures: bool,
506}
507
508impl RowUniquenessTracker {
509    pub fn observe(&mut self, signature: String) {
510        self.rows_seen += 1;
511        self.distinct.insert_owned(signature);
512    }
513
514    pub fn rows_seen(&self) -> usize {
515        self.rows_seen
516    }
517
518    /// Rows minus distinct rows; exact until the estimator spills.
519    pub fn duplicate_rows(&self) -> usize {
520        self.rows_seen.saturating_sub(self.distinct.estimate())
521    }
522
523    pub fn is_approximate(&self) -> bool {
524        self.distinct.is_approximate()
525    }
526
527    /// Record that the signatures folded in so far were not all made against
528    /// the same schema, so they cannot be compared with each other.
529    pub fn mark_incomparable(&mut self) {
530        self.incomparable_signatures = true;
531    }
532
533    pub fn merge(&mut self, other: &RowUniquenessTracker) {
534        self.rows_seen += other.rows_seen;
535        self.distinct.merge(&other.distinct);
536        self.incomparable_signatures |= other.incomparable_signatures;
537    }
538
539    pub fn memory_usage_bytes(&self) -> usize {
540        self.distinct.memory_usage_bytes()
541    }
542
543    /// Heap held by the exact set of row signatures: what [`Self::spill`] frees.
544    pub fn exact_bytes(&self) -> usize {
545        self.distinct.exact_bytes()
546    }
547
548    /// Answer the duplicate count from the sketch from here on, freeing the
549    /// exact set. For memory pressure; the count is then reported approximate.
550    pub fn spill(&mut self) {
551        self.distinct.spill();
552    }
553
554    /// Summary for quality metrics, or `None` when no rows were observed
555    /// (e.g. an engine that never fed whole records through this tracker) or
556    /// when the signatures seen are not comparable with one another.
557    pub fn summary(&self) -> Option<RowDuplicateSummary> {
558        if self.rows_seen == 0 || self.incomparable_signatures {
559            return None;
560        }
561        Some(RowDuplicateSummary {
562            duplicate_rows: self.duplicate_rows(),
563            rows_checked: self.rows_seen,
564            approximate: self.is_approximate(),
565            max_duplicate_rows: self
566                .rows_seen
567                .saturating_sub(self.distinct.distinct_lower_bound()),
568        })
569    }
570}
571
572/// Full-stream count of records in which every field is present.
573///
574/// Completeness of a *record* is not recoverable from per-column null
575/// totals: those say how many nulls exist, not whether two of them shared a
576/// row. One counter fed whole rows answers it exactly, at any scale and
577/// regardless of sampling.
578#[derive(Debug, Clone, Default)]
579pub struct RowCompletenessTracker {
580    rows_seen: usize,
581    complete_rows: usize,
582}
583
584impl RowCompletenessTracker {
585    /// Record one row. `had_null` is true when any of its fields was absent.
586    pub fn observe(&mut self, had_null: bool) {
587        self.rows_seen += 1;
588        if !had_null {
589            self.complete_rows += 1;
590        }
591    }
592
593    /// A column appeared after rows had already been counted, so every row
594    /// counted so far is missing it and none of them was complete after all.
595    pub fn invalidate_completed_rows(&mut self) {
596        self.complete_rows = 0;
597    }
598
599    /// Count complete records across columns that are already row-aligned
600    /// and hold every value, as the database and ad-hoc input paths do.
601    ///
602    /// A row shorter than `total_rows` in some column is missing that field,
603    /// which is the same as holding a null there — the reading
604    /// [`StreamingColumnCollection::process_record`] gives ragged records.
605    pub fn observe_aligned_columns(&mut self, columns: &[&[String]], total_rows: usize) {
606        if columns.is_empty() {
607            return;
608        }
609        for row_index in 0..total_rows {
610            let had_null = columns.iter().any(|cells| {
611                cells
612                    .get(row_index)
613                    .is_none_or(|value| is_null_like_token(value))
614            });
615            self.observe(had_null);
616        }
617    }
618
619    pub fn merge(&mut self, other: &RowCompletenessTracker) {
620        self.rows_seen += other.rows_seen;
621        self.complete_rows += other.complete_rows;
622    }
623
624    /// Exact complete-record counts, or `None` when no rows were observed.
625    pub fn summary(&self) -> Option<RowCompletenessSummary> {
626        if self.rows_seen == 0 {
627            return None;
628        }
629        Some(RowCompletenessSummary {
630            complete_rows: self.complete_rows,
631            rows_checked: self.rows_seen,
632        })
633    }
634}
635
636pub struct StreamingColumnCollection {
637    columns: HashMap<String, StreamingStatistics>,
638    ordered_names: Vec<String>,
639    memory_limit_bytes: usize,
640    row_tracker: RowUniquenessTracker,
641    completeness_tracker: RowCompletenessTracker,
642    hint_bindings: ValueHintBindingAccumulator,
643}
644
645impl StreamingColumnCollection {
646    pub fn new() -> Self {
647        Self {
648            columns: HashMap::new(),
649            ordered_names: Vec::new(),
650            memory_limit_bytes: 100 * 1024 * 1024,
651            row_tracker: RowUniquenessTracker::default(),
652            completeness_tracker: RowCompletenessTracker::default(),
653            hint_bindings: ValueHintBindingAccumulator::default(),
654        }
655    }
656
657    pub fn memory_limit(limit_mb: usize) -> Self {
658        Self {
659            columns: HashMap::new(),
660            ordered_names: Vec::new(),
661            memory_limit_bytes: limit_mb * 1024 * 1024,
662            row_tracker: RowUniquenessTracker::default(),
663            completeness_tracker: RowCompletenessTracker::default(),
664            hint_bindings: ValueHintBindingAccumulator::default(),
665        }
666    }
667
668    /// Configure value-driven semantic hints before records are processed.
669    pub fn with_semantic_hints(mut self, hints: &SemanticHints) -> Self {
670        self.hint_bindings = ValueHintBindingAccumulator::new(hints);
671        self
672    }
673
674    pub fn init_columns(&mut self, headers: &[String]) {
675        for header in headers {
676            if !self.columns.contains_key(header) {
677                self.columns
678                    .insert(header.clone(), StreamingStatistics::default());
679                self.ordered_names.push(header.clone());
680            }
681        }
682    }
683
684    /// Add a column discovered after `prior_rows` records have already passed.
685    ///
686    /// JSON objects may introduce keys at any point in a stream. Those earlier
687    /// objects are missing the new key, so the column must start with matching
688    /// total/null counters rather than looking shorter and more complete than
689    /// the dataset.
690    pub fn init_column_with_missing(&mut self, header: &str, prior_rows: usize) {
691        if self.columns.contains_key(header) {
692            return;
693        }
694
695        let stats = StreamingStatistics {
696            count: prior_rows,
697            null_count: prior_rows,
698            ..Default::default()
699        };
700        self.columns.insert(header.to_string(), stats);
701        self.ordered_names.push(header.to_string());
702        if prior_rows > 0 {
703            self.completeness_tracker.invalidate_completed_rows();
704        }
705    }
706
707    pub fn process_record<I>(&mut self, headers: &[String], values: I)
708    where
709        I: IntoIterator<Item = String>,
710    {
711        let mut row_signature = RowSignature::default();
712        let mut row_has_null = false;
713        let mut values = values.into_iter();
714
715        // Headers define the row schema. Normalize a missing trailing field to
716        // the profiler's empty/null representation so ragged flexible records
717        // update every column and hash identically to an explicit empty field.
718        for header in headers {
719            let value = values.next().unwrap_or_default();
720            row_signature.push_field(&value);
721
722            if !self.columns.contains_key(header) {
723                self.ordered_names.push(header.clone());
724            }
725            let stats = self.columns.entry(header.to_string()).or_default();
726            stats.update(&value);
727            // Same null definition the column counters use, so the record
728            // count and the cell counts always describe the same nulls.
729            row_has_null |= is_null_like_token(&value);
730            self.hint_bindings.observe(header, &value);
731        }
732
733        if !headers.is_empty() {
734            self.row_tracker.observe(row_signature.finish());
735            self.completeness_tracker.observe(row_has_null);
736        }
737    }
738
739    /// Full-stream row-duplicate counts, or `None` when no rows were seen.
740    pub fn row_duplicate_summary(&self) -> Option<RowDuplicateSummary> {
741        self.row_tracker.summary()
742    }
743
744    /// Full-stream complete-record counts, or `None` when no rows were seen.
745    pub fn row_completeness_summary(&self) -> Option<RowCompletenessSummary> {
746        self.completeness_tracker.summary()
747    }
748
749    /// Exact value-driven semantic-hint evidence over every processed record.
750    pub fn semantic_hint_bindings(&self) -> Vec<SemanticHintBinding> {
751        self.hint_bindings
752            .bindings(self.ordered_names.iter().map(String::as_str))
753    }
754
755    pub fn get_column_stats(&self, column_name: &str) -> Option<&StreamingStatistics> {
756        self.columns.get(column_name)
757    }
758
759    pub fn column_names(&self) -> Vec<String> {
760        self.ordered_names.clone()
761    }
762
763    /// Retain only the selected columns, preserving their source order.
764    ///
765    /// Parsers call this after validating the selection against the complete
766    /// source schema. Row-level trackers intentionally remain unchanged; a
767    /// projected report withholds the row-level quality dimensions because
768    /// their meaning changes when columns are removed.
769    pub fn retain_columns(&mut self, names: &[String]) {
770        let selected = names.iter().map(String::as_str).collect::<HashSet<_>>();
771        self.columns
772            .retain(|name, _| selected.contains(name.as_str()));
773        self.ordered_names
774            .retain(|name| selected.contains(name.as_str()));
775    }
776
777    pub fn memory_usage_bytes(&self) -> usize {
778        self.columns
779            .values()
780            .map(|stats| stats.memory_usage_bytes())
781            .sum::<usize>()
782            + self.row_tracker.memory_usage_bytes()
783    }
784
785    pub fn is_memory_pressure(&self) -> bool {
786        self.memory_usage_bytes() > (self.memory_limit_bytes * 80 / 100)
787    }
788
789    pub fn reduce_memory_usage(&mut self) {
790        // Exact distinct sets grow with the data and are spilled first: the
791        // largest first, and only while still over the line, so the columns
792        // that stay exact are the ones that cost least to keep. Ties go by
793        // column order, so the choice does not depend on hash order. The row
794        // tracker goes last of the exact sets: its duplicate count feeds the
795        // score, and once spilled it is reported approximate.
796        //
797        // The reservoirs are shrunk only if spilling was not enough. They are
798        // small by comparison, and halving them on every chunk under pressure
799        // degrades every sampled statistic, which is too high a price to pay
800        // for memory the distinct sets can give back.
801        //
802        // Usage is measured once and each spill subtracts what it frees:
803        // measuring walks every reservoir, too slow to repeat per column.
804        let limit = self.memory_limit_bytes * 80 / 100;
805        let mut usage = self.memory_usage_bytes();
806        if usage <= limit {
807            return;
808        }
809        usage -= self.spill_distinct_sets(usage - limit);
810        if usage > limit {
811            usage -= self.row_tracker.exact_bytes();
812            self.row_tracker.spill();
813        }
814        if usage > limit {
815            for stats in self.columns.values_mut() {
816                stats.reduce_sample_capacity();
817            }
818        }
819    }
820
821    /// Spill column exact sets, largest first, until at least `excess` bytes
822    /// are freed or none is left. Returns the bytes freed.
823    fn spill_distinct_sets(&mut self, excess: usize) -> usize {
824        let mut by_size: Vec<(usize, usize)> = self
825            .ordered_names
826            .iter()
827            .enumerate()
828            .filter_map(|(position, name)| {
829                let bytes = self.columns.get(name)?.exact_distinct_bytes();
830                (bytes > 0).then_some((position, bytes))
831            })
832            .collect();
833        by_size.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
834        let mut freed = 0;
835        for (position, bytes) in by_size {
836            if freed >= excess {
837                break;
838            }
839            let name = &self.ordered_names[position];
840            if let Some(stats) = self.columns.get_mut(name) {
841                stats.spill_distinct();
842                freed += bytes;
843            }
844        }
845        freed
846    }
847
848    /// Fingerprint of each column's currently inferred data type.
849    ///
850    /// Returns a `u64` hash suitable for cheap comparison in a schema
851    /// stability tracker.
852    pub fn column_type_fingerprint(&self) -> u64 {
853        use std::collections::hash_map::DefaultHasher;
854        use std::hash::{Hash, Hasher};
855
856        let mut hasher = DefaultHasher::new();
857        let mut names: Vec<&String> = self.columns.keys().collect();
858        names.sort();
859        for name in names {
860            let stats = &self.columns[name];
861            let data_type = infer_data_type_streaming(stats);
862            name.hash(&mut hasher);
863            std::mem::discriminant(&data_type).hash(&mut hasher);
864        }
865        hasher.finish()
866    }
867
868    /// Fold another collection into this one, as if a single pass had read
869    /// both sides.
870    ///
871    /// The merged profile describes the union of the two schemas, and three
872    /// consequences of that are each a silently wrong number when skipped:
873    ///
874    /// - A column only one side saw has to join `ordered_names`, or
875    ///   [`Self::column_names`] omits it and the column vanishes from the
876    ///   report while its statistics sit unreachable in the map.
877    /// - That column is absent from every record the *other* side read, so it
878    ///   is padded with those rows as missing values, the way
879    ///   [`Self::init_column_with_missing`] pads a key that appears mid-stream.
880    ///   Skipping the padding leaves the column looking shorter and more
881    ///   complete than the dataset.
882    /// - Rows counted complete against the narrower schema are not complete
883    ///   against the union, so that side's complete-row count is invalidated
884    ///   for the same reason.
885    ///
886    /// Row signatures are built per record from that record's own headers, and
887    /// encode values only. Two sides that read different columns, or the same
888    /// columns in a different order, therefore sign different rows identically.
889    /// Those signatures cannot be rebuilt against the union schema once folded
890    /// into the estimator, so the merged duplicate count is withheld rather
891    /// than reported wrong.
892    pub fn merge(&mut self, other: StreamingColumnCollection) {
893        let self_rows = self.row_tracker.rows_seen();
894        let other_rows = other.row_tracker.rows_seen();
895
896        let StreamingColumnCollection {
897            columns,
898            ordered_names,
899            memory_limit_bytes: _,
900            row_tracker,
901            mut completeness_tracker,
902            hint_bindings,
903        } = other;
904
905        // Signatures are positional over the record's own headers, so they
906        // only mean the same thing on both sides when both read the same
907        // columns in the same order. Captured before the orders are joined.
908        let schemas_match = self.ordered_names == ordered_names;
909
910        // A column this side saw and the other did not is absent from every
911        // record the other side read.
912        let mut other_lacks_columns = false;
913        for (column_name, stats) in self.columns.iter_mut() {
914            if columns.contains_key(column_name) {
915                continue;
916            }
917            other_lacks_columns = true;
918            stats.count += other_rows;
919            stats.null_count += other_rows;
920        }
921
922        // Columns the other side introduces keep its discovery order, appended
923        // after the ones already known here. Names carry the order; the map
924        // carries the statistics, and the two always hold the same keys.
925        let mut self_lacks_columns = false;
926        for column_name in &ordered_names {
927            if !self.columns.contains_key(column_name) {
928                self.ordered_names.push(column_name.clone());
929                self_lacks_columns = true;
930            }
931        }
932
933        for (column_name, other_stats) in columns {
934            match self.columns.get_mut(&column_name) {
935                Some(existing_stats) => existing_stats.merge(&other_stats),
936                None => {
937                    let mut stats = other_stats;
938                    // Records this side already read had no such field.
939                    stats.count += self_rows;
940                    stats.null_count += self_rows;
941                    self.columns.insert(column_name, stats);
942                }
943            }
944        }
945
946        if self_lacks_columns && self_rows > 0 {
947            self.completeness_tracker.invalidate_completed_rows();
948        }
949        if other_lacks_columns && other_rows > 0 {
950            completeness_tracker.invalidate_completed_rows();
951        }
952
953        self.completeness_tracker.merge(&completeness_tracker);
954        self.row_tracker.merge(&row_tracker);
955        // Only a side that signed rows can make them incomparable, so folding
956        // a partition into a fresh accumulator stays a plain accumulation.
957        if self_rows > 0 && other_rows > 0 && !schemas_match {
958            self.row_tracker.mark_incomparable();
959        }
960        self.hint_bindings.merge(&hint_bindings);
961    }
962}
963
964impl Default for StreamingColumnCollection {
965    fn default() -> Self {
966        Self::new()
967    }
968}
969
970#[cfg(test)]
971mod row_tracker_tests {
972    use super::*;
973
974    fn record(collection: &mut StreamingColumnCollection, headers: &[String], values: &[&str]) {
975        collection.process_record(headers, values.iter().map(|v| v.to_string()));
976    }
977
978    fn completeness(collection: &StreamingColumnCollection) -> (usize, usize) {
979        let summary = collection
980            .row_completeness_summary()
981            .expect("rows were observed");
982        (summary.complete_rows, summary.rows_checked)
983    }
984
985    #[test]
986    fn test_complete_rows_count_rows_not_null_cells() {
987        let headers = vec!["a".to_string(), "b".to_string()];
988        let mut collection = StreamingColumnCollection::new();
989        // Both nulls land in the same row, so 3 of 4 rows are complete. Per
990        // column the nulls total 2, which the cell-based lower bound would
991        // read as only 2 complete rows.
992        record(&mut collection, &headers, &["", ""]);
993        record(&mut collection, &headers, &["x", "1"]);
994        record(&mut collection, &headers, &["y", "2"]);
995        record(&mut collection, &headers, &["z", "3"]);
996
997        assert_eq!(completeness(&collection), (3, 4));
998    }
999
1000    #[test]
1001    fn test_null_like_tokens_make_a_row_incomplete() {
1002        let headers = vec!["a".to_string(), "b".to_string()];
1003        let mut collection = StreamingColumnCollection::new();
1004        // The column counters treat these as nulls, so the record count must
1005        // too, or the two halves of the dimension describe different data.
1006        record(&mut collection, &headers, &["x", "NULL"]);
1007        record(&mut collection, &headers, &["y", "NaN"]);
1008        record(&mut collection, &headers, &["z", "  "]);
1009        record(&mut collection, &headers, &["w", "3"]);
1010
1011        assert_eq!(completeness(&collection), (1, 4));
1012    }
1013
1014    #[test]
1015    fn test_ragged_rows_are_incomplete() {
1016        let headers = vec!["a".to_string(), "b".to_string()];
1017        let mut collection = StreamingColumnCollection::new();
1018        record(&mut collection, &headers, &["x"]);
1019        record(&mut collection, &headers, &["y", "1"]);
1020
1021        assert_eq!(completeness(&collection), (1, 2));
1022    }
1023
1024    #[test]
1025    fn test_a_late_column_makes_earlier_rows_incomplete() {
1026        let mut collection = StreamingColumnCollection::new();
1027        let first = vec!["a".to_string()];
1028        record(&mut collection, &first, &["x"]);
1029        record(&mut collection, &first, &["y"]);
1030        assert_eq!(completeness(&collection), (2, 2));
1031
1032        // A JSON object introduces `b` on the third record. The first two
1033        // records never carried it, so neither of them was complete.
1034        collection.init_column_with_missing("b", 2);
1035        let both = vec!["a".to_string(), "b".to_string()];
1036        record(&mut collection, &both, &["z", "1"]);
1037
1038        assert_eq!(completeness(&collection), (1, 3));
1039    }
1040
1041    #[test]
1042    fn test_no_rows_means_no_completeness_summary() {
1043        let collection = StreamingColumnCollection::new();
1044        assert!(collection.row_completeness_summary().is_none());
1045    }
1046
1047    #[test]
1048    fn test_aligned_columns_count_the_same_complete_rows() {
1049        let a: Vec<String> = ["", "x", "y"].iter().map(|v| v.to_string()).collect();
1050        let b: Vec<String> = ["", "1", "2"].iter().map(|v| v.to_string()).collect();
1051        let mut tracker = RowCompletenessTracker::default();
1052        tracker.observe_aligned_columns(&[a.as_slice(), b.as_slice()], 3);
1053
1054        let summary = tracker.summary().expect("rows were observed");
1055        assert_eq!((summary.complete_rows, summary.rows_checked), (2, 3));
1056    }
1057
1058    #[test]
1059    fn test_aligned_columns_treat_a_short_column_as_missing() {
1060        let a: Vec<String> = ["x", "y"].iter().map(|v| v.to_string()).collect();
1061        let b: Vec<String> = ["1"].iter().map(|v| v.to_string()).collect();
1062        let mut tracker = RowCompletenessTracker::default();
1063        tracker.observe_aligned_columns(&[a.as_slice(), b.as_slice()], 2);
1064
1065        let summary = tracker.summary().expect("rows were observed");
1066        assert_eq!((summary.complete_rows, summary.rows_checked), (1, 2));
1067    }
1068
1069    #[test]
1070    fn test_exact_duplicates_including_null_rows() {
1071        let headers = vec!["a".to_string(), "b".to_string()];
1072        let mut collection = StreamingColumnCollection::new();
1073        record(&mut collection, &headers, &["x", ""]);
1074        record(&mut collection, &headers, &["x", ""]);
1075        record(&mut collection, &headers, &["x", "1"]);
1076        record(&mut collection, &headers, &["", ""]);
1077
1078        let summary = collection
1079            .row_duplicate_summary()
1080            .expect("rows were observed");
1081        assert_eq!(summary.rows_checked, 4);
1082        // Null-like values are part of the row identity: the per-column
1083        // reservoirs drop them, but the row tracker must not.
1084        assert_eq!(summary.duplicate_rows, 1);
1085        assert!(!summary.approximate);
1086    }
1087
1088    #[test]
1089    fn test_field_boundaries_are_unambiguous() {
1090        let headers = vec!["a".to_string(), "b".to_string()];
1091        let mut collection = StreamingColumnCollection::new();
1092        record(&mut collection, &headers, &["ab", "c"]);
1093        record(&mut collection, &headers, &["a", "bc"]);
1094
1095        let summary = collection
1096            .row_duplicate_summary()
1097            .expect("rows were observed");
1098        assert_eq!(
1099            summary.duplicate_rows, 0,
1100            "different field splits must not collide"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_ragged_rows_normalize_missing_trailing_fields() {
1106        let headers = vec!["a".to_string(), "b".to_string()];
1107        let mut collection = StreamingColumnCollection::new();
1108        record(&mut collection, &headers, &["x"]);
1109        record(&mut collection, &headers, &["x", ""]);
1110
1111        let summary = collection
1112            .row_duplicate_summary()
1113            .expect("rows were observed");
1114        assert_eq!(summary.rows_checked, 2);
1115        assert_eq!(summary.duplicate_rows, 1);
1116        assert_eq!(
1117            collection
1118                .get_column_stats("b")
1119                .expect("column b")
1120                .null_count,
1121            2
1122        );
1123    }
1124
1125    #[test]
1126    fn test_trailing_null_like_token_is_not_trimmed() {
1127        // Only an exactly empty field is absent. A trailing "null" or " " is
1128        // a value the row holds, and `is_null_like_token` matches both;
1129        // trimming on that broader rule would collapse these rows against the
1130        // absent-field one and report duplicates that are not there.
1131        let headers = vec!["a".to_string(), "b".to_string()];
1132        let mut collection = StreamingColumnCollection::new();
1133        record(&mut collection, &headers, &["x", "null"]);
1134        record(&mut collection, &headers, &["x", " "]);
1135        record(&mut collection, &headers, &["x", ""]);
1136
1137        let summary = collection
1138            .row_duplicate_summary()
1139            .expect("rows were observed");
1140        assert_eq!(summary.duplicate_rows, 0);
1141    }
1142
1143    #[test]
1144    fn test_late_column_does_not_split_identical_rows() {
1145        // A JSON key discovered mid-stream appends a column. Rows read before
1146        // and after that point are signed against header lists of different
1147        // lengths, and two identical records must still count as duplicates.
1148        let mut collection = StreamingColumnCollection::new();
1149        let short = vec!["a".to_string()];
1150        let wide = vec!["a".to_string(), "b".to_string()];
1151
1152        record(&mut collection, &short, &["x"]);
1153        collection.init_column_with_missing("b", 1);
1154        record(&mut collection, &wide, &["x", "y"]);
1155        record(&mut collection, &wide, &["x", ""]);
1156
1157        let summary = collection
1158            .row_duplicate_summary()
1159            .expect("rows were observed");
1160        assert_eq!(summary.rows_checked, 3);
1161        assert_eq!(
1162            summary.duplicate_rows, 1,
1163            "rows 1 and 3 hold the same data; they differ only in how many \
1164             columns had been discovered when each was read"
1165        );
1166    }
1167
1168    #[test]
1169    fn test_late_column_in_the_middle_does_not_split_identical_rows() {
1170        // A second late key pushes the first one away from the end, so the
1171        // absent field sits in the middle of the later signatures.
1172        let mut collection = StreamingColumnCollection::new();
1173        let one = vec!["a".to_string()];
1174        let two = vec!["a".to_string(), "b".to_string()];
1175        let three = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1176
1177        record(&mut collection, &one, &["1"]);
1178        collection.init_column_with_missing("b", 1);
1179        record(&mut collection, &two, &["1", "2"]);
1180        collection.init_column_with_missing("c", 2);
1181        record(&mut collection, &three, &["1", "", "3"]);
1182        record(&mut collection, &three, &["1", "", ""]);
1183
1184        let summary = collection
1185            .row_duplicate_summary()
1186            .expect("rows were observed");
1187        assert_eq!(summary.rows_checked, 4);
1188        assert_eq!(summary.duplicate_rows, 1, "row 4 repeats row 1");
1189    }
1190
1191    #[test]
1192    fn test_no_rows_means_no_summary() {
1193        let collection = StreamingColumnCollection::new();
1194        assert!(collection.row_duplicate_summary().is_none());
1195    }
1196
1197    #[test]
1198    fn a_column_longer_than_the_reservoir_keeps_an_exact_count() {
1199        // The engine used to go approximate as soon as a column outgrew its
1200        // 10,000-value reservoir: a one-value column over 20,000 rows was an
1201        // estimate, and 50,000 unique ids read 50,755.
1202        let mut constant = StreamingStatistics::new();
1203        let mut ids = StreamingStatistics::new();
1204        for id in 0..50_000 {
1205            constant.update("v1");
1206            ids.update(&id.to_string());
1207        }
1208        assert!(!constant.unique_count_is_approximate());
1209        assert_eq!(constant.unique_count(), 1);
1210        assert!(!ids.unique_count_is_approximate());
1211        assert_eq!(ids.unique_count(), 50_000);
1212    }
1213
1214    #[test]
1215    fn memory_pressure_spills_the_largest_exact_set_first() {
1216        let headers = vec!["id".to_string(), "flag".to_string()];
1217        // 300,000 fingerprints per exact set is ~4.7 MB; the row tracker
1218        // holds as much again. At 8 MB (pressure above 6.4 MB), spilling `id`
1219        // alone brings the collection back under the line.
1220        let mut collection = StreamingColumnCollection::memory_limit(8);
1221        for id in 0..300_000 {
1222            let flag = if id % 2 == 0 { "yes" } else { "no" };
1223            record(&mut collection, &headers, &[&id.to_string(), flag]);
1224        }
1225        assert!(
1226            collection.is_memory_pressure(),
1227            "300,000 exact fingerprints no longer exceed the limit; this test reaches nothing"
1228        );
1229        collection.reduce_memory_usage();
1230
1231        let id = collection.get_column_stats("id").unwrap();
1232        let flag = collection.get_column_stats("flag").unwrap();
1233        assert!(id.unique_count_is_approximate(), "the large set spills");
1234        assert!(!flag.unique_count_is_approximate(), "the small one is kept");
1235        assert_eq!(flag.unique_count(), 2);
1236        // Spilling was enough, so the sampled statistics keep their full
1237        // reservoirs rather than being halved on every chunk under pressure.
1238        assert_eq!(id.sample_values().len(), 10_000);
1239        assert_eq!(flag.sample_values().len(), 10_000);
1240    }
1241
1242    #[test]
1243    fn memory_pressure_spills_the_row_tracker_before_shrinking_reservoirs() {
1244        let headers = vec!["id".to_string(), "flag".to_string(), "blank".to_string()];
1245        // At 5 MB (pressure above 4 MB) spilling every column set still
1246        // leaves the row tracker's ~4.1 MB plus the reservoirs over the line.
1247        let mut collection = StreamingColumnCollection::memory_limit(5);
1248        for id in 0..300_000 {
1249            let flag = if id % 2 == 0 { "yes" } else { "no" };
1250            record(&mut collection, &headers, &[&id.to_string(), flag, ""]);
1251        }
1252        collection.reduce_memory_usage();
1253
1254        let rows = collection.row_duplicate_summary().unwrap();
1255        assert!(rows.approximate, "the row tracker spills");
1256        assert!(!collection.is_memory_pressure());
1257        // A column that has seen no value holds no exact set to give back,
1258        // so it keeps its exact count of zero.
1259        let blank = collection.get_column_stats("blank").unwrap();
1260        assert!(!blank.unique_count_is_approximate());
1261        assert_eq!(blank.unique_count(), 0);
1262        for name in ["id", "flag"] {
1263            let stats = collection.get_column_stats(name).unwrap();
1264            assert!(stats.unique_count_is_approximate(), "{name} spills first");
1265            assert_eq!(stats.sample_values().len(), 10_000, "{name} reservoir kept");
1266        }
1267    }
1268
1269    #[test]
1270    fn test_spills_to_approximate_beyond_distinct_threshold() {
1271        let headers = vec!["n".to_string()];
1272        let mut collection = StreamingColumnCollection::new();
1273        let distinct = dataprof_metrics::EXACT_CARDINALITY_THRESHOLD + 500;
1274        for i in 0..distinct {
1275            record(&mut collection, &headers, &[&i.to_string()]);
1276        }
1277        // Every row twice: duplicates == distinct.
1278        for i in 0..distinct {
1279            record(&mut collection, &headers, &[&i.to_string()]);
1280        }
1281
1282        let summary = collection
1283            .row_duplicate_summary()
1284            .expect("rows were observed");
1285        assert!(summary.approximate, "past the threshold the count is HLL");
1286        assert_eq!(summary.rows_checked, distinct * 2);
1287        let error = (summary.duplicate_rows as f64 - distinct as f64).abs() / distinct as f64;
1288        assert!(
1289            error < 0.05,
1290            "estimated {} duplicates for {distinct} true, off by {error:.4}",
1291            summary.duplicate_rows
1292        );
1293    }
1294
1295    #[test]
1296    fn test_merge_combines_row_trackers() {
1297        let headers = vec!["a".to_string()];
1298        let mut left = StreamingColumnCollection::new();
1299        let mut right = StreamingColumnCollection::new();
1300        record(&mut left, &headers, &["x"]);
1301        record(&mut left, &headers, &["y"]);
1302        record(&mut right, &headers, &["x"]);
1303
1304        left.merge(right);
1305        let summary = left.row_duplicate_summary().expect("rows were observed");
1306        assert_eq!(summary.rows_checked, 3);
1307        assert_eq!(summary.duplicate_rows, 1);
1308    }
1309
1310    #[test]
1311    fn test_merge_keeps_columns_only_the_other_side_saw() {
1312        let mut left = StreamingColumnCollection::new();
1313        let mut right = StreamingColumnCollection::new();
1314        record(&mut left, &["a".to_string()], &["1"]);
1315        record(&mut right, &["b".to_string()], &["2"]);
1316
1317        left.merge(right);
1318
1319        // A column reachable in the map but missing from the order is a column
1320        // the report never renders.
1321        assert_eq!(left.column_names(), vec!["a".to_string(), "b".to_string()]);
1322        assert!(left.get_column_stats("b").is_some());
1323    }
1324
1325    #[test]
1326    fn test_merge_pads_columns_the_other_side_never_saw() {
1327        let left_headers = vec!["a".to_string()];
1328        let right_headers = vec!["a".to_string(), "b".to_string()];
1329        let mut left = StreamingColumnCollection::new();
1330        let mut right = StreamingColumnCollection::new();
1331        record(&mut left, &left_headers, &["1"]);
1332        record(&mut left, &left_headers, &["2"]);
1333        record(&mut right, &right_headers, &["3", "x"]);
1334
1335        left.merge(right);
1336
1337        let b = left.get_column_stats("b").expect("column b survived");
1338        // Three records in the merged dataset, two of which had no `b` at all.
1339        assert_eq!(b.count, 3);
1340        assert_eq!(b.null_count, 2);
1341
1342        let a = left.get_column_stats("a").expect("column a");
1343        assert_eq!(a.count, 3);
1344        assert_eq!(a.null_count, 0);
1345    }
1346
1347    #[test]
1348    fn test_merge_combines_completeness() {
1349        let headers = vec!["a".to_string(), "b".to_string()];
1350        let mut left = StreamingColumnCollection::new();
1351        let mut right = StreamingColumnCollection::new();
1352        record(&mut left, &headers, &["1", "2"]);
1353        record(&mut left, &headers, &["3", ""]);
1354        record(&mut right, &headers, &["4", "5"]);
1355
1356        left.merge(right);
1357
1358        assert_eq!(completeness(&left), (2, 3));
1359    }
1360
1361    #[test]
1362    fn test_merge_invalidates_completeness_for_the_narrower_side() {
1363        let left_headers = vec!["a".to_string()];
1364        let right_headers = vec!["a".to_string(), "b".to_string()];
1365        let mut left = StreamingColumnCollection::new();
1366        let mut right = StreamingColumnCollection::new();
1367        record(&mut left, &left_headers, &["1"]);
1368        record(&mut right, &right_headers, &["2", "y"]);
1369
1370        left.merge(right);
1371
1372        // The left record has no `b`, so it is not complete against the union.
1373        assert_eq!(completeness(&left), (1, 2));
1374    }
1375
1376    #[test]
1377    fn test_merge_withholds_duplicates_when_schemas_differ() {
1378        let mut left = StreamingColumnCollection::new();
1379        let mut right = StreamingColumnCollection::new();
1380        record(&mut left, &["a".to_string()], &["x"]);
1381        record(&mut right, &["b".to_string()], &["x"]);
1382
1383        left.merge(right);
1384
1385        // Both rows sign as "1:x", so the count would claim one duplicate,
1386        // while under the union schema the rows differ in every field.
1387        assert!(left.row_duplicate_summary().is_none());
1388    }
1389
1390    #[test]
1391    fn test_merge_withholds_duplicates_when_column_order_differs() {
1392        let mut left = StreamingColumnCollection::new();
1393        let mut right = StreamingColumnCollection::new();
1394        record(&mut left, &["a".to_string(), "b".to_string()], &["x", "y"]);
1395        record(&mut right, &["b".to_string(), "a".to_string()], &["x", "y"]);
1396
1397        left.merge(right);
1398
1399        // Same columns, opposite order: the two signatures collide on rows
1400        // that hold the values the other way round.
1401        assert!(left.row_duplicate_summary().is_none());
1402    }
1403
1404    #[test]
1405    fn test_merge_keeps_duplicates_when_schemas_match() {
1406        let headers = vec!["a".to_string(), "b".to_string()];
1407        let mut left = StreamingColumnCollection::new();
1408        let mut right = StreamingColumnCollection::new();
1409        record(&mut left, &headers, &["x", "y"]);
1410        record(&mut right, &headers, &["x", "y"]);
1411
1412        left.merge(right);
1413
1414        let summary = left
1415            .row_duplicate_summary()
1416            .expect("one schema on both sides");
1417        assert_eq!(summary.rows_checked, 2);
1418        assert_eq!(summary.duplicate_rows, 1);
1419    }
1420
1421    #[test]
1422    fn test_merge_into_an_empty_accumulator_keeps_duplicates() {
1423        let headers = vec!["a".to_string()];
1424        let mut accumulator = StreamingColumnCollection::new();
1425        let mut partition = StreamingColumnCollection::new();
1426        record(&mut partition, &headers, &["x"]);
1427        record(&mut partition, &headers, &["x"]);
1428
1429        // A reduce starts from an empty accumulator. That is not a schema
1430        // mismatch: the empty side signed nothing to be confused with.
1431        accumulator.merge(partition);
1432
1433        let summary = accumulator
1434            .row_duplicate_summary()
1435            .expect("the empty side contributed no signatures");
1436        assert_eq!(summary.rows_checked, 2);
1437        assert_eq!(summary.duplicate_rows, 1);
1438    }
1439
1440    /// Merging partitions must produce the profile a single pass would.
1441    ///
1442    /// This is the acceptance criterion for any distributed profiling path:
1443    /// without it, the numbers silently depend on how the input was split.
1444    #[test]
1445    fn test_merged_partitions_match_a_single_pass() {
1446        let headers = vec!["id".to_string(), "city".to_string(), "score".to_string()];
1447        let rows: Vec<[String; 3]> = (0..300)
1448            .map(|index| {
1449                [
1450                    format!("{}", index % 10),
1451                    ["Rome", "Milan", "Turin", ""][index % 4].to_string(),
1452                    format!("{}.5", index % 5),
1453                ]
1454            })
1455            .collect();
1456
1457        let mut single = StreamingColumnCollection::new();
1458        for row in &rows {
1459            single.process_record(&headers, row.iter().cloned());
1460        }
1461
1462        // Seven partitions over 300 rows, so the split is uneven.
1463        let mut partitions: Vec<StreamingColumnCollection> =
1464            (0..7).map(|_| StreamingColumnCollection::new()).collect();
1465        for (index, row) in rows.iter().enumerate() {
1466            partitions[index % 7].process_record(&headers, row.iter().cloned());
1467        }
1468        let mut merged = partitions.remove(0);
1469        for partition in partitions {
1470            merged.merge(partition);
1471        }
1472
1473        assert_eq!(merged.column_names(), single.column_names());
1474        for name in single.column_names() {
1475            let expected = single.get_column_stats(&name).expect("single-pass column");
1476            let actual = merged.get_column_stats(&name).expect("merged column");
1477            assert_eq!(actual.count, expected.count, "count for {name}");
1478            assert_eq!(actual.null_count, expected.null_count, "nulls for {name}");
1479            assert_eq!(
1480                actual.unique_count(),
1481                expected.unique_count(),
1482                "distinct for {name}"
1483            );
1484            assert_eq!(actual.min, expected.min, "min for {name}");
1485            assert_eq!(actual.max, expected.max, "max for {name}");
1486            assert!(
1487                (actual.mean() - expected.mean()).abs() < 1e-9,
1488                "mean for {name}"
1489            );
1490            assert!(
1491                (actual.variance() - expected.variance()).abs() < 1e-9,
1492                "variance for {name}"
1493            );
1494        }
1495
1496        let merged_rows = merged.row_completeness_summary().expect("rows observed");
1497        let single_rows = single.row_completeness_summary().expect("rows observed");
1498        assert_eq!(merged_rows.rows_checked, single_rows.rows_checked);
1499        assert_eq!(merged_rows.complete_rows, single_rows.complete_rows);
1500
1501        let merged_dupes = merged.row_duplicate_summary().expect("rows observed");
1502        let single_dupes = single.row_duplicate_summary().expect("rows observed");
1503        assert_eq!(merged_dupes.rows_checked, single_dupes.rows_checked);
1504        assert_eq!(merged_dupes.duplicate_rows, single_dupes.duplicate_rows);
1505    }
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    use super::*;
1511    use dataprof_metrics::HyperLogLog;
1512
1513    #[test]
1514    fn test_streaming_statistics() {
1515        let mut stats = StreamingStatistics::new();
1516
1517        stats.update("10.5");
1518        stats.update("20.0");
1519        stats.update("15.5");
1520        stats.update("");
1521
1522        assert_eq!(stats.count, 4);
1523        assert_eq!(stats.null_count, 1);
1524        assert_eq!(stats.unique_count(), 3);
1525        assert!(!stats.unique_count_is_approximate());
1526        assert!((stats.mean() - 15.333333333333334).abs() < 1e-10);
1527        assert_eq!(stats.min, 10.5);
1528        assert_eq!(stats.max, 20.0);
1529    }
1530
1531    #[test]
1532    fn test_streaming_statistics_merge() {
1533        let mut stats1 = StreamingStatistics::new();
1534        stats1.update("10");
1535        stats1.update("20");
1536
1537        let mut stats2 = StreamingStatistics::new();
1538        stats2.update("30");
1539        stats2.update("40");
1540
1541        stats1.merge(&stats2);
1542
1543        assert_eq!(stats1.count, 4);
1544        assert_eq!(stats1.unique_count(), 4);
1545        assert!(!stats1.unique_count_is_approximate());
1546        assert!((stats1.mean() - 25.0).abs() < 1e-10);
1547        assert_eq!(stats1.min, 10.0);
1548        assert_eq!(stats1.max, 40.0);
1549    }
1550
1551    #[test]
1552    fn test_reservoir_merge_weights_sides_by_population() {
1553        let mut large = StreamReservoirSampler::new(100);
1554        for index in 0..10_000 {
1555            large.offer(format!("large-{index}"));
1556        }
1557        let mut small = StreamReservoirSampler::new(100);
1558        for index in 0..100 {
1559            small.offer(format!("small-{index}"));
1560        }
1561
1562        large.merge(&small);
1563
1564        assert_eq!(large.samples().len(), 100);
1565        let from_small = large
1566            .samples()
1567            .iter()
1568            .filter(|value| value.starts_with("small-"))
1569            .count();
1570        // The small side is 100 rows of 10,100, so it earns about one slot.
1571        // Pooling both reservoirs and subsampling uniformly would hand it
1572        // roughly half of them, making the sample depend on the partitioning.
1573        assert!(
1574            from_small <= 10,
1575            "small partition took {from_small} of 100 slots"
1576        );
1577    }
1578
1579    #[test]
1580    fn test_reservoir_merge_keeps_every_value_when_capacity_allows() {
1581        let mut left = StreamReservoirSampler::new(100);
1582        left.offer("a".to_string());
1583        left.offer("b".to_string());
1584        let mut right = StreamReservoirSampler::new(100);
1585        right.offer("c".to_string());
1586
1587        left.merge(&right);
1588
1589        let mut samples: Vec<String> = left.samples().to_vec();
1590        samples.sort();
1591        assert_eq!(samples, vec!["a", "b", "c"]);
1592        assert_eq!(left.count, 3);
1593    }
1594
1595    #[test]
1596    fn test_column_collection() {
1597        let mut collection = StreamingColumnCollection::new();
1598        let headers = vec!["name".to_string(), "age".to_string()];
1599
1600        collection.process_record(&headers, vec!["Alice".to_string(), "25".to_string()]);
1601        collection.process_record(&headers, vec!["Bob".to_string(), "30".to_string()]);
1602
1603        let age_stats = collection.get_column_stats("age").unwrap();
1604        assert_eq!(age_stats.count, 2);
1605        assert!((age_stats.mean() - 27.5).abs() < 1e-10);
1606    }
1607
1608    #[test]
1609    fn the_distinct_count_does_not_depend_on_the_reservoir() {
1610        // The reservoir bounds the sampled statistics, not the distinct count:
1611        // a column that outgrows it still counts its distinct values exactly
1612        // until the estimator itself spills.
1613        let mut stats = StreamingStatistics::with_sample_capacity(2);
1614        for value in ["a", "b", "c", "a"] {
1615            stats.update(value);
1616        }
1617        assert_eq!(stats.unique_count(), 3);
1618        assert!(!stats.unique_count_is_approximate());
1619
1620        stats.spill_distinct();
1621        assert!(stats.unique_count_is_approximate());
1622        assert_eq!(stats.unique_count(), 3);
1623    }
1624
1625    #[test]
1626    fn test_default_reservoir_sampling_is_deterministic() {
1627        let mut left = StreamReservoirSampler::new(10);
1628        let mut right = StreamReservoirSampler::new(10);
1629        for value in 0..1_000 {
1630            left.offer(value.to_string());
1631            right.offer(value.to_string());
1632        }
1633
1634        assert_eq!(left.samples(), right.samples());
1635    }
1636
1637    #[test]
1638    fn shrinking_a_reservoir_keeps_a_uniform_subset_not_its_head() {
1639        // A full reservoir that has not replaced anything yet holds the stream
1640        // in order, so truncating it kept exactly the first values.
1641        let mut sampler = StreamReservoirSampler::new(1_000);
1642        for value in 0..1_000 {
1643            sampler.offer(value.to_string());
1644        }
1645        sampler.shrink_to(100);
1646
1647        let retained: Vec<usize> = sampler
1648            .samples()
1649            .iter()
1650            .map(|value| value.parse().unwrap())
1651            .collect();
1652        assert_eq!(retained.len(), 100);
1653        let mean = retained.iter().sum::<usize>() as f64 / retained.len() as f64;
1654        assert!(
1655            (350.0..650.0).contains(&mean),
1656            "mean {mean} of {retained:?}"
1657        );
1658        let mut distinct = retained.clone();
1659        distinct.sort_unstable();
1660        distinct.dedup();
1661        assert_eq!(distinct.len(), 100, "a value was kept twice");
1662    }
1663
1664    #[test]
1665    fn test_reservoir_zero_capacity_still_retains_a_sample() {
1666        let mut sampler = StreamReservoirSampler::new(0);
1667        sampler.offer("value".to_string());
1668
1669        assert_eq!(sampler.samples(), ["value"]);
1670    }
1671
1672    #[test]
1673    fn test_late_column_is_backfilled_as_missing() {
1674        let mut collection = StreamingColumnCollection::new();
1675        collection.init_column_with_missing("late", 3);
1676        collection.process_record(&["late".to_string()], ["value".to_string()]);
1677
1678        let stats = collection.get_column_stats("late").unwrap();
1679        assert_eq!(stats.count, 4);
1680        assert_eq!(stats.null_count, 3);
1681        assert_eq!(stats.unique_count(), 1);
1682    }
1683
1684    #[test]
1685    fn test_welford_accuracy() {
1686        let mut accumulator = NumericAccumulator::new();
1687        for value in 1..=1000 {
1688            accumulator.update(value as f64);
1689        }
1690        let expected_mean = 500.5;
1691        let expected_variance = (1000.0 * 1000.0 - 1.0) / 12.0;
1692        assert!((accumulator.mean() - expected_mean).abs() < 1e-6);
1693        assert!((accumulator.population_variance() - expected_variance).abs() < 1.0);
1694    }
1695
1696    #[test]
1697    fn test_welford_merge() {
1698        let mut left = NumericAccumulator::new();
1699        let mut right = NumericAccumulator::new();
1700        let mut full = NumericAccumulator::new();
1701
1702        for value in 1..=500 {
1703            left.update(value as f64);
1704            full.update(value as f64);
1705        }
1706        for value in 501..=1000 {
1707            right.update(value as f64);
1708            full.update(value as f64);
1709        }
1710
1711        left.merge(&right);
1712        assert!((left.mean() - full.mean()).abs() < 1e-10);
1713        assert!((left.population_variance() - full.population_variance()).abs() < 1e-6);
1714    }
1715
1716    #[test]
1717    fn test_hll_cardinality() {
1718        let mut counter = HyperLogLog::new();
1719        let total = 100_000;
1720        for index in 0..total {
1721            counter.insert(&format!("item_{index}"));
1722        }
1723        let estimate = counter.count();
1724        let error = (estimate as f64 - total as f64).abs() / total as f64;
1725        assert!(error < 0.05);
1726    }
1727
1728    #[test]
1729    fn test_reservoir_uniformity() {
1730        let mut sampler = StreamReservoirSampler::seed(1000, 42);
1731        let total = 100_000;
1732        for index in 0..total {
1733            sampler.offer(index.to_string());
1734        }
1735
1736        assert_eq!(sampler.samples().len(), 1000);
1737        let values: Vec<usize> = sampler
1738            .samples()
1739            .iter()
1740            .map(|value| value.parse().unwrap())
1741            .collect();
1742        let max_value = *values.iter().max().unwrap();
1743        assert!(max_value > total / 2);
1744    }
1745
1746    #[test]
1747    fn test_text_length_stats_streaming() {
1748        let mut stats = TextLengthStats::new();
1749        for &length in &[3, 5, 10, 1, 7] {
1750            stats.update(length);
1751        }
1752        assert_eq!(stats.min_length, 1);
1753        assert_eq!(stats.max_length, 10);
1754        assert!((stats.avg_length - 5.2).abs() < 1e-10);
1755    }
1756
1757    #[test]
1758    fn test_memory_usage_bounded() {
1759        let mut stats = StreamingStatistics::new();
1760        for index in 0..50_000 {
1761            stats.update(&format!("value_{index}"));
1762        }
1763        let usage = stats.memory_usage_bytes();
1764        assert!(usage < 1_000_000);
1765    }
1766}