Skip to main content

dataprof_core/sampling/
strategies.rs

1use std::collections::HashMap;
2use std::fmt::Write;
3
4/// How rows are selected for profiling.
5///
6/// Strategies divide into two families, which differ in cost. *Streaming*
7/// strategies decide each row as it arrives and add no memory. *Fixed-size*
8/// strategies — [`Random`](Self::Random) and [`Reservoir`](Self::Reservoir) —
9/// cannot know the final sample until the source ends, so they hold `size` rows
10/// in memory and the profile is computed from that buffer.
11///
12/// Every strategy is applied by `RowSampler`, which carries the state they need
13/// across rows.
14#[derive(Debug, Clone)]
15pub enum SamplingStrategy {
16    /// No sampling - analyze all data
17    None,
18
19    /// A uniform random sample of exactly `size` rows (or every row, when the
20    /// source is shorter).
21    ///
22    /// Over a source of unknown length this is reservoir sampling, so it holds
23    /// `size` rows in memory and is equivalent to
24    /// [`Reservoir`](Self::Reservoir); both names are kept because both are
25    /// familiar.
26    Random { size: usize },
27
28    /// A uniform random sample of exactly `size` rows, selected with Algorithm
29    /// R over a single pass.
30    ///
31    /// Holds `size` rows in memory: membership is not final until the source
32    /// ends, and statistics folded in earlier could not be retracted.
33    Reservoir { size: usize },
34
35    /// Up to `samples_per_stratum` rows for each distinct combination of
36    /// `key_columns` observed in the data.
37    ///
38    /// Strata are discovered as rows arrive, so the sample size grows with the
39    /// number of distinct keys rather than being fixed up front.
40    Stratified {
41        key_columns: Vec<String>,
42        samples_per_stratum: usize,
43    },
44
45    /// Grow the sample until the mean of every numeric column is precise
46    /// enough, bounded by `initial_size` and `max_size`.
47    ///
48    /// `confidence_level` sets a target *relative standard error* of
49    /// `1.0 - confidence_level`: at `0.95`, sampling stops once the standard
50    /// error of each numeric column's mean is within 5% of that mean. Precision
51    /// is measured from the sampled data, so a low-variance column stops early
52    /// and a volatile one runs to `max_size`.
53    ///
54    /// A source with no numeric columns has no measurable precision, so it
55    /// always samples `max_size` rows.
56    Progressive {
57        initial_size: usize,
58        confidence_level: f64,
59        max_size: usize,
60    },
61
62    /// Every `interval`-th row, starting at the first.
63    Systematic { interval: usize },
64
65    /// Rows whose `weight_column` holds a number at or above
66    /// `weight_threshold`.
67    ///
68    /// The caller states what matters: there is no built-in notion of an
69    /// important row. Rows where the column is absent or unparseable are
70    /// excluded. Note that this is a filter, not a probability-weighted sample —
71    /// the resulting profile describes the rows that met the threshold, not the
72    /// source as a whole.
73    Importance {
74        weight_column: String,
75        weight_threshold: f64,
76    },
77
78    /// Several strategies applied in order.
79    ///
80    /// Streaming stages act as filters that a row must pass in sequence. At
81    /// most one fixed-size stage may appear, and it must be last, since it
82    /// draws its sample from whatever the filters let through.
83    MultiStage { stages: Vec<SamplingStrategy> },
84}
85
86/// The cross-row state that stateful strategies need.
87///
88/// One instance lives for a whole scan. Recreating it per row — which is what
89/// a stateless helper amounts to — makes every stateful strategy behave as if
90/// each row were the first, which is how stratified sampling came to return
91/// nothing and reservoir sampling came to return everything.
92#[derive(Debug, Default)]
93pub struct SamplingState {
94    /// Rows already taken by a `Progressive` stage.
95    progressive_samples: usize,
96
97    /// Rows already taken from each observed stratum.
98    stratum_samples: HashMap<String, usize>,
99}
100
101impl SamplingState {
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Rows a `Progressive` stage has taken so far.
107    pub fn progressive_taken(&self) -> usize {
108        self.progressive_samples
109    }
110
111    /// Record that a `Progressive` stage took a row.
112    pub fn record_progressive(&mut self) {
113        self.progressive_samples += 1;
114    }
115
116    /// Take `row` for its stratum if that stratum still has room.
117    ///
118    /// The stratum key is the joined values of `key_columns`. A row missing any
119    /// key column belongs to no stratum and is not sampled — silently folding
120    /// it into a partial key would merge unrelated rows into one stratum.
121    pub fn take_from_stratum(
122        &mut self,
123        row: super::sampler::RowView<'_>,
124        key_columns: &[String],
125        samples_per_stratum: usize,
126    ) -> bool {
127        if key_columns.is_empty() {
128            return false;
129        }
130
131        // Length-prefixed so field boundaries are unambiguous: joining on a
132        // separator would merge ("a|b", "c") and ("a", "b|c") into one stratum
133        // and apply the cap to both together.
134        let mut stratum = String::new();
135        for column in key_columns {
136            let Some(value) = row.get(column) else {
137                return false;
138            };
139            let _ = write!(stratum, "{}:{}", value.len(), value);
140        }
141
142        let taken = self.stratum_samples.entry(stratum).or_insert(0);
143        if *taken < samples_per_stratum {
144            *taken += 1;
145            true
146        } else {
147            false
148        }
149    }
150
151    /// Number of distinct strata observed so far.
152    pub fn strata_seen(&self) -> usize {
153        self.stratum_samples.len()
154    }
155}
156
157impl SamplingStrategy {
158    /// Create adaptive strategy based on data characteristics
159    pub fn adaptive(total_rows: Option<usize>, file_size_mb: f64) -> Self {
160        match (total_rows, file_size_mb) {
161            (Some(rows), size_mb) if rows <= 10_000 && size_mb < 10.0 => SamplingStrategy::None,
162            (Some(rows), _) if rows <= 100_000 => SamplingStrategy::Random { size: 10_000 },
163            (Some(rows), _) if rows <= 1_000_000 => SamplingStrategy::Progressive {
164                initial_size: 10_000,
165                confidence_level: 0.95,
166                max_size: 50_000,
167            },
168            (_, size_mb) if size_mb > 1000.0 => SamplingStrategy::MultiStage {
169                stages: vec![
170                    SamplingStrategy::Systematic { interval: 100 },
171                    SamplingStrategy::Progressive {
172                        initial_size: 5_000,
173                        confidence_level: 0.99,
174                        max_size: 25_000,
175                    },
176                ],
177            },
178            _ => SamplingStrategy::Reservoir { size: 100_000 },
179        }
180    }
181
182    /// Create stratified sampling strategy
183    pub fn stratified(key_columns: Vec<String>, samples_per_stratum: usize) -> Self {
184        Self::Stratified {
185            key_columns,
186            samples_per_stratum,
187        }
188    }
189
190    /// Create an importance filter over a caller-nominated weight column.
191    pub fn importance(weight_column: impl Into<String>, weight_threshold: f64) -> Self {
192        Self::Importance {
193            weight_column: weight_column.into(),
194            weight_threshold,
195        }
196    }
197
198    pub fn target_sample_size(&self) -> Option<usize> {
199        match self {
200            SamplingStrategy::None => None,
201            SamplingStrategy::Random { size } => Some(*size),
202            SamplingStrategy::Reservoir { size } => Some(*size),
203            SamplingStrategy::Stratified {
204                samples_per_stratum,
205                ..
206            } => Some(*samples_per_stratum),
207            SamplingStrategy::Progressive { max_size, .. } => Some(*max_size),
208            SamplingStrategy::Systematic { .. } => None,
209            SamplingStrategy::Importance { .. } => None,
210            SamplingStrategy::MultiStage { stages } => {
211                // Return the minimum target size across all stages
212                stages.iter().filter_map(|s| s.target_sample_size()).min()
213            }
214        }
215    }
216
217    /// Get description of the sampling strategy
218    pub fn description(&self) -> String {
219        match self {
220            SamplingStrategy::None => "Full dataset analysis".to_string(),
221            SamplingStrategy::Random { size } => format!("Random sampling ({} records)", size),
222            SamplingStrategy::Reservoir { size } => {
223                format!("Reservoir sampling ({} records)", size)
224            }
225            SamplingStrategy::Stratified {
226                key_columns,
227                samples_per_stratum,
228            } => {
229                format!(
230                    "Stratified by {} ({} per stratum)",
231                    key_columns.join(", "),
232                    samples_per_stratum
233                )
234            }
235            SamplingStrategy::Progressive {
236                initial_size,
237                confidence_level,
238                max_size,
239            } => {
240                format!(
241                    "Progressive sampling ({}-{} records, {}% confidence)",
242                    initial_size,
243                    max_size,
244                    (confidence_level * 100.0) as u8
245                )
246            }
247            SamplingStrategy::Systematic { interval } => {
248                format!("Systematic (every {}th record)", interval)
249            }
250            SamplingStrategy::Importance {
251                weight_column,
252                weight_threshold,
253            } => {
254                format!("Importance filter ({weight_column} >= {weight_threshold:.2})")
255            }
256            SamplingStrategy::MultiStage { stages } => {
257                format!("Multi-stage ({} stages)", stages.len())
258            }
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::sampling::sampler::RowView;
267
268    // Behavioural coverage of each strategy lives with the runtime that
269    // applies them, in `sampler.rs`; a strategy on its own is only data.
270
271    #[test]
272    fn test_stratum_state_persists_across_rows() {
273        let headers = vec!["region".to_string()];
274        let mut state = SamplingState::new();
275        let keys = vec!["region".to_string()];
276
277        let north = vec!["north".to_string()];
278        let south = vec!["south".to_string()];
279
280        assert!(state.take_from_stratum(RowView::new(&headers, &north), &keys, 2));
281        assert!(state.take_from_stratum(RowView::new(&headers, &north), &keys, 2));
282        assert!(
283            !state.take_from_stratum(RowView::new(&headers, &north), &keys, 2),
284            "the third northern row exceeds the per-stratum cap"
285        );
286        assert!(
287            state.take_from_stratum(RowView::new(&headers, &south), &keys, 2),
288            "a different stratum has its own budget"
289        );
290        assert_eq!(state.strata_seen(), 2);
291    }
292
293    #[test]
294    fn test_stratum_requires_every_key_column() {
295        let headers = vec!["region".to_string()];
296        let values = vec!["north".to_string()];
297        let mut state = SamplingState::new();
298        let keys = vec!["region".to_string(), "segment".to_string()];
299
300        assert!(
301            !state.take_from_stratum(RowView::new(&headers, &values), &keys, 5),
302            "a row missing a key column belongs to no stratum"
303        );
304        assert_eq!(state.strata_seen(), 0);
305    }
306
307    #[test]
308    fn test_importance_constructor_names_its_column() {
309        let strategy = SamplingStrategy::importance("risk", 0.8);
310        match strategy {
311            SamplingStrategy::Importance {
312                ref weight_column,
313                weight_threshold,
314            } => {
315                assert_eq!(weight_column, "risk");
316                assert_eq!(weight_threshold, 0.8);
317            }
318            other => panic!("expected an importance filter, got {other:?}"),
319        }
320        assert!(strategy.description().contains("risk"));
321    }
322
323    #[test]
324    fn test_adaptive_strategy() {
325        // Small dataset - should use no sampling
326        let small = SamplingStrategy::adaptive(Some(5_000), 1.0);
327        matches!(small, SamplingStrategy::None);
328
329        // Medium dataset - should use random sampling
330        let medium = SamplingStrategy::adaptive(Some(50_000), 10.0);
331        matches!(medium, SamplingStrategy::Random { .. });
332
333        // Large file - should use multi-stage
334        let large = SamplingStrategy::adaptive(Some(10_000_000), 2000.0);
335        matches!(large, SamplingStrategy::MultiStage { .. });
336    }
337}