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