Skip to main content

arrow_graph/streaming/
approximate.rs

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