Skip to main content

linkrs_query/optimizer/cost_based/
bidirectional_traversal.rs

1//! Bidirectional Traversal Optimizer
2//!
3//! Optimization decisions for bidirectional BFS traversals in shortest path queries
4//! Bidirectional BFS searches from both the start and end points simultaneously, reducing the complexity from O(b^d) to O(b^(d/2))
5
6use std::sync::Arc;
7
8use crate::optimizer::cost::config::{
9    DEFAULT_DEGREE_FALLBACK, SKEW_MODERATE_GINI, SKEW_SEVERE_GINI,
10};
11use crate::optimizer::cost::CostCalculator;
12use crate::optimizer::stats::{EdgeTypeStatistics, StatisticsManager};
13use linkrs_core::types::EdgeDirection;
14
15/// Deep context allocation
16#[derive(Debug, Clone)]
17pub struct DepthAllocationContext {
18    /// Name of the starting variable
19    pub start_variable: String,
20    /// Destination variable name
21    pub end_variable: String,
22    /// List of edge types
23    pub edge_types: Vec<String>,
24    /// Total traversal depth
25    pub total_depth: u32,
26    /// Source tag (if any):
27    pub start_tag: Option<String>,
28    /// Destination tag (if any)
29    pub end_tag: Option<String>,
30    /// Estimated starting degree (if known)
31    pub start_degree_hint: Option<f64>,
32    /// Estimated degree at the destination (if known)
33    pub end_degree_hint: Option<f64>,
34}
35
36impl DepthAllocationContext {
37    /// Create a new deep allocation context.
38    pub fn new(
39        start_variable: impl Into<String>,
40        end_variable: impl Into<String>,
41        edge_types: Vec<String>,
42        total_depth: u32,
43    ) -> Self {
44        Self {
45            start_variable: start_variable.into(),
46            end_variable: end_variable.into(),
47            edge_types,
48            total_depth,
49            start_tag: None,
50            end_tag: None,
51            start_degree_hint: None,
52            end_degree_hint: None,
53        }
54    }
55
56    /// Set the starting point label
57    pub fn with_start_tag(mut self, tag: impl Into<String>) -> Self {
58        self.start_tag = Some(tag.into());
59        self
60    }
61
62    /// Set the destination label
63    pub fn with_end_tag(mut self, tag: impl Into<String>) -> Self {
64        self.end_tag = Some(tag.into());
65        self
66    }
67
68    /// “Set the starting degree value” prompt
69    pub fn with_start_degree(mut self, degree: f64) -> Self {
70        self.start_degree_hint = Some(degree);
71        self
72    }
73
74    /// Set a reminder for the target degree value
75    pub fn with_end_degree(mut self, degree: f64) -> Self {
76        self.end_degree_hint = Some(degree);
77        self
78    }
79}
80
81/// Bidirectional traversal decision
82#[derive(Debug, Clone)]
83pub struct BidirectionalDecision {
84    /// Should we use bidirectional traversal?
85    pub use_bidirectional: bool,
86    /// Starting variable for forward search
87    pub forward_start: String,
88    /// Reverse search starting variable
89    pub backward_start: String,
90    /// Expected proportion of reduction in the search space
91    pub estimated_savings: f64,
92    /// Recommended depth limit
93    pub recommended_depth: u32,
94}
95
96impl BidirectionalDecision {
97    /// Creating a decision-making process that does not use bidirectional traversal
98    pub fn unidirectional(forward_start: String) -> Self {
99        Self {
100            use_bidirectional: false,
101            forward_start,
102            backward_start: String::new(),
103            estimated_savings: 0.0,
104            recommended_depth: 0,
105        }
106    }
107
108    /// Creating a decision-making process that uses bidirectional traversal
109    pub fn bidirectional(
110        forward_start: String,
111        backward_start: String,
112        estimated_savings: f64,
113        recommended_depth: u32,
114    ) -> Self {
115        Self {
116            use_bidirectional: true,
117            forward_start,
118            backward_start,
119            estimated_savings,
120            recommended_depth,
121        }
122    }
123}
124
125/// Bidirectional Traversal Optimizer
126pub struct BidirectionalTraversalOptimizer {
127    /// Cost Calculator (reserved for more accurate cost estimations in the future)
128    cost_calculator: Arc<CostCalculator>,
129    /// Statistics Information Manager
130    stats_manager: Arc<StatisticsManager>,
131}
132
133impl BidirectionalTraversalOptimizer {
134    /// Create a new optimizer for bidirectional traversal optimization.
135    pub fn new(
136        cost_calculator: Arc<CostCalculator>,
137        stats_manager: Arc<StatisticsManager>,
138    ) -> Self {
139        Self {
140            cost_calculator,
141            stats_manager,
142        }
143    }
144
145    /// Evaluating whether it is suitable for bidirectional traversal
146    ///
147    /// # Parameters
148    /// `start_variable`: The name of the starting variable
149    /// `end_variable`: The name of the destination variable
150    /// `edge_types`: List of edge types
151    /// `max_depth`: The maximum depth of the traversal.
152    ///
153    /// # Return value
154    /// Return the decision for bidirectional traversal.
155    pub fn evaluate(
156        &self,
157        space: &str,
158        start_variable: &str,
159        end_variable: &str,
160        edge_types: &[String],
161        max_depth: u32,
162    ) -> BidirectionalDecision {
163        // When the depth is less than 2, the benefits of bidirectional traversal are not significant.
164        if max_depth < 2 {
165            return BidirectionalDecision::unidirectional(start_variable.to_string());
166        }
167
168        // Obtaining the average branching factor
169        let avg_branching = self.estimate_average_branching(space, edge_types);
170
171        // Calculate the one-way search space: b^d
172        let unidirectional_cost = avg_branching.powi(max_depth as i32);
173
174        // Calculate the size of the bidirectional search space: 2 * b^(d/2)
175        let half_depth = (max_depth as f64 / 2.0).ceil() as i32;
176        let bidirectional_cost = 2.0 * avg_branching.powi(half_depth);
177
178        // Calculate the savings percentage
179        let savings = if unidirectional_cost > 0.0 {
180            1.0 - (bidirectional_cost / unidirectional_cost)
181        } else {
182            0.0
183        };
184
185        // Use threshold from config for bidirectional savings
186        let threshold = self
187            .cost_calculator
188            .config()
189            .strategy_thresholds
190            .bidirectional_savings_threshold;
191        if savings > threshold {
192            BidirectionalDecision::bidirectional(
193                start_variable.to_string(),
194                end_variable.to_string(),
195                savings,
196                max_depth,
197            )
198        } else {
199            BidirectionalDecision::unidirectional(start_variable.to_string())
200        }
201    }
202
203    /// Evaluating whether the path query is suitable for bidirectional traversal
204    pub fn evaluate_path_query(
205        &self,
206        space: &str,
207        start_variable: &str,
208        end_variable: &str,
209        edge_types: &[String],
210        min_depth: u32,
211        max_depth: u32,
212    ) -> BidirectionalDecision {
213        // For path queries, it is necessary to consider the depth range.
214        let avg_depth = ((min_depth + max_depth) as f64 / 2.0) as u32;
215
216        // If the minimum depth is relatively large, a bidirectional traversal is more worthwhile.
217        if min_depth >= 2 {
218            let decision =
219                self.evaluate(space, start_variable, end_variable, edge_types, avg_depth);
220            if decision.use_bidirectional {
221                return decision;
222            }
223        }
224
225        // Otherwise, use the standard evaluation method.
226        self.evaluate(space, start_variable, end_variable, edge_types, max_depth)
227    }
228
229    /// Estimate the average branching factor
230    fn estimate_average_branching(&self, space: &str, edge_types: &[String]) -> f64 {
231        if edge_types.is_empty() {
232            return DEFAULT_DEGREE_FALLBACK;
233        }
234
235        let mut total_branching = 0.0;
236        let mut count = 0;
237
238        for edge_type in edge_types {
239            if let Some(stats) = self.stats_manager.get_edge_stats(space, edge_type) {
240                // Using the average outdegree as the branching factor for estimation
241                let branching = stats.avg_out_degree;
242                total_branching += branching;
243                count += 1;
244            }
245        }
246
247        if count > 0 {
248            total_branching / count as f64
249        } else {
250            DEFAULT_DEGREE_FALLBACK
251        }
252    }
253
254    /// Calculating the recommended depth distribution for bidirectional traversal
255    ///
256    /// Based on statistical information about edge types and degree estimates, the depth of forward and backward searches is intelligently allocated.
257    /// Strategy: Allocate more depth to the end with the smaller degree value, as there are fewer branches and a smaller search space.
258    pub fn calculate_depth_allocation(
259        &self,
260        space: &str,
261        context: &DepthAllocationContext,
262    ) -> (u32, u32) {
263        let total_depth = context.total_depth;
264
265        // When the depth is less than 2, the resources are evenly distributed directly.
266        if total_depth < 2 {
267            return (total_depth, 0);
268        }
269
270        // Obtain statistical information about the edge types.
271        let edge_stats: Vec<EdgeTypeStatistics> = context
272            .edge_types
273            .iter()
274            .filter_map(|et| self.stats_manager.get_edge_stats(space, et))
275            .collect();
276
277        // Estimate the degrees of the starting and ending points.
278        let start_degree = context
279            .start_degree_hint
280            .or_else(|| {
281                self.estimate_vertex_degree(
282                    space,
283                    &context.start_tag,
284                    &edge_stats,
285                    EdgeDirection::Out,
286                )
287            })
288            .unwrap_or_else(|| self.estimate_average_branching(space, &context.edge_types));
289
290        let end_degree = context
291            .end_degree_hint
292            .or_else(|| {
293                self.estimate_vertex_degree(space, &context.end_tag, &edge_stats, EdgeDirection::In)
294            })
295            .unwrap_or_else(|| self.estimate_average_branching(space, &context.edge_types));
296
297        // Depth distribution calculated based on the degree proportion.
298        // The smaller the degree, the greater the allocated depth (because the search space grows more slowly).
299        self.allocate_depth_by_degree(start_degree, end_degree, total_depth, &edge_stats)
300    }
301
302    /// Depth is allocated based on the proportion of degrees.
303    fn allocate_depth_by_degree(
304        &self,
305        start_degree: f64,
306        end_degree: f64,
307        total_depth: u32,
308        edge_stats: &[EdgeTypeStatistics],
309    ) -> (u32, u32) {
310        // Calculate the inclination adjustment factor
311        let skewness_adjustment = self.calculate_skewness_adjustment(edge_stats);
312
313        // Basic allocation: The end with the smaller degree value is assigned more depth.
314        // Use logarithmic scaling to smooth out extreme values.
315        let log_start = (start_degree + 1.0).ln();
316        let log_end = (end_degree + 1.0).ln();
317        let log_total = log_start + log_end;
318
319        // Inverse proportional distribution: The smaller the degree, the greater the depth.
320        let base_forward_ratio = if log_total > 0.0 {
321            log_end / log_total
322        } else {
323            0.5
324        };
325
326        // App for adjusting the inclination angle
327        let adjusted_ratio = base_forward_ratio * (1.0 + skewness_adjustment);
328        let forward_ratio = adjusted_ratio.clamp(0.2, 0.8); // The limit is between 20% and 80%.
329
330        let forward_depth =
331            ((forward_ratio * total_depth as f64).round() as u32).clamp(1, total_depth - 1);
332        let backward_depth = total_depth - forward_depth;
333
334        (forward_depth, backward_depth)
335    }
336
337    /// Calculate the inclination adjustment factor
338    fn calculate_skewness_adjustment(&self, edge_stats: &[EdgeTypeStatistics]) -> f64 {
339        if edge_stats.is_empty() {
340            return 0.0;
341        }
342
343        let total_skewness: f64 = edge_stats.iter().map(|s| s.degree_gini_coefficient).sum();
344
345        let avg_skewness = total_skewness / edge_stats.len() as f64;
346
347        // The higher the slope, the greater the tendency for an even distribution (to avoid being concentrated in “hot spots”).
348        // Return range: -0.1 to 0.1
349        if avg_skewness > SKEW_SEVERE_GINI {
350            -0.1 // Severe inclination; adjustment towards equal distribution.
351        } else if avg_skewness > SKEW_MODERATE_GINI {
352            -0.05 // Moderate inclination
353        } else {
354            0.0 // Slight tilt; no adjustment required.
355        }
356    }
357
358    /// Estimating the degree of a vertex
359    fn estimate_vertex_degree(
360        &self,
361        space: &str,
362        tag: &Option<String>,
363        _edge_stats: &[EdgeTypeStatistics],
364        direction: EdgeDirection,
365    ) -> Option<f64> {
366        let tag_name = tag.as_ref()?;
367
368        // Obtain tag statistics information
369        let tag_stats = self.stats_manager.get_tag_stats(space, tag_name)?;
370
371        // Return the average degree based on the direction.
372        match direction {
373            EdgeDirection::Out => Some(tag_stats.avg_out_degree),
374            EdgeDirection::In => Some(tag_stats.avg_in_degree),
375            EdgeDirection::Both => Some((tag_stats.avg_out_degree + tag_stats.avg_in_degree) / 2.0),
376        }
377    }
378
379    /// Check whether it is suitable for use with bidirectional traversal.
380    ///
381    /// Determine whether bidirectional traversal is beneficial based on statistical information about edge types.
382    pub fn should_use_bidirectional(&self, space: &str, edge_types: &[String]) -> bool {
383        if edge_types.is_empty() {
384            return false;
385        }
386
387        let mut total_out_degree = 0.0;
388        let mut total_in_degree = 0.0;
389        let mut count = 0;
390
391        for edge_type in edge_types {
392            if let Some(stats) = self.stats_manager.get_edge_stats(space, edge_type) {
393                total_out_degree += stats.avg_out_degree;
394                total_in_degree += stats.avg_in_degree;
395                count += 1;
396
397                // If there is a significant inclination, it is not recommended to perform a bidirectional traversal.
398                if stats.is_heavily_skewed() {
399                    return false;
400                }
401            }
402        }
403
404        if count == 0 {
405            return false;
406        }
407
408        let avg_out = total_out_degree / count as f64;
409        let avg_in = total_in_degree / count as f64;
410
411        // If the degrees in both directions are very low (< 10), then performing a bidirectional traversal is beneficial.
412        // Or if the degrees in both directions are similar, a bidirectional traversal could also be considered.
413        (avg_out < 10.0 && avg_in < 10.0) || ((avg_out - avg_in).abs() / (avg_out + avg_in) < 0.3)
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::optimizer::cost::config::CostModelConfig;
421    use crate::optimizer::stats::{EdgeTypeStatistics, TagStatistics};
422
423    fn create_test_optimizer() -> BidirectionalTraversalOptimizer {
424        let stats_manager = Arc::new(crate::optimizer::stats::StatisticsManager::new());
425        let config = CostModelConfig::default();
426        let cost_calculator = Arc::new(CostCalculator::with_config(stats_manager.clone(), config));
427
428        // Add test data
429        let edge_stats = EdgeTypeStatistics::new("friend".to_string());
430        stats_manager.update_edge_stats("test", edge_stats);
431
432        BidirectionalTraversalOptimizer::new(cost_calculator, stats_manager)
433    }
434
435    fn create_test_optimizer_with_stats() -> BidirectionalTraversalOptimizer {
436        let stats_manager = Arc::new(crate::optimizer::stats::StatisticsManager::new());
437        let config = CostModelConfig::default();
438        let cost_calculator = Arc::new(CostCalculator::with_config(stats_manager.clone(), config));
439
440        // Add tag statistics
441        let person_tag = TagStatistics {
442            tag_name: "Person".to_string(),
443            vertex_count: 1000,
444            avg_out_degree: 5.0,
445            avg_in_degree: 4.0,
446            ..TagStatistics::default()
447        };
448        stats_manager.update_tag_stats("test", person_tag);
449
450        let company_tag = TagStatistics {
451            tag_name: "Company".to_string(),
452            vertex_count: 100,
453            avg_out_degree: 2.0,
454            avg_in_degree: 50.0,
455            ..TagStatistics::default()
456        };
457        stats_manager.update_tag_stats("test", company_tag);
458
459        // Add edge statistics
460        let edge_stats = EdgeTypeStatistics {
461            edge_type: "works_at".to_string(),
462            edge_count: 500,
463            avg_out_degree: 1.0,
464            avg_in_degree: 5.0,
465            max_out_degree: 1,
466            max_in_degree: 10,
467            unique_src_vertices: 500,
468            out_degree_std_dev: 0.5,
469            in_degree_std_dev: 2.0,
470            degree_gini_coefficient: 0.2,
471            hot_vertices: Vec::new(),
472            ..EdgeTypeStatistics::default()
473        };
474        stats_manager.update_edge_stats("test", edge_stats);
475
476        BidirectionalTraversalOptimizer::new(cost_calculator, stats_manager)
477    }
478
479    #[test]
480    fn test_unidirectional_for_shallow_depth() {
481        let optimizer = create_test_optimizer();
482        let decision = optimizer.evaluate("test", "a", "b", &[], 1);
483
484        assert!(!decision.use_bidirectional);
485        assert_eq!(decision.forward_start, "a");
486    }
487
488    #[test]
489    fn test_bidirectional_for_deep_depth() {
490        let optimizer = create_test_optimizer();
491        // When the depth is 4, there should be a significant benefit from performing a bidirectional traversal.
492        let decision = optimizer.evaluate("test", "a", "b", &[], 4);
493
494        // Since the default branching factor is 2, there should be a significant savings at a depth of 4.
495        if decision.use_bidirectional {
496            assert!(decision.estimated_savings > 0.0);
497            assert_eq!(decision.forward_start, "a");
498            assert_eq!(decision.backward_start, "b");
499        }
500    }
501
502    #[test]
503    fn test_estimate_average_branching() {
504        let optimizer = create_test_optimizer();
505        let branching = optimizer.estimate_average_branching("test", &[]);
506
507        // The list of empty edge types should return the default value.
508        assert_eq!(branching, 2.0);
509    }
510
511    #[test]
512    fn test_depth_allocation_with_context() {
513        let optimizer = create_test_optimizer_with_stats();
514
515        // Test average distribution (without label information)
516        let context = DepthAllocationContext::new("a", "b", vec!["works_at".to_string()], 4);
517        let (forward, backward) = optimizer.calculate_depth_allocation("test", &context);
518
519        assert_eq!(forward + backward, 4);
520        assert!(forward >= 1);
521        assert!(backward >= 1);
522    }
523
524    #[test]
525    fn test_depth_allocation_with_tags() {
526        let optimizer = create_test_optimizer_with_stats();
527
528        // Person(out-degree 5) -> Company(in-degree 50), should give the Person side more depth
529        let context = DepthAllocationContext::new("a", "b", vec!["works_at".to_string()], 4)
530            .with_start_tag("Person")
531            .with_end_tag("Company");
532
533        let (forward, backward) = optimizer.calculate_depth_allocation("test", &context);
534
535        // Verify that the sum of the depths is correct.
536        assert_eq!(forward + backward, 4);
537        // Person out-degree (5) < Company in-degree (50), should give Person more depth
538        // In other words, the value of `forward_depth` should be larger.
539        assert!(
540            forward >= backward,
541            "The end with the smaller degree should be assigned more depth."
542        );
543    }
544
545    #[test]
546    fn test_depth_allocation_with_degree_hints() {
547        let optimizer = create_test_optimizer();
548
549        // Use the degree hints.
550        let context = DepthAllocationContext::new("a", "b", vec![], 6)
551            .with_start_degree(2.0)
552            .with_end_degree(8.0);
553
554        let (forward, backward) = optimizer.calculate_depth_allocation("test", &context);
555
556        // If the degree value is 2 < degree value is 8, the starting point should be given more “depth” (in terms of certain parameters, such as depth of field, complexity, or significance).
557        assert!(
558            forward > backward,
559            "Endpoints with smaller degrees should be assigned more depth."
560        );
561        assert_eq!(forward + backward, 6);
562    }
563
564    #[test]
565    fn test_should_use_bidirectional() {
566        let optimizer = create_test_optimizer_with_stats();
567
568        // The degree value associated with the “works_at” field is relatively low, which should make it suitable for bidirectional traversal.
569        assert!(optimizer.should_use_bidirectional("test", &["works_at".to_string()]));
570
571        // The list of empty edge types should return false.
572        assert!(!optimizer.should_use_bidirectional("test", &[]));
573    }
574
575    #[test]
576    fn test_depth_allocation_shallow_depth() {
577        let optimizer = create_test_optimizer();
578
579        // When the depth is 1, all resources should be allocated to the positive direction (i.e., to the positive part of the process or the positive outcome).
580        let context = DepthAllocationContext::new("a", "b", vec![], 1);
581        let (forward, backward) = optimizer.calculate_depth_allocation("test", &context);
582
583        assert_eq!(forward, 1);
584        assert_eq!(backward, 0);
585    }
586}