arrow_graph/streaming/
approximate.rs

1use crate::error::Result;
2use crate::streaming::incremental::IncrementalGraphProcessor;
3use arrow::array::Array;
4use std::collections::HashMap;
5use std::hash::{Hash, Hasher};
6
7/// Approximate algorithms for large-scale graph analytics
8/// These algorithms trade accuracy for performance and memory efficiency
9
10/// HyperLogLog implementation for approximate cardinality estimation
11/// Useful for counting unique nodes, edges, or other graph elements
12#[derive(Debug, Clone)]
13pub struct HyperLogLog {
14    buckets: Vec<u8>,
15    bucket_count: usize,
16    alpha: f64,
17}
18
19impl HyperLogLog {
20    /// Create new HyperLogLog with specified precision
21    /// precision: number of bits for bucket selection (4-16)
22    pub fn new(precision: u8) -> Self {
23        let precision = precision.clamp(4, 16);
24        let bucket_count = 1 << precision; // 2^precision
25        
26        let alpha = match bucket_count {
27            16 => 0.673,
28            32 => 0.697,
29            64 => 0.709,
30            _ => 0.7213 / (1.0 + 1.079 / bucket_count as f64),
31        };
32
33        Self {
34            buckets: vec![0; bucket_count],
35            bucket_count,
36            alpha,
37        }
38    }
39
40    /// Add an element to the HyperLogLog
41    pub fn add<T: Hash>(&mut self, element: &T) {
42        let hash = self.hash_element(element);
43        let bucket_index = (hash >> (64 - self.precision_bits())) as usize;
44        let leading_zeros = (hash << self.precision_bits()).leading_zeros() as u8 + 1;
45        
46        self.buckets[bucket_index] = self.buckets[bucket_index].max(leading_zeros);
47    }
48
49    /// Estimate the cardinality
50    pub fn estimate(&self) -> f64 {
51        let raw_estimate = self.alpha * (self.bucket_count as f64).powi(2) / 
52            self.buckets.iter().map(|&b| 2.0_f64.powf(-(b as f64))).sum::<f64>();
53
54        // Apply small range correction
55        if raw_estimate <= 2.5 * self.bucket_count as f64 {
56            let zero_count = self.buckets.iter().filter(|&&b| b == 0).count();
57            if zero_count != 0 {
58                return (self.bucket_count as f64) * (self.bucket_count as f64 / zero_count as f64).ln();
59            }
60        }
61
62        // Apply large range correction for 64-bit hash
63        if raw_estimate <= (1.0 / 30.0) * (1u64 << 32) as f64 {
64            raw_estimate
65        } else {
66            -((1u64 << 32) as f64) * (1.0 - raw_estimate / ((1u64 << 32) as f64)).ln()
67        }
68    }
69
70    /// Merge with another HyperLogLog
71    pub fn merge(&mut self, other: &HyperLogLog) -> Result<()> {
72        if self.bucket_count != other.bucket_count {
73            return Err(crate::error::GraphError::graph_construction("HyperLogLog bucket counts must match for merge"));
74        }
75
76        for i in 0..self.bucket_count {
77            self.buckets[i] = self.buckets[i].max(other.buckets[i]);
78        }
79
80        Ok(())
81    }
82
83    fn hash_element<T: Hash>(&self, element: &T) -> u64 {
84        let mut hasher = std::collections::hash_map::DefaultHasher::new();
85        element.hash(&mut hasher);
86        hasher.finish()
87    }
88
89    fn precision_bits(&self) -> u32 {
90        (self.bucket_count as f64).log2() as u32
91    }
92}
93
94/// Count-Min Sketch for approximate frequency counting
95/// Useful for tracking edge weights, node degrees, or other frequencies
96#[derive(Debug, Clone)]
97pub struct CountMinSketch {
98    counters: Vec<Vec<u32>>,
99    hash_functions: Vec<HashFunction>,
100    width: usize,
101    depth: usize,
102}
103
104#[derive(Debug, Clone)]
105struct HashFunction {
106    a: u64,
107    b: u64,
108    p: u64, // Large prime
109}
110
111impl CountMinSketch {
112    /// Create new Count-Min Sketch
113    /// width: number of buckets per row
114    /// depth: number of hash functions/rows
115    pub fn new(width: usize, depth: usize) -> Self {
116        let mut hash_functions = Vec::new();
117        let p = 2147483647u64; // Large prime
118
119        for i in 0..depth {
120            hash_functions.push(HashFunction {
121                a: (i as u64 * 2 + 1) * 1000000007,
122                b: (i as u64 + 1) * 1000000009,
123                p,
124            });
125        }
126
127        Self {
128            counters: vec![vec![0; width]; depth],
129            hash_functions,
130            width,
131            depth,
132        }
133    }
134
135    /// Add an element with count
136    pub fn add<T: Hash>(&mut self, element: &T, count: u32) {
137        let hash = self.hash_element(element);
138        
139        for (i, hash_func) in self.hash_functions.iter().enumerate() {
140            let index = hash_func.hash(hash) % self.width as u64;
141            self.counters[i][index as usize] += count;
142        }
143    }
144
145    /// Estimate the count of an element
146    pub fn estimate<T: Hash>(&self, element: &T) -> u32 {
147        let hash = self.hash_element(element);
148        let mut min_count = u32::MAX;
149        
150        for (i, hash_func) in self.hash_functions.iter().enumerate() {
151            let index = hash_func.hash(hash) % self.width as u64;
152            min_count = min_count.min(self.counters[i][index as usize]);
153        }
154        
155        min_count
156    }
157
158    /// Merge with another Count-Min Sketch
159    pub fn merge(&mut self, other: &CountMinSketch) -> Result<()> {
160        if self.width != other.width || self.depth != other.depth {
161            return Err(crate::error::GraphError::graph_construction("Count-Min Sketch dimensions must match for merge"));
162        }
163
164        for i in 0..self.depth {
165            for j in 0..self.width {
166                self.counters[i][j] += other.counters[i][j];
167            }
168        }
169
170        Ok(())
171    }
172
173    fn hash_element<T: Hash>(&self, element: &T) -> u64 {
174        let mut hasher = std::collections::hash_map::DefaultHasher::new();
175        element.hash(&mut hasher);
176        hasher.finish()
177    }
178}
179
180impl HashFunction {
181    fn hash(&self, x: u64) -> u64 {
182        ((self.a.wrapping_mul(x).wrapping_add(self.b)) % self.p) % self.p
183    }
184}
185
186/// Bloom Filter for approximate set membership testing
187/// Useful for checking if nodes/edges exist without storing the full set
188#[derive(Debug, Clone)]
189pub struct BloomFilter {
190    bit_array: Vec<bool>,
191    size: usize,
192    hash_count: u32,
193    element_count: usize,
194}
195
196impl BloomFilter {
197    /// Create new Bloom Filter
198    /// expected_elements: expected number of elements
199    /// false_positive_rate: desired false positive rate (0.0 - 1.0)
200    pub fn new(expected_elements: usize, false_positive_rate: f64) -> Self {
201        let size = Self::optimal_size(expected_elements, false_positive_rate);
202        let hash_count = Self::optimal_hash_count(size, expected_elements);
203
204        Self {
205            bit_array: vec![false; size],
206            size,
207            hash_count,
208            element_count: 0,
209        }
210    }
211
212    /// Add an element to the filter
213    pub fn add<T: Hash>(&mut self, element: &T) {
214        let hashes = self.hash_element(element);
215        
216        for i in 0..self.hash_count {
217            let index = (hashes.0.wrapping_add((i as u64).wrapping_mul(hashes.1))) % self.size as u64;
218            self.bit_array[index as usize] = true;
219        }
220        
221        self.element_count += 1;
222    }
223
224    /// Check if an element might be in the set
225    /// Returns true if element might be present (could be false positive)
226    /// Returns false if element is definitely not present
227    pub fn contains<T: Hash>(&self, element: &T) -> bool {
228        let hashes = self.hash_element(element);
229        
230        for i in 0..self.hash_count {
231            let index = (hashes.0.wrapping_add((i as u64).wrapping_mul(hashes.1))) % self.size as u64;
232            if !self.bit_array[index as usize] {
233                return false;
234            }
235        }
236        
237        true
238    }
239
240    /// Get current false positive probability estimate
241    pub fn false_positive_probability(&self) -> f64 {
242        let filled_ratio = self.bit_array.iter().filter(|&&b| b).count() as f64 / self.size as f64;
243        filled_ratio.powf(self.hash_count as f64)
244    }
245
246    /// Union with another Bloom Filter
247    pub fn union(&mut self, other: &BloomFilter) -> Result<()> {
248        if self.size != other.size || self.hash_count != other.hash_count {
249            return Err(crate::error::GraphError::graph_construction("Bloom Filter parameters must match for union"));
250        }
251
252        for i in 0..self.size {
253            self.bit_array[i] |= other.bit_array[i];
254        }
255
256        self.element_count += other.element_count;
257        Ok(())
258    }
259
260    fn optimal_size(expected_elements: usize, false_positive_rate: f64) -> usize {
261        let m = -(expected_elements as f64 * false_positive_rate.ln()) / (2.0_f64.ln().powi(2));
262        m.ceil() as usize
263    }
264
265    fn optimal_hash_count(size: usize, expected_elements: usize) -> u32 {
266        let k = (size as f64 / expected_elements as f64) * 2.0_f64.ln();
267        k.round().max(1.0) as u32
268    }
269
270    fn hash_element<T: Hash>(&self, element: &T) -> (u64, u64) {
271        let mut hasher1 = std::collections::hash_map::DefaultHasher::new();
272        let mut hasher2 = std::collections::hash_map::DefaultHasher::new();
273        
274        element.hash(&mut hasher1);
275        (element, 1u8).hash(&mut hasher2); // Add salt for second hash
276        
277        (hasher1.finish(), hasher2.finish())
278    }
279}
280
281/// Approximate graph analytics processor
282/// Combines multiple approximate algorithms for scalable graph analysis
283#[derive(Debug)]
284pub struct ApproximateGraphProcessor {
285    // Cardinality estimators
286    unique_nodes: HyperLogLog,
287    unique_edges: HyperLogLog,
288    unique_triangles: HyperLogLog,
289    
290    // Frequency counters
291    node_degrees: CountMinSketch,
292    edge_weights: CountMinSketch,
293    
294    // Set membership
295    active_nodes: BloomFilter,
296    recent_edges: BloomFilter,
297    
298    // Configuration
299    config: ApproximateConfig,
300}
301
302/// Configuration for approximate algorithms
303#[derive(Debug, Clone)]
304pub struct ApproximateConfig {
305    pub hyperloglog_precision: u8,
306    pub count_min_width: usize,
307    pub count_min_depth: usize,
308    pub bloom_expected_elements: usize,
309    pub bloom_false_positive_rate: f64,
310}
311
312impl Default for ApproximateConfig {
313    fn default() -> Self {
314        Self {
315            hyperloglog_precision: 12, // ~1.6% error
316            count_min_width: 1000,
317            count_min_depth: 5,
318            bloom_expected_elements: 10000,
319            bloom_false_positive_rate: 0.01, // 1% false positive rate
320        }
321    }
322}
323
324impl ApproximateGraphProcessor {
325    /// Create new approximate graph processor
326    pub fn new(config: ApproximateConfig) -> Self {
327        Self {
328            unique_nodes: HyperLogLog::new(config.hyperloglog_precision),
329            unique_edges: HyperLogLog::new(config.hyperloglog_precision),
330            unique_triangles: HyperLogLog::new(config.hyperloglog_precision),
331            node_degrees: CountMinSketch::new(config.count_min_width, config.count_min_depth),
332            edge_weights: CountMinSketch::new(config.count_min_width, config.count_min_depth),
333            active_nodes: BloomFilter::new(config.bloom_expected_elements, config.bloom_false_positive_rate),
334            recent_edges: BloomFilter::new(config.bloom_expected_elements * 2, config.bloom_false_positive_rate),
335            config,
336        }
337    }
338
339    /// Initialize with graph data
340    pub fn initialize(&mut self, processor: &IncrementalGraphProcessor) -> Result<()> {
341        let graph = processor.graph();
342        let nodes_batch = &graph.nodes;
343        let edges_batch = &graph.edges;
344
345        // Process nodes
346        if nodes_batch.num_rows() > 0 {
347            let node_ids = nodes_batch.column(0)
348                .as_any()
349                .downcast_ref::<arrow::array::StringArray>()
350                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected string array for node IDs"))?;
351
352            for i in 0..node_ids.len() {
353                let node_id = node_ids.value(i);
354                self.unique_nodes.add(&node_id);
355                self.active_nodes.add(&node_id);
356            }
357        }
358
359        // Process edges
360        if edges_batch.num_rows() > 0 {
361            let source_ids = edges_batch.column(0)
362                .as_any()
363                .downcast_ref::<arrow::array::StringArray>()
364                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected string array for source IDs"))?;
365            let target_ids = edges_batch.column(1)
366                .as_any()
367                .downcast_ref::<arrow::array::StringArray>()
368                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected string array for target IDs"))?;
369            let weights = edges_batch.column(2)
370                .as_any()
371                .downcast_ref::<arrow::array::Float64Array>()
372                .ok_or_else(|| crate::error::GraphError::graph_construction("Expected float64 array for weights"))?;
373
374            for i in 0..source_ids.len() {
375                let source = source_ids.value(i);
376                let target = target_ids.value(i);
377                let weight = weights.value(i);
378
379                let edge = (source, target);
380                self.unique_edges.add(&edge);
381                self.recent_edges.add(&edge);
382
383                // Update degree counts
384                self.node_degrees.add(&source, 1);
385                self.node_degrees.add(&target, 1);
386
387                // Update edge weight
388                let weight_key = format!("{}→{}", source, target);
389                self.edge_weights.add(&weight_key, (weight * 1000.0) as u32); // Scale for integer storage
390            }
391        }
392
393        Ok(())
394    }
395
396    /// Update with new graph changes
397    pub fn update(&mut self, processor: &IncrementalGraphProcessor) -> Result<ApproximateMetrics> {
398        // For simplicity, reinitialize on updates
399        // A full implementation would incrementally update each structure
400        self.initialize(processor)?;
401
402        Ok(self.get_metrics())
403    }
404
405    /// Get current approximate metrics
406    pub fn get_metrics(&self) -> ApproximateMetrics {
407        ApproximateMetrics {
408            estimated_unique_nodes: self.unique_nodes.estimate(),
409            estimated_unique_edges: self.unique_edges.estimate(),
410            estimated_unique_triangles: self.unique_triangles.estimate(),
411            active_nodes_bloom_fill_ratio: self.active_nodes.false_positive_probability(),
412            recent_edges_bloom_fill_ratio: self.recent_edges.false_positive_probability(),
413        }
414    }
415
416    /// Check if a node is likely active
417    pub fn is_node_active(&self, node_id: &str) -> bool {
418        self.active_nodes.contains(&node_id)
419    }
420
421    /// Check if an edge was recently added
422    pub fn is_edge_recent(&self, source: &str, target: &str) -> bool {
423        let edge = (source, target);
424        self.recent_edges.contains(&edge)
425    }
426
427    /// Estimate node degree
428    pub fn estimate_node_degree(&self, node_id: &str) -> u32 {
429        self.node_degrees.estimate(&node_id)
430    }
431
432    /// Estimate edge weight
433    pub fn estimate_edge_weight(&self, source: &str, target: &str) -> f64 {
434        let weight_key = format!("{}→{}", source, target);
435        self.edge_weights.estimate(&weight_key) as f64 / 1000.0 // Unscale
436    }
437
438    /// Merge with another approximate processor
439    pub fn merge(&mut self, other: &ApproximateGraphProcessor) -> Result<()> {
440        self.unique_nodes.merge(&other.unique_nodes)?;
441        self.unique_edges.merge(&other.unique_edges)?;
442        self.unique_triangles.merge(&other.unique_triangles)?;
443        self.node_degrees.merge(&other.node_degrees)?;
444        self.edge_weights.merge(&other.edge_weights)?;
445        self.active_nodes.union(&other.active_nodes)?;
446        self.recent_edges.union(&other.recent_edges)?;
447        Ok(())
448    }
449
450    /// Add triangle for approximate triangle counting
451    pub fn add_triangle(&mut self, node_a: &str, node_b: &str, node_c: &str) {
452        // Create canonical triangle representation
453        let mut nodes = vec![node_a, node_b, node_c];
454        nodes.sort();
455        let triangle = format!("{}→{}→{}", nodes[0], nodes[1], nodes[2]);
456        self.unique_triangles.add(&triangle);
457    }
458
459    /// Reset all approximate structures
460    pub fn reset(&mut self) {
461        self.unique_nodes = HyperLogLog::new(self.config.hyperloglog_precision);
462        self.unique_edges = HyperLogLog::new(self.config.hyperloglog_precision);
463        self.unique_triangles = HyperLogLog::new(self.config.hyperloglog_precision);
464        self.node_degrees = CountMinSketch::new(self.config.count_min_width, self.config.count_min_depth);
465        self.edge_weights = CountMinSketch::new(self.config.count_min_width, self.config.count_min_depth);
466        self.active_nodes = BloomFilter::new(self.config.bloom_expected_elements, self.config.bloom_false_positive_rate);
467        self.recent_edges = BloomFilter::new(self.config.bloom_expected_elements * 2, self.config.bloom_false_positive_rate);
468    }
469}
470
471/// Approximate metrics computed by the processor
472#[derive(Debug, Clone)]
473pub struct ApproximateMetrics {
474    pub estimated_unique_nodes: f64,
475    pub estimated_unique_edges: f64,
476    pub estimated_unique_triangles: f64,
477    pub active_nodes_bloom_fill_ratio: f64,
478    pub recent_edges_bloom_fill_ratio: f64,
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::graph::ArrowGraph;
485    use arrow::array::{StringArray, Float64Array};
486    use arrow::record_batch::RecordBatch;
487    use arrow::datatypes::{Schema, Field, DataType};
488    use std::sync::Arc;
489
490    #[test]
491    fn test_hyperloglog_basic() {
492        let mut hll = HyperLogLog::new(12);
493        
494        // Add some elements
495        for i in 0..1000 {
496            hll.add(&format!("element_{}", i));
497        }
498        
499        let estimate = hll.estimate();
500        
501        // Should be close to 1000 (within ~1.6% error for precision 12)
502        assert!(estimate > 950.0 && estimate < 1050.0);
503    }
504
505    #[test]
506    fn test_hyperloglog_merge() {
507        let mut hll1 = HyperLogLog::new(8);
508        let mut hll2 = HyperLogLog::new(8);
509        
510        // Add different elements to each
511        for i in 0..500 {
512            hll1.add(&format!("a_{}", i));
513            hll2.add(&format!("b_{}", i));
514        }
515        
516        let estimate1 = hll1.estimate();
517        let estimate2 = hll2.estimate();
518        
519        hll1.merge(&hll2).unwrap();
520        let merged_estimate = hll1.estimate();
521        
522        // Merged estimate should be roughly the sum
523        assert!(merged_estimate > estimate1 + estimate2 - 100.0);
524    }
525
526    #[test]
527    fn test_count_min_sketch() {
528        let mut cms = CountMinSketch::new(100, 5);
529        
530        // Add some elements with counts
531        cms.add(&"element1", 10);
532        cms.add(&"element2", 5);
533        cms.add(&"element1", 3); // Add more to element1
534        
535        // Estimates should be at least the actual counts
536        assert!(cms.estimate(&"element1") >= 13);
537        assert!(cms.estimate(&"element2") >= 5);
538        assert_eq!(cms.estimate(&"nonexistent"), 0);
539    }
540
541    #[test]
542    fn test_bloom_filter() {
543        let mut bloom = BloomFilter::new(1000, 0.01);
544        
545        // Add some elements
546        bloom.add(&"element1");
547        bloom.add(&"element2");
548        bloom.add(&"element3");
549        
550        // Should contain added elements
551        assert!(bloom.contains(&"element1"));
552        assert!(bloom.contains(&"element2"));
553        assert!(bloom.contains(&"element3"));
554        
555        // Should not contain non-added elements (with high probability)
556        assert!(!bloom.contains(&"nonexistent"));
557    }
558
559    #[test]
560    fn test_bloom_filter_union() {
561        let mut bloom1 = BloomFilter::new(1000, 0.01);
562        let mut bloom2 = BloomFilter::new(1000, 0.01);
563        
564        bloom1.add(&"element1");
565        bloom2.add(&"element2");
566        
567        bloom1.union(&bloom2).unwrap();
568        
569        // Should contain elements from both filters
570        assert!(bloom1.contains(&"element1"));
571        assert!(bloom1.contains(&"element2"));
572    }
573
574    fn create_test_graph() -> Result<ArrowGraph> {
575        let nodes_schema = Arc::new(Schema::new(vec![
576            Field::new("id", DataType::Utf8, false),
577        ]));
578        let node_ids = StringArray::from(vec!["A", "B", "C", "D"]);
579        let nodes_batch = RecordBatch::try_new(
580            nodes_schema,
581            vec![Arc::new(node_ids)],
582        )?;
583
584        let edges_schema = Arc::new(Schema::new(vec![
585            Field::new("source", DataType::Utf8, false),
586            Field::new("target", DataType::Utf8, false),
587            Field::new("weight", DataType::Float64, false),
588        ]));
589        let sources = StringArray::from(vec!["A", "B", "C"]);
590        let targets = StringArray::from(vec!["B", "C", "D"]);
591        let weights = Float64Array::from(vec![1.0, 2.0, 3.0]);
592        let edges_batch = RecordBatch::try_new(
593            edges_schema,
594            vec![Arc::new(sources), Arc::new(targets), Arc::new(weights)],
595        )?;
596
597        ArrowGraph::new(nodes_batch, edges_batch)
598    }
599
600    #[test]
601    fn test_approximate_graph_processor() {
602        let graph = create_test_graph().unwrap();
603        let processor = IncrementalGraphProcessor::new(graph).unwrap();
604        
605        let mut approx = ApproximateGraphProcessor::new(ApproximateConfig::default());
606        approx.initialize(&processor).unwrap();
607        
608        let metrics = approx.get_metrics();
609        
610        // Should estimate roughly 4 nodes and 3 edges
611        assert!(metrics.estimated_unique_nodes > 3.0 && metrics.estimated_unique_nodes < 5.0);
612        assert!(metrics.estimated_unique_edges > 2.0 && metrics.estimated_unique_edges < 4.0);
613    }
614
615    #[test]
616    fn test_node_and_edge_queries() {
617        let graph = create_test_graph().unwrap();
618        let processor = IncrementalGraphProcessor::new(graph).unwrap();
619        
620        let mut approx = ApproximateGraphProcessor::new(ApproximateConfig::default());
621        approx.initialize(&processor).unwrap();
622        
623        // Should detect active nodes
624        assert!(approx.is_node_active("A"));
625        assert!(approx.is_node_active("B"));
626        assert!(!approx.is_node_active("nonexistent")); // High probability
627        
628        // Should detect recent edges
629        assert!(approx.is_edge_recent("A", "B"));
630        assert!(approx.is_edge_recent("B", "C"));
631        
632        // Should estimate node degrees
633        assert!(approx.estimate_node_degree("A") > 0);
634        assert!(approx.estimate_node_degree("B") > 0);
635    }
636
637    #[test]
638    fn test_triangle_counting() {
639        let mut approx = ApproximateGraphProcessor::new(ApproximateConfig::default());
640        
641        // Add some triangles
642        approx.add_triangle("A", "B", "C");
643        approx.add_triangle("B", "C", "D");
644        approx.add_triangle("A", "B", "C"); // Duplicate
645        
646        let metrics = approx.get_metrics();
647        
648        // Should estimate around 2 unique triangles
649        assert!(metrics.estimated_unique_triangles > 1.5 && metrics.estimated_unique_triangles < 2.5);
650    }
651
652    #[test]
653    fn test_processor_merge() {
654        let config = ApproximateConfig::default();
655        
656        let mut approx1 = ApproximateGraphProcessor::new(config.clone());
657        let mut approx2 = ApproximateGraphProcessor::new(config);
658        
659        // Add different elements to each
660        approx1.unique_nodes.add(&"A");
661        approx1.unique_nodes.add(&"B");
662        
663        approx2.unique_nodes.add(&"C");
664        approx2.unique_nodes.add(&"D");
665        
666        let estimate1 = approx1.get_metrics().estimated_unique_nodes;
667        let estimate2 = approx2.get_metrics().estimated_unique_nodes;
668        
669        approx1.merge(&approx2).unwrap();
670        let merged_estimate = approx1.get_metrics().estimated_unique_nodes;
671        
672        // Merged estimate should be higher than individual estimates
673        assert!(merged_estimate > estimate1);
674        assert!(merged_estimate > estimate2);
675    }
676}