Skip to main content

dataprof_core/sampling/
sampler.rs

1//! The runtime that applies a [`SamplingStrategy`] to a stream of rows.
2//!
3//! Strategies fall into two families, and the difference is not cosmetic:
4//!
5//! * **Streaming** strategies (`Systematic`, `Stratified`, `Progressive`,
6//!   `Importance`) decide each row on the spot, so a row can be folded into the
7//!   running statistics immediately and memory stays bounded.
8//! * **Fixed-size** strategies (`Reservoir`, `Random`) cannot. Whether row 5
9//!   belongs in a uniform sample of 10 is not known until the stream ends —
10//!   row 5 may be evicted at row 900. Streaming statistics are not retractable,
11//!   so a row that was folded in and later evicted would silently corrupt the
12//!   profile. These strategies therefore buffer the candidate rows and hand the
13//!   final sample back at end of stream, costing `size` rows of memory.
14//!
15//! [`RowSampler`] hides that split behind one interface so every engine treats
16//! sampling identically. Consult [`RowSampler::is_buffered`] to know which of
17//! [`RowSampler::accept`] or [`RowSampler::offer`] to call.
18
19use crate::errors::DataProfilerError;
20
21use super::reservoir::ReservoirSampler;
22use super::strategies::{SamplingState, SamplingStrategy};
23
24/// A borrowed view of one row, addressable by column name.
25///
26/// Sampling reads at most a couple of columns per row, so this scans the header
27/// slice rather than building a map — allocating a `HashMap` per row would cost
28/// far more than the lookup it saves.
29#[derive(Clone, Copy)]
30pub struct RowView<'a> {
31    headers: &'a [String],
32    values: &'a [String],
33}
34
35impl<'a> RowView<'a> {
36    pub fn new(headers: &'a [String], values: &'a [String]) -> Self {
37        Self { headers, values }
38    }
39
40    /// The value of `column`, or `None` when the row has no such field.
41    pub fn get(&self, column: &str) -> Option<&'a str> {
42        let index = self.headers.iter().position(|h| h == column)?;
43        self.values.get(index).map(String::as_str)
44    }
45
46    pub fn headers(&self) -> &'a [String] {
47        self.headers
48    }
49
50    pub fn values(&self) -> &'a [String] {
51        self.values
52    }
53}
54
55/// Running mean and variance over one column, used by `Progressive` to measure
56/// how precise the sample has become. Welford's method: numerically stable and
57/// single-pass, so precision can be checked after every row without a rescan.
58#[derive(Debug, Default, Clone)]
59struct RunningMoments {
60    count: u64,
61    mean: f64,
62    m2: f64,
63}
64
65impl RunningMoments {
66    fn push(&mut self, value: f64) {
67        self.count += 1;
68        let delta = value - self.mean;
69        self.mean += delta / self.count as f64;
70        self.m2 += delta * (value - self.mean);
71    }
72
73    /// Relative standard error of the mean: `stderr / |mean|`.
74    ///
75    /// `None` until there are enough observations to have a variance, or when
76    /// the mean sits at zero and a *relative* error is undefined.
77    fn relative_standard_error(&self) -> Option<f64> {
78        if self.count < 2 {
79            return None;
80        }
81        let variance = self.m2 / (self.count - 1) as f64;
82        let standard_error = (variance / self.count as f64).sqrt();
83        let mean_magnitude = self.mean.abs();
84        if mean_magnitude <= f64::EPSILON {
85            return None;
86        }
87        Some(standard_error / mean_magnitude)
88    }
89}
90
91/// Precision tracker behind the `Progressive` strategy.
92#[derive(Debug, Default)]
93struct PrecisionTracker {
94    /// Moments per column position, for columns seen holding numbers.
95    columns: Vec<Option<RunningMoments>>,
96    numeric_columns: usize,
97}
98
99impl PrecisionTracker {
100    fn observe(&mut self, row: RowView<'_>) {
101        if self.columns.len() < row.values().len() {
102            self.columns.resize(row.values().len(), None);
103        }
104        for (index, raw) in row.values().iter().enumerate() {
105            let trimmed = raw.trim();
106            if trimmed.is_empty() {
107                continue;
108            }
109            // A column counts as numeric once a value parses; a later
110            // non-numeric value does not retract that, it is simply skipped.
111            let Ok(value) = trimmed.parse::<f64>() else {
112                continue;
113            };
114            if !value.is_finite() {
115                continue;
116            }
117            let slot = &mut self.columns[index];
118            if slot.is_none() {
119                *slot = Some(RunningMoments::default());
120                self.numeric_columns += 1;
121            }
122            // decode-audit: impossible — the slot was just populated above.
123            slot.as_mut()
124                .expect("numeric column slot is present")
125                .push(value);
126        }
127    }
128
129    /// Whether every numeric column has reached the target relative standard
130    /// error. `false` when nothing numeric has been seen: precision cannot be
131    /// claimed for data it could not measure.
132    fn meets_target(&self, target: f64) -> bool {
133        if self.numeric_columns == 0 {
134            return false;
135        }
136        self.columns
137            .iter()
138            .flatten()
139            .all(|moments| match moments.relative_standard_error() {
140                Some(rse) => rse <= target,
141                // Not yet measurable — not yet precise enough.
142                None => false,
143            })
144    }
145}
146
147/// A fixed-size uniform sample held in memory until the stream ends.
148#[derive(Debug)]
149struct SampleBuffer<R> {
150    capacity: usize,
151    rows: Vec<R>,
152    sampler: ReservoirSampler,
153    seen: usize,
154}
155
156impl<R> SampleBuffer<R> {
157    fn new(capacity: usize) -> Self {
158        Self {
159            capacity,
160            rows: Vec::new(),
161            sampler: ReservoirSampler::new(capacity),
162            seen: 0,
163        }
164    }
165
166    /// Offer a row to the sample, replacing an existing member if selected.
167    ///
168    /// Algorithm R: the first `capacity` rows fill the reservoir, after which
169    /// row `n` replaces a uniformly chosen member with probability
170    /// `capacity / n`. Every row of the stream ends up equally likely to be in
171    /// the final sample.
172    fn offer(&mut self, values: R) {
173        self.seen += 1;
174        if self.capacity == 0 {
175            return;
176        }
177        if self.rows.len() < self.capacity {
178            self.rows.push(values);
179            return;
180        }
181        if let Some(position) = self.sampler.replacement_slot(self.seen) {
182            self.rows[position] = values;
183        }
184    }
185
186    fn take(&mut self) -> Vec<R> {
187        std::mem::take(&mut self.rows)
188    }
189}
190
191/// Applies a [`SamplingStrategy`] to a stream of rows, holding all the state
192/// the strategy needs across rows.
193///
194/// A fresh state per row — which is what calling a stateless helper amounts to
195/// — silently disables every stateful strategy, so engines must create one
196/// sampler per scan and keep it for the whole scan.
197///
198/// `R` is what the fixed-size stage holds for each row until the stream ends.
199/// It is the row's field values unless an engine has to keep something about
200/// the row that its values cannot say, and must then get it back with exactly
201/// the rows that were sampled.
202#[derive(Debug)]
203pub struct RowSampler<R = Vec<String>> {
204    /// Streaming filters, applied in order; a row must pass all of them.
205    filters: Vec<SamplingStrategy>,
206    /// Terminal fixed-size stage, if the strategy has one.
207    buffer: Option<SampleBuffer<R>>,
208    state: SamplingState,
209    precision: PrecisionTracker,
210    /// Rows offered to the sampler, whether or not they were kept.
211    iterated: usize,
212    /// Rows the sampler accepted for immediate folding (streaming path only).
213    accepted: usize,
214}
215
216impl RowSampler {
217    /// Build a sampler for `strategy`, validating that it can be applied.
218    ///
219    /// Rejects here rather than mid-scan: a caller learns that a strategy is
220    /// unusable before the source is read, not after a partial profile exists.
221    pub fn new(strategy: &SamplingStrategy) -> Result<Self, DataProfilerError> {
222        Self::for_rows(strategy)
223    }
224}
225
226impl<R> RowSampler<R> {
227    /// As [`RowSampler::new`], for a sampler whose fixed-size stage holds `R`
228    /// for each row rather than its field values alone.
229    pub fn for_rows(strategy: &SamplingStrategy) -> Result<Self, DataProfilerError> {
230        let mut filters = Vec::new();
231        let mut buffer = None;
232        Self::flatten(strategy, &mut filters, &mut buffer)?;
233
234        Ok(Self {
235            filters,
236            buffer,
237            state: SamplingState::new(),
238            precision: PrecisionTracker::default(),
239            iterated: 0,
240            accepted: 0,
241        })
242    }
243
244    /// Split a (possibly multi-stage) strategy into streaming filters and at
245    /// most one terminal fixed-size stage.
246    fn flatten(
247        strategy: &SamplingStrategy,
248        filters: &mut Vec<SamplingStrategy>,
249        buffer: &mut Option<SampleBuffer<R>>,
250    ) -> Result<(), DataProfilerError> {
251        match strategy {
252            SamplingStrategy::None => Ok(()),
253            SamplingStrategy::Reservoir { size } | SamplingStrategy::Random { size } => {
254                if buffer.is_some() {
255                    // Two fixed-size stages have no combined meaning: each wants
256                    // to define the final sample.
257                    return Err(DataProfilerError::InvalidConfiguration {
258                        message: "a multi-stage strategy may contain at most one fixed-size stage \
259                                  (random or reservoir)"
260                            .to_string(),
261                        suggestion: "Keep a single fixed-size stage and express the rest as \
262                                     filters, e.g. multi_stage([systematic(10), reservoir(1000)])."
263                            .to_string(),
264                        source: None,
265                    });
266                }
267                *buffer = Some(SampleBuffer::new(*size));
268                Ok(())
269            }
270            SamplingStrategy::MultiStage { stages } => {
271                for stage in stages {
272                    // A filter after the fixed-size stage would have nothing to
273                    // filter: the sample is only final once the source ends.
274                    if buffer.is_some() && !Self::is_fixed_size(stage) {
275                        return Err(DataProfilerError::InvalidConfiguration {
276                            message: "a fixed-size stage (random or reservoir) must be the last \
277                                      stage of a multi-stage strategy"
278                                .to_string(),
279                            suggestion: "Reorder the stages so filters such as systematic or \
280                                         stratified come first, e.g. \
281                                         multi_stage([systematic(10), reservoir(1000)])."
282                                .to_string(),
283                            source: None,
284                        });
285                    }
286                    Self::flatten(stage, filters, buffer)?;
287                }
288                Ok(())
289            }
290            other => {
291                filters.push(other.clone());
292                Ok(())
293            }
294        }
295    }
296
297    /// Whether a stage draws a fixed-size sample, and so must come last.
298    fn is_fixed_size(strategy: &SamplingStrategy) -> bool {
299        match strategy {
300            SamplingStrategy::Reservoir { .. } | SamplingStrategy::Random { .. } => true,
301            SamplingStrategy::MultiStage { stages } => stages.iter().any(Self::is_fixed_size),
302            _ => false,
303        }
304    }
305
306    /// Whether rows must be handed to [`offer`](Self::offer) instead of being
307    /// folded in as [`accept`](Self::accept) approves them.
308    pub fn is_buffered(&self) -> bool {
309        self.buffer.is_some()
310    }
311
312    /// Whether this sampler can ever exclude a row.
313    pub fn is_noop(&self) -> bool {
314        self.filters.is_empty() && self.buffer.is_none()
315    }
316
317    /// Decide whether a row passes the streaming filters.
318    ///
319    /// Always call this, including on the buffered path, so the filters of a
320    /// multi-stage strategy run before the fixed-size stage sees a row.
321    pub fn accept(&mut self, row: RowView<'_>) -> bool {
322        self.iterated += 1;
323        let index = self.iterated - 1;
324
325        for filter in &self.filters {
326            if !Self::passes(filter, index, row, &mut self.state, &mut self.precision) {
327                return false;
328            }
329        }
330
331        if self.buffer.is_none() {
332            self.accepted += 1;
333        }
334        true
335    }
336
337    /// Hand a row that passed [`accept`](Self::accept) to the fixed-size stage.
338    ///
339    /// Only meaningful when [`is_buffered`](Self::is_buffered) is true.
340    pub fn offer(&mut self, values: R) {
341        if let Some(buffer) = self.buffer.as_mut() {
342            buffer.offer(values);
343        }
344    }
345
346    /// The final sample from the fixed-size stage, ready to fold into the
347    /// statistics. Empty for a purely streaming strategy.
348    pub fn take_sample(&mut self) -> Vec<R> {
349        match self.buffer.as_mut() {
350            Some(buffer) => {
351                let rows = buffer.take();
352                self.accepted += rows.len();
353                rows
354            }
355            None => Vec::new(),
356        }
357    }
358
359    /// Rows the sampler has seen, sampled or not.
360    pub fn iterated_rows(&self) -> usize {
361        self.iterated
362    }
363
364    /// Rows that ended up in the sample.
365    pub fn sampled_rows(&self) -> usize {
366        self.accepted
367    }
368
369    fn passes(
370        filter: &SamplingStrategy,
371        index: usize,
372        row: RowView<'_>,
373        state: &mut SamplingState,
374        precision: &mut PrecisionTracker,
375    ) -> bool {
376        match filter {
377            #[allow(clippy::manual_is_multiple_of)]
378            SamplingStrategy::Systematic { interval } => {
379                if *interval == 0 {
380                    return true;
381                }
382                index % interval == 0
383            }
384            SamplingStrategy::Stratified {
385                key_columns,
386                samples_per_stratum,
387            } => state.take_from_stratum(row, key_columns, *samples_per_stratum),
388            SamplingStrategy::Importance {
389                weight_column,
390                weight_threshold,
391            } => match row.get(weight_column) {
392                // A row whose weight is missing or unparseable has no stated
393                // importance, so it is not important enough to keep.
394                Some(raw) => matches!(raw.trim().parse::<f64>(), Ok(w) if w >= *weight_threshold),
395                None => false,
396            },
397            SamplingStrategy::Progressive {
398                initial_size,
399                confidence_level,
400                max_size,
401            } => {
402                let taken = state.progressive_taken();
403                if taken >= *max_size {
404                    return false;
405                }
406                if taken >= *initial_size && precision.meets_target(1.0 - confidence_level) {
407                    return false;
408                }
409                precision.observe(row);
410                state.record_progressive();
411                true
412            }
413            // Fixed-size stages never reach here; `None` and `MultiStage` are
414            // flattened away by `flatten`.
415            _ => true,
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn headers() -> Vec<String> {
425        vec!["group".into(), "weight".into(), "value".into()]
426    }
427
428    fn row(group: &str, weight: &str, value: &str) -> Vec<String> {
429        vec![group.into(), weight.into(), value.into()]
430    }
431
432    fn run(strategy: SamplingStrategy, rows: usize) -> (usize, usize) {
433        let headers = headers();
434        let mut sampler = RowSampler::new(&strategy).expect("valid strategy");
435        for i in 0..rows {
436            let values = row(
437                &format!("g{}", i % 3),
438                &format!("{}", i % 10),
439                &format!("{}", 100 + (i % 7)),
440            );
441            let view = RowView::new(&headers, &values);
442            if sampler.accept(view) && sampler.is_buffered() {
443                sampler.offer(values);
444            }
445        }
446        let final_rows = sampler.take_sample().len();
447        (sampler.sampled_rows(), final_rows)
448    }
449
450    #[test]
451    fn reservoir_yields_exactly_its_size() {
452        let (sampled, buffered) = run(SamplingStrategy::Reservoir { size: 10 }, 100);
453        assert_eq!(buffered, 10);
454        assert_eq!(sampled, 10);
455    }
456
457    #[test]
458    fn reservoir_smaller_than_its_size_keeps_every_row() {
459        let (sampled, buffered) = run(SamplingStrategy::Reservoir { size: 50 }, 20);
460        assert_eq!(buffered, 20, "a short stream cannot fill the reservoir");
461        assert_eq!(sampled, 20);
462    }
463
464    #[test]
465    fn random_matches_reservoir_semantics() {
466        let (_, buffered) = run(SamplingStrategy::Random { size: 25 }, 500);
467        assert_eq!(buffered, 25);
468    }
469
470    #[test]
471    fn systematic_takes_every_nth_row() {
472        let (sampled, _) = run(SamplingStrategy::Systematic { interval: 10 }, 100);
473        assert_eq!(sampled, 10);
474    }
475
476    #[test]
477    fn stratified_caps_each_stratum() {
478        let (sampled, _) = run(
479            SamplingStrategy::Stratified {
480                key_columns: vec!["group".into()],
481                samples_per_stratum: 2,
482            },
483            100,
484        );
485        // Three distinct groups, two rows each.
486        assert_eq!(sampled, 6);
487    }
488
489    #[test]
490    fn importance_keeps_rows_at_or_above_the_weight() {
491        let (sampled, _) = run(
492            SamplingStrategy::Importance {
493                weight_column: "weight".into(),
494                weight_threshold: 8.0,
495            },
496            100,
497        );
498        // weight cycles 0..9, so 8 and 9 qualify: 20 of 100.
499        assert_eq!(sampled, 20);
500    }
501
502    #[test]
503    fn importance_on_a_missing_column_keeps_nothing() {
504        let (sampled, _) = run(
505            SamplingStrategy::Importance {
506                weight_column: "absent".into(),
507                weight_threshold: 0.0,
508            },
509            50,
510        );
511        assert_eq!(sampled, 0);
512    }
513
514    #[test]
515    fn progressive_stays_within_its_bounds() {
516        let (sampled, _) = run(
517            SamplingStrategy::Progressive {
518                initial_size: 5,
519                confidence_level: 0.95,
520                max_size: 40,
521            },
522            500,
523        );
524        assert!(
525            (5..=40).contains(&sampled),
526            "progressive took {sampled} rows, outside 5..=40"
527        );
528    }
529
530    #[test]
531    fn progressive_stops_once_precision_is_reached() {
532        // A constant column has zero variance, so its relative standard error
533        // is 0 immediately: the strategy must stop at initial_size rather than
534        // run to max_size.
535        let headers = vec!["value".to_string()];
536        let strategy = SamplingStrategy::Progressive {
537            initial_size: 10,
538            confidence_level: 0.95,
539            max_size: 1_000,
540        };
541        let mut sampler = RowSampler::new(&strategy).unwrap();
542        for _ in 0..500 {
543            let values = vec!["42".to_string()];
544            sampler.accept(RowView::new(&headers, &values));
545        }
546        assert_eq!(
547            sampler.sampled_rows(),
548            10,
549            "zero-variance data reaches any precision target at initial_size"
550        );
551    }
552
553    #[test]
554    fn progressive_without_numeric_columns_runs_to_max_size() {
555        let headers = vec!["label".to_string()];
556        let strategy = SamplingStrategy::Progressive {
557            initial_size: 5,
558            confidence_level: 0.95,
559            max_size: 30,
560        };
561        let mut sampler = RowSampler::new(&strategy).unwrap();
562        for i in 0..500 {
563            let values = vec![format!("text_{i}")];
564            sampler.accept(RowView::new(&headers, &values));
565        }
566        assert_eq!(
567            sampler.sampled_rows(),
568            30,
569            "precision is unmeasurable without numbers, so the cap decides"
570        );
571    }
572
573    #[test]
574    fn multi_stage_applies_the_filter_then_the_fixed_size_stage() {
575        let strategy = SamplingStrategy::MultiStage {
576            stages: vec![
577                SamplingStrategy::Systematic { interval: 2 },
578                SamplingStrategy::Reservoir { size: 10 },
579            ],
580        };
581        let (sampled, buffered) = run(strategy, 100);
582        assert_eq!(buffered, 10, "the reservoir bounds the final sample");
583        assert_eq!(sampled, 10);
584    }
585
586    #[test]
587    fn multi_stage_rejects_two_fixed_size_stages() {
588        let strategy = SamplingStrategy::MultiStage {
589            stages: vec![
590                SamplingStrategy::Reservoir { size: 10 },
591                SamplingStrategy::Random { size: 5 },
592            ],
593        };
594        let error = RowSampler::new(&strategy).expect_err("two fixed-size stages are ambiguous");
595        assert!(error.to_string().contains("at most one fixed-size stage"));
596    }
597
598    #[test]
599    fn multi_stage_rejects_a_filter_after_a_fixed_size_stage() {
600        let strategy = SamplingStrategy::MultiStage {
601            stages: vec![
602                SamplingStrategy::Reservoir { size: 10 },
603                SamplingStrategy::Systematic { interval: 2 },
604            ],
605        };
606        let error = RowSampler::new(&strategy).expect_err("a filter cannot follow the reservoir");
607        assert!(error.to_string().contains("must be the last stage"));
608    }
609
610    #[test]
611    fn none_is_a_noop() {
612        let sampler = RowSampler::new(&SamplingStrategy::None).unwrap();
613        assert!(sampler.is_noop());
614        assert!(!sampler.is_buffered());
615    }
616
617    #[test]
618    fn row_view_reads_by_name() {
619        let headers = headers();
620        let values = row("a", "1.5", "9");
621        let view = RowView::new(&headers, &values);
622        assert_eq!(view.get("weight"), Some("1.5"));
623        assert_eq!(view.get("missing"), None);
624    }
625}