1use crate::errors::DataProfilerError;
20
21use super::reservoir::ReservoirSampler;
22use super::strategies::{SamplingState, SamplingStrategy};
23
24#[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 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#[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 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#[derive(Debug, Default)]
93struct PrecisionTracker {
94 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 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 slot.as_mut()
124 .expect("numeric column slot is present")
125 .push(value);
126 }
127 }
128
129 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 None => false,
143 })
144 }
145}
146
147#[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 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#[derive(Debug)]
203pub struct RowSampler<R = Vec<String>> {
204 filters: Vec<SamplingStrategy>,
206 buffer: Option<SampleBuffer<R>>,
208 state: SamplingState,
209 precision: PrecisionTracker,
210 iterated: usize,
212 accepted: usize,
214}
215
216impl RowSampler {
217 pub fn new(strategy: &SamplingStrategy) -> Result<Self, DataProfilerError> {
222 Self::for_rows(strategy)
223 }
224}
225
226impl<R> RowSampler<R> {
227 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 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 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 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 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 pub fn is_buffered(&self) -> bool {
309 self.buffer.is_some()
310 }
311
312 pub fn is_noop(&self) -> bool {
314 self.filters.is_empty() && self.buffer.is_none()
315 }
316
317 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 pub fn offer(&mut self, values: R) {
341 if let Some(buffer) = self.buffer.as_mut() {
342 buffer.offer(values);
343 }
344 }
345
346 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 pub fn iterated_rows(&self) -> usize {
361 self.iterated
362 }
363
364 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 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 _ => 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 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 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 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}