Skip to main content

dataprof_core/sampling/
reservoir.rs

1use rand::{Rng, SeedableRng};
2use rand_chacha::ChaCha8Rng;
3use std::collections::HashMap;
4
5/// Enhanced reservoir sampling implementation based on Vitter's algorithm
6///
7/// This is a proper implementation of Algorithm R with optimizations:
8/// - True randomness with seedable RNG for reproducibility
9/// - Optimized skip calculation using geometric distribution
10/// - Memory-efficient storage of sample indices
11/// - Support for weighted sampling
12#[derive(Debug, Clone)]
13pub struct ReservoirSampler {
14    /// Maximum size of the reservoir
15    capacity: usize,
16    /// Current sample (stores row indices)
17    reservoir: Vec<usize>,
18    /// Total number of records processed
19    total_processed: usize,
20    /// Random number generator (seeded for reproducibility)
21    rng: ChaCha8Rng,
22    /// Skip optimization - next record to consider
23    next_record: usize,
24    /// Statistics for analysis
25    stats: ReservoirStats,
26}
27
28/// Statistics for reservoir sampling performance
29#[derive(Debug, Clone, Default)]
30pub struct ReservoirStats {
31    pub records_processed: usize,
32    pub records_sampled: usize,
33    pub replacement_count: usize,
34    pub skip_count: usize,
35    pub efficiency_ratio: f64,
36}
37
38impl ReservoirSampler {
39    /// Create a new reservoir sampler with specified capacity
40    pub fn new(capacity: usize) -> Self {
41        Self::seed(capacity, 42) // Default seed for reproducibility
42    }
43
44    /// Create a new reservoir sampler with custom seed
45    pub fn seed(capacity: usize, seed: u64) -> Self {
46        Self {
47            capacity,
48            reservoir: Vec::with_capacity(capacity),
49            total_processed: 0,
50            rng: ChaCha8Rng::seed_from_u64(seed),
51            next_record: 0,
52            stats: ReservoirStats::default(),
53        }
54    }
55
56    /// Process a new record and decide if it should be included
57    /// Returns true if the record is selected for the sample
58    pub fn process_record(&mut self, record_index: usize) -> bool {
59        self.total_processed += 1;
60        self.stats.records_processed += 1;
61
62        // Phase 1: Fill the reservoir with first k records
63        if self.reservoir.len() < self.capacity {
64            self.reservoir.push(record_index);
65            self.stats.records_sampled += 1;
66            return true;
67        }
68
69        // Phase 2: Reservoir is full, use replacement algorithm
70        self.apply_vitter_algorithm(record_index)
71    }
72
73    /// Apply Vitter's Algorithm R with skip optimization
74    fn apply_vitter_algorithm(&mut self, record_index: usize) -> bool {
75        // Skip records using geometric distribution for efficiency
76        if self.total_processed < self.next_record {
77            return false;
78        }
79
80        // Calculate if this record should replace one in the reservoir
81        let random_index = self.rng.random_range(0..self.total_processed);
82
83        if random_index < self.capacity {
84            // Replace the record at random_index in reservoir
85            let replace_position = random_index % self.capacity;
86            self.reservoir[replace_position] = record_index;
87            self.stats.replacement_count += 1;
88            self.stats.records_sampled += 1;
89
90            // Calculate next skip using geometric distribution
91            self.calculate_next_skip();
92
93            return true;
94        }
95
96        false
97    }
98
99    /// Calculate next skip distance using geometric distribution
100    /// This optimizes performance by skipping records that won't be selected
101    fn calculate_next_skip(&mut self) {
102        // Use geometric distribution to calculate skip distance
103        // This is based on Vitter's Algorithm S optimization
104        let u: f64 = self.rng.random();
105        let skip = if u > 0.0 {
106            ((self.total_processed as f64) * (u.powf(1.0 / self.capacity as f64) - 1.0)) as usize
107        } else {
108            1
109        };
110
111        self.next_record = self.total_processed + skip.max(1);
112        self.stats.skip_count += skip;
113    }
114
115    /// Which reservoir slot row number `seen` should replace, if any.
116    ///
117    /// Algorithm R over a full reservoir: the `seen`-th row of the stream
118    /// (1-based) replaces a uniformly chosen member with probability
119    /// `capacity / seen`, which leaves every row seen so far equally likely to
120    /// be in the sample. Returns `None` when the row is not selected.
121    ///
122    /// This is the primitive for sampling *rows*; [`process_record`] tracks
123    /// indices only and is kept for callers that sample by position.
124    ///
125    /// [`process_record`]: Self::process_record
126    pub fn replacement_slot(&mut self, seen: usize) -> Option<usize> {
127        if self.capacity == 0 || seen <= self.capacity {
128            return None;
129        }
130        self.total_processed = seen;
131        let draw = self.rng.random_range(0..seen);
132        if draw < self.capacity {
133            self.stats.replacement_count += 1;
134            Some(draw)
135        } else {
136            None
137        }
138    }
139
140    /// Get current sample as a vector of indices
141    pub fn get_sample_indices(&self) -> &[usize] {
142        &self.reservoir
143    }
144
145    /// Get current sample size
146    pub fn sample_size(&self) -> usize {
147        self.reservoir.len()
148    }
149
150    /// Check if reservoir is full
151    pub fn is_full(&self) -> bool {
152        self.reservoir.len() >= self.capacity
153    }
154
155    /// Get sampling statistics
156    pub fn get_stats(&self) -> &ReservoirStats {
157        &self.stats
158    }
159
160    /// Calculate current sampling ratio
161    pub fn sampling_ratio(&self) -> f64 {
162        if self.total_processed > 0 {
163            self.reservoir.len() as f64 / self.total_processed as f64
164        } else {
165            0.0
166        }
167    }
168
169    /// Reset the sampler for reuse
170    pub fn reset(&mut self) {
171        self.reservoir.clear();
172        self.total_processed = 0;
173        self.next_record = 0;
174        self.stats = ReservoirStats::default();
175    }
176
177    /// Set new seed for reproducible results
178    pub fn set_seed(&mut self, seed: u64) {
179        self.rng = ChaCha8Rng::seed_from_u64(seed);
180    }
181
182    /// Update efficiency statistics
183    pub fn update_efficiency_stats(&mut self) {
184        self.stats.efficiency_ratio = if self.stats.records_processed > 0 {
185            self.stats.records_sampled as f64 / self.stats.records_processed as f64
186        } else {
187            0.0
188        };
189    }
190}
191
192/// Weighted reservoir sampling for stratified sampling
193#[derive(Debug, Clone)]
194pub struct WeightedReservoirSampler {
195    base_sampler: ReservoirSampler,
196    /// Weights for each record type/stratum
197    weights: HashMap<String, f64>,
198    /// Total weight processed
199    total_weight: f64,
200}
201
202impl WeightedReservoirSampler {
203    pub fn new(capacity: usize, weights: HashMap<String, f64>) -> Self {
204        Self {
205            base_sampler: ReservoirSampler::new(capacity),
206            weights,
207            total_weight: 0.0,
208        }
209    }
210
211    /// Process a record with associated weight category
212    pub fn process_weighted_record(&mut self, record_index: usize, category: &str) -> bool {
213        let weight = self.weights.get(category).copied().unwrap_or(1.0);
214        self.total_weight += weight;
215
216        // Adjust sampling probability based on weight
217        let adjusted_probability = weight / self.total_weight;
218        let u: f64 = self.base_sampler.rng.random();
219
220        if u < adjusted_probability {
221            self.base_sampler.process_record(record_index)
222        } else {
223            self.base_sampler.total_processed += 1;
224            false
225        }
226    }
227
228    pub fn get_sample_indices(&self) -> &[usize] {
229        self.base_sampler.get_sample_indices()
230    }
231
232    pub fn sampling_ratio(&self) -> f64 {
233        self.base_sampler.sampling_ratio()
234    }
235}
236
237/// Multi-reservoir sampling for handling multiple data types
238#[derive(Debug)]
239pub struct MultiReservoirSampler {
240    reservoirs: HashMap<String, ReservoirSampler>,
241    default_capacity: usize,
242}
243
244impl MultiReservoirSampler {
245    pub fn new(default_capacity: usize) -> Self {
246        Self {
247            reservoirs: HashMap::new(),
248            default_capacity,
249        }
250    }
251
252    /// Process a record for a specific category/type
253    pub fn process_categorized_record(&mut self, record_index: usize, category: &str) -> bool {
254        let reservoir = self
255            .reservoirs
256            .entry(category.to_string())
257            .or_insert_with(|| ReservoirSampler::new(self.default_capacity));
258
259        reservoir.process_record(record_index)
260    }
261
262    /// Get combined sample from all reservoirs
263    pub fn get_combined_sample(&self) -> Vec<usize> {
264        let mut combined = Vec::new();
265
266        for reservoir in self.reservoirs.values() {
267            combined.extend_from_slice(reservoir.get_sample_indices());
268        }
269
270        // Sort for consistent ordering
271        combined.sort_unstable();
272        combined
273    }
274
275    /// Get samples by category
276    pub fn get_samples_by_category(&self) -> HashMap<String, Vec<usize>> {
277        self.reservoirs
278            .iter()
279            .map(|(category, reservoir)| {
280                (
281                    category.to_string(),
282                    reservoir.get_sample_indices().to_vec(),
283                )
284            })
285            .collect()
286    }
287
288    /// Get statistics for all reservoirs
289    pub fn get_all_stats(&self) -> HashMap<String, ReservoirStats> {
290        self.reservoirs
291            .iter()
292            .map(|(category, reservoir)| (category.to_string(), reservoir.get_stats().clone()))
293            .collect()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_basic_reservoir_sampling() {
303        let mut sampler = ReservoirSampler::new(10);
304
305        // Process 100 records
306        let mut selected_count = 0;
307        for i in 0..100 {
308            if sampler.process_record(i) {
309                selected_count += 1;
310            }
311        }
312
313        // Should have exactly 10 samples
314        assert_eq!(sampler.sample_size(), 10);
315        assert_eq!(sampler.get_sample_indices().len(), 10);
316        assert!(selected_count >= 10); // May be more due to replacements
317    }
318
319    #[test]
320    fn test_reservoir_filling_phase() {
321        let mut sampler = ReservoirSampler::new(5);
322
323        // First 5 records should all be selected
324        for i in 0..5 {
325            assert!(sampler.process_record(i));
326        }
327
328        assert_eq!(sampler.sample_size(), 5);
329        assert!(sampler.is_full());
330    }
331
332    #[test]
333    fn test_replacement_phase() {
334        let mut sampler = ReservoirSampler::seed(3, 42); // Fixed seed for reproducibility
335
336        // Fill reservoir
337        for i in 0..3 {
338            sampler.process_record(i);
339        }
340
341        // Process more records
342        let _initial_sample = sampler.get_sample_indices().to_vec();
343
344        for i in 3..20 {
345            sampler.process_record(i);
346        }
347
348        let final_sample = sampler.get_sample_indices().to_vec();
349
350        // Sample size should remain the same
351        assert_eq!(final_sample.len(), 3);
352
353        // Some replacements should have occurred
354        assert!(sampler.get_stats().replacement_count > 0);
355    }
356
357    #[test]
358    fn test_sampling_ratio() {
359        let mut sampler = ReservoirSampler::new(10);
360
361        for i in 0..100 {
362            sampler.process_record(i);
363        }
364
365        let ratio = sampler.sampling_ratio();
366        assert!((ratio - 0.1).abs() < 0.01); // Should be ~10%
367    }
368
369    #[test]
370    fn test_reset_functionality() {
371        let mut sampler = ReservoirSampler::new(5);
372
373        for i in 0..10 {
374            sampler.process_record(i);
375        }
376
377        assert_eq!(sampler.sample_size(), 5);
378        assert!(sampler.total_processed > 0);
379
380        sampler.reset();
381
382        assert_eq!(sampler.sample_size(), 0);
383        assert_eq!(sampler.total_processed, 0);
384    }
385
386    #[test]
387    fn test_weighted_sampling() {
388        let mut weights = HashMap::new();
389        weights.insert("high".to_string(), 3.0);
390        weights.insert("low".to_string(), 1.0);
391
392        let mut sampler = WeightedReservoirSampler::new(10, weights);
393
394        let mut _high_selected = 0;
395        let mut _low_selected = 0;
396
397        // Process records with different weights
398        for i in 0..50 {
399            let category = if i % 2 == 0 { "high" } else { "low" };
400            if sampler.process_weighted_record(i, category) {
401                if category == "high" {
402                    _high_selected += 1;
403                } else {
404                    _low_selected += 1;
405                }
406            }
407        }
408
409        // High weight records should be selected more frequently
410        // This is probabilistic, so we allow some variance
411        assert!(sampler.get_sample_indices().len() <= 10);
412    }
413
414    #[test]
415    fn test_multi_reservoir() {
416        let mut sampler = MultiReservoirSampler::new(5);
417
418        for i in 0..20 {
419            let category = format!("type_{}", i % 3);
420            sampler.process_categorized_record(i, &category);
421        }
422
423        let combined = sampler.get_combined_sample();
424        assert!(combined.len() <= 15); // Max 5 per category * 3 categories
425
426        let by_category = sampler.get_samples_by_category();
427        assert_eq!(by_category.len(), 3); // Should have 3 categories
428    }
429
430    #[test]
431    fn test_deterministic_with_seed() {
432        let mut sampler1 = ReservoirSampler::seed(5, 123);
433        let mut sampler2 = ReservoirSampler::seed(5, 123);
434
435        for i in 0..50 {
436            sampler1.process_record(i);
437            sampler2.process_record(i);
438        }
439
440        // Same seed should produce identical samples
441        assert_eq!(sampler1.get_sample_indices(), sampler2.get_sample_indices());
442    }
443}