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 {
150 capacity: usize,
151 rows: Vec<Vec<String>>,
152 sampler: ReservoirSampler,
153 seen: usize,
154}
155
156impl SampleBuffer {
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: Vec<String>) {
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<Vec<String>> {
187 std::mem::take(&mut self.rows)
188 }
189}
190
191#[derive(Debug)]
198pub struct RowSampler {
199 filters: Vec<SamplingStrategy>,
201 buffer: Option<SampleBuffer>,
203 state: SamplingState,
204 precision: PrecisionTracker,
205 iterated: usize,
207 accepted: usize,
209}
210
211impl RowSampler {
212 pub fn new(strategy: &SamplingStrategy) -> Result<Self, DataProfilerError> {
217 let mut filters = Vec::new();
218 let mut buffer = None;
219 Self::flatten(strategy, &mut filters, &mut buffer)?;
220
221 Ok(Self {
222 filters,
223 buffer,
224 state: SamplingState::new(),
225 precision: PrecisionTracker::default(),
226 iterated: 0,
227 accepted: 0,
228 })
229 }
230
231 fn flatten(
234 strategy: &SamplingStrategy,
235 filters: &mut Vec<SamplingStrategy>,
236 buffer: &mut Option<SampleBuffer>,
237 ) -> Result<(), DataProfilerError> {
238 match strategy {
239 SamplingStrategy::None => Ok(()),
240 SamplingStrategy::Reservoir { size } | SamplingStrategy::Random { size } => {
241 if buffer.is_some() {
242 return Err(DataProfilerError::InvalidConfiguration {
245 message: "a multi-stage strategy may contain at most one fixed-size stage \
246 (random or reservoir)"
247 .to_string(),
248 suggestion: "Keep a single fixed-size stage and express the rest as \
249 filters, e.g. multi_stage([systematic(10), reservoir(1000)])."
250 .to_string(),
251 source: None,
252 });
253 }
254 *buffer = Some(SampleBuffer::new(*size));
255 Ok(())
256 }
257 SamplingStrategy::MultiStage { stages } => {
258 for stage in stages {
259 if buffer.is_some() && !Self::is_fixed_size(stage) {
262 return Err(DataProfilerError::InvalidConfiguration {
263 message: "a fixed-size stage (random or reservoir) must be the last \
264 stage of a multi-stage strategy"
265 .to_string(),
266 suggestion: "Reorder the stages so filters such as systematic or \
267 stratified come first, e.g. \
268 multi_stage([systematic(10), reservoir(1000)])."
269 .to_string(),
270 source: None,
271 });
272 }
273 Self::flatten(stage, filters, buffer)?;
274 }
275 Ok(())
276 }
277 other => {
278 filters.push(other.clone());
279 Ok(())
280 }
281 }
282 }
283
284 fn is_fixed_size(strategy: &SamplingStrategy) -> bool {
286 match strategy {
287 SamplingStrategy::Reservoir { .. } | SamplingStrategy::Random { .. } => true,
288 SamplingStrategy::MultiStage { stages } => stages.iter().any(Self::is_fixed_size),
289 _ => false,
290 }
291 }
292
293 pub fn is_buffered(&self) -> bool {
296 self.buffer.is_some()
297 }
298
299 pub fn is_noop(&self) -> bool {
301 self.filters.is_empty() && self.buffer.is_none()
302 }
303
304 pub fn accept(&mut self, row: RowView<'_>) -> bool {
309 self.iterated += 1;
310 let index = self.iterated - 1;
311
312 for filter in &self.filters {
313 if !Self::passes(filter, index, row, &mut self.state, &mut self.precision) {
314 return false;
315 }
316 }
317
318 if self.buffer.is_none() {
319 self.accepted += 1;
320 }
321 true
322 }
323
324 pub fn offer(&mut self, values: Vec<String>) {
328 if let Some(buffer) = self.buffer.as_mut() {
329 buffer.offer(values);
330 }
331 }
332
333 pub fn take_sample(&mut self) -> Vec<Vec<String>> {
336 match self.buffer.as_mut() {
337 Some(buffer) => {
338 let rows = buffer.take();
339 self.accepted += rows.len();
340 rows
341 }
342 None => Vec::new(),
343 }
344 }
345
346 pub fn iterated_rows(&self) -> usize {
348 self.iterated
349 }
350
351 pub fn sampled_rows(&self) -> usize {
353 self.accepted
354 }
355
356 fn passes(
357 filter: &SamplingStrategy,
358 index: usize,
359 row: RowView<'_>,
360 state: &mut SamplingState,
361 precision: &mut PrecisionTracker,
362 ) -> bool {
363 match filter {
364 #[allow(clippy::manual_is_multiple_of)]
365 SamplingStrategy::Systematic { interval } => {
366 if *interval == 0 {
367 return true;
368 }
369 index % interval == 0
370 }
371 SamplingStrategy::Stratified {
372 key_columns,
373 samples_per_stratum,
374 } => state.take_from_stratum(row, key_columns, *samples_per_stratum),
375 SamplingStrategy::Importance {
376 weight_column,
377 weight_threshold,
378 } => match row.get(weight_column) {
379 Some(raw) => matches!(raw.trim().parse::<f64>(), Ok(w) if w >= *weight_threshold),
382 None => false,
383 },
384 SamplingStrategy::Progressive {
385 initial_size,
386 confidence_level,
387 max_size,
388 } => {
389 let taken = state.progressive_taken();
390 if taken >= *max_size {
391 return false;
392 }
393 if taken >= *initial_size && precision.meets_target(1.0 - confidence_level) {
394 return false;
395 }
396 precision.observe(row);
397 state.record_progressive();
398 true
399 }
400 _ => true,
403 }
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn headers() -> Vec<String> {
412 vec!["group".into(), "weight".into(), "value".into()]
413 }
414
415 fn row(group: &str, weight: &str, value: &str) -> Vec<String> {
416 vec![group.into(), weight.into(), value.into()]
417 }
418
419 fn run(strategy: SamplingStrategy, rows: usize) -> (usize, usize) {
420 let headers = headers();
421 let mut sampler = RowSampler::new(&strategy).expect("valid strategy");
422 for i in 0..rows {
423 let values = row(
424 &format!("g{}", i % 3),
425 &format!("{}", i % 10),
426 &format!("{}", 100 + (i % 7)),
427 );
428 let view = RowView::new(&headers, &values);
429 if sampler.accept(view) && sampler.is_buffered() {
430 sampler.offer(values);
431 }
432 }
433 let final_rows = sampler.take_sample().len();
434 (sampler.sampled_rows(), final_rows)
435 }
436
437 #[test]
438 fn reservoir_yields_exactly_its_size() {
439 let (sampled, buffered) = run(SamplingStrategy::Reservoir { size: 10 }, 100);
440 assert_eq!(buffered, 10);
441 assert_eq!(sampled, 10);
442 }
443
444 #[test]
445 fn reservoir_smaller_than_its_size_keeps_every_row() {
446 let (sampled, buffered) = run(SamplingStrategy::Reservoir { size: 50 }, 20);
447 assert_eq!(buffered, 20, "a short stream cannot fill the reservoir");
448 assert_eq!(sampled, 20);
449 }
450
451 #[test]
452 fn random_matches_reservoir_semantics() {
453 let (_, buffered) = run(SamplingStrategy::Random { size: 25 }, 500);
454 assert_eq!(buffered, 25);
455 }
456
457 #[test]
458 fn systematic_takes_every_nth_row() {
459 let (sampled, _) = run(SamplingStrategy::Systematic { interval: 10 }, 100);
460 assert_eq!(sampled, 10);
461 }
462
463 #[test]
464 fn stratified_caps_each_stratum() {
465 let (sampled, _) = run(
466 SamplingStrategy::Stratified {
467 key_columns: vec!["group".into()],
468 samples_per_stratum: 2,
469 },
470 100,
471 );
472 assert_eq!(sampled, 6);
474 }
475
476 #[test]
477 fn importance_keeps_rows_at_or_above_the_weight() {
478 let (sampled, _) = run(
479 SamplingStrategy::Importance {
480 weight_column: "weight".into(),
481 weight_threshold: 8.0,
482 },
483 100,
484 );
485 assert_eq!(sampled, 20);
487 }
488
489 #[test]
490 fn importance_on_a_missing_column_keeps_nothing() {
491 let (sampled, _) = run(
492 SamplingStrategy::Importance {
493 weight_column: "absent".into(),
494 weight_threshold: 0.0,
495 },
496 50,
497 );
498 assert_eq!(sampled, 0);
499 }
500
501 #[test]
502 fn progressive_stays_within_its_bounds() {
503 let (sampled, _) = run(
504 SamplingStrategy::Progressive {
505 initial_size: 5,
506 confidence_level: 0.95,
507 max_size: 40,
508 },
509 500,
510 );
511 assert!(
512 (5..=40).contains(&sampled),
513 "progressive took {sampled} rows, outside 5..=40"
514 );
515 }
516
517 #[test]
518 fn progressive_stops_once_precision_is_reached() {
519 let headers = vec!["value".to_string()];
523 let strategy = SamplingStrategy::Progressive {
524 initial_size: 10,
525 confidence_level: 0.95,
526 max_size: 1_000,
527 };
528 let mut sampler = RowSampler::new(&strategy).unwrap();
529 for _ in 0..500 {
530 let values = vec!["42".to_string()];
531 sampler.accept(RowView::new(&headers, &values));
532 }
533 assert_eq!(
534 sampler.sampled_rows(),
535 10,
536 "zero-variance data reaches any precision target at initial_size"
537 );
538 }
539
540 #[test]
541 fn progressive_without_numeric_columns_runs_to_max_size() {
542 let headers = vec!["label".to_string()];
543 let strategy = SamplingStrategy::Progressive {
544 initial_size: 5,
545 confidence_level: 0.95,
546 max_size: 30,
547 };
548 let mut sampler = RowSampler::new(&strategy).unwrap();
549 for i in 0..500 {
550 let values = vec![format!("text_{i}")];
551 sampler.accept(RowView::new(&headers, &values));
552 }
553 assert_eq!(
554 sampler.sampled_rows(),
555 30,
556 "precision is unmeasurable without numbers, so the cap decides"
557 );
558 }
559
560 #[test]
561 fn multi_stage_applies_the_filter_then_the_fixed_size_stage() {
562 let strategy = SamplingStrategy::MultiStage {
563 stages: vec![
564 SamplingStrategy::Systematic { interval: 2 },
565 SamplingStrategy::Reservoir { size: 10 },
566 ],
567 };
568 let (sampled, buffered) = run(strategy, 100);
569 assert_eq!(buffered, 10, "the reservoir bounds the final sample");
570 assert_eq!(sampled, 10);
571 }
572
573 #[test]
574 fn multi_stage_rejects_two_fixed_size_stages() {
575 let strategy = SamplingStrategy::MultiStage {
576 stages: vec![
577 SamplingStrategy::Reservoir { size: 10 },
578 SamplingStrategy::Random { size: 5 },
579 ],
580 };
581 let error = RowSampler::new(&strategy).expect_err("two fixed-size stages are ambiguous");
582 assert!(error.to_string().contains("at most one fixed-size stage"));
583 }
584
585 #[test]
586 fn multi_stage_rejects_a_filter_after_a_fixed_size_stage() {
587 let strategy = SamplingStrategy::MultiStage {
588 stages: vec![
589 SamplingStrategy::Reservoir { size: 10 },
590 SamplingStrategy::Systematic { interval: 2 },
591 ],
592 };
593 let error = RowSampler::new(&strategy).expect_err("a filter cannot follow the reservoir");
594 assert!(error.to_string().contains("must be the last stage"));
595 }
596
597 #[test]
598 fn none_is_a_noop() {
599 let sampler = RowSampler::new(&SamplingStrategy::None).unwrap();
600 assert!(sampler.is_noop());
601 assert!(!sampler.is_buffered());
602 }
603
604 #[test]
605 fn row_view_reads_by_name() {
606 let headers = headers();
607 let values = row("a", "1.5", "9");
608 let view = RowView::new(&headers, &values);
609 assert_eq!(view.get("weight"), Some("1.5"));
610 assert_eq!(view.get("missing"), None);
611 }
612}