Skip to main content

linkrs_query/planning/statements/paths/
variable_length_path_planner.rs

1//! Variable-Length Path Planner
2//!
3//! Specialized planner for variable-length path patterns `[:TYPE*min..max]`.
4//!
5//! ## Features
6//!
7//! - Exact hop planning: `*n`
8//! - Range hop planning: `*min..max`
9//! - Unbounded planning: `*` or `*+`
10//! - Path pruning strategies
11//! - BFS-based shortest path optimization
12
13use std::collections::HashSet;
14use std::sync::Arc;
15
16use crate::parser::ast::pattern::{EdgeRange, RepetitionType};
17use linkrs_core::types::graph_schema::EdgeDirection;
18use linkrs_core::Value;
19
20pub type PlannerError = String;
21
22#[derive(Debug, Clone)]
23pub struct VLPConfig {
24    pub max_unbounded_depth: usize,
25    pub enable_path_pruning: bool,
26    pub use_bfs_for_shortest: bool,
27    pub batch_size: usize,
28    pub max_path_memory_mb: usize,
29    pub unbounded_timeout_secs: Option<u64>,
30}
31
32impl Default for VLPConfig {
33    fn default() -> Self {
34        Self {
35            max_unbounded_depth: 100,
36            enable_path_pruning: true,
37            use_bfs_for_shortest: true,
38            batch_size: 1000,
39            max_path_memory_mb: 512,
40            unbounded_timeout_secs: Some(30),
41        }
42    }
43}
44
45impl VLPConfig {
46    pub fn for_production() -> Self {
47        Self {
48            max_unbounded_depth: 100,
49            enable_path_pruning: true,
50            use_bfs_for_shortest: true,
51            batch_size: 1000,
52            max_path_memory_mb: 512,
53            unbounded_timeout_secs: Some(30),
54        }
55    }
56
57    pub fn for_analytics() -> Self {
58        Self {
59            max_unbounded_depth: 500,
60            enable_path_pruning: true,
61            use_bfs_for_shortest: true,
62            batch_size: 5000,
63            max_path_memory_mb: 2048,
64            unbounded_timeout_secs: Some(300),
65        }
66    }
67}
68
69#[derive(Debug, Clone)]
70pub struct VariableLengthPathSpec {
71    pub edge_types: Vec<String>,
72    pub direction: EdgeDirection,
73    pub range: EdgeRange,
74    pub properties: Vec<(Arc<str>, Value)>,
75}
76
77impl VariableLengthPathSpec {
78    pub fn new(
79        edge_types: Vec<String>,
80        direction: EdgeDirection,
81        range: EdgeRange,
82        properties: Vec<(Arc<str>, Value)>,
83    ) -> Self {
84        Self {
85            edge_types,
86            direction,
87            range,
88            properties,
89        }
90    }
91
92    pub fn min_hops(&self) -> usize {
93        self.range.min.unwrap_or(1)
94    }
95
96    pub fn max_hops(&self, default_max: usize) -> usize {
97        self.range.max.unwrap_or(default_max)
98    }
99
100    pub fn is_bounded(&self) -> bool {
101        self.range.max.is_some()
102    }
103
104    pub fn is_single_hop(&self) -> bool {
105        self.range.min == Some(1) && self.range.max == Some(1)
106    }
107
108    pub fn is_zero_inclusive(&self) -> bool {
109        self.range.min == Some(0)
110    }
111}
112
113#[derive(Debug, Clone)]
114pub struct VariableLengthPathPlan {
115    pub strategy: VLPStrategy,
116    pub edge_types: Vec<String>,
117    pub direction: EdgeDirection,
118    pub min_hops: usize,
119    pub max_hops: usize,
120    pub properties: Vec<(Arc<str>, Value)>,
121    pub pruning: Option<PruningConfig>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub enum VLPStrategy {
126    Exact,
127    Range,
128    Unbounded,
129    BFS,
130    Iterative,
131}
132
133#[derive(Debug, Clone)]
134pub struct PruningConfig {
135    pub strategy: PruningStrategy,
136    pub enable_cycle_detection: bool,
137    pub max_visited_nodes: usize,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum PruningStrategy {
142    None,
143    NoCycles,
144    ShortestPath,
145    PredicateBased,
146}
147
148pub struct VariableLengthPathPlanner {
149    config: VLPConfig,
150}
151
152impl VariableLengthPathPlanner {
153    pub fn new() -> Self {
154        Self {
155            config: VLPConfig::default(),
156        }
157    }
158
159    pub fn with_config(config: VLPConfig) -> Self {
160        Self { config }
161    }
162
163    pub fn plan(
164        &self,
165        spec: &VariableLengthPathSpec,
166    ) -> Result<VariableLengthPathPlan, PlannerError> {
167        let min_hops = spec.min_hops();
168        let max_hops = spec.max_hops(self.config.max_unbounded_depth);
169
170        if min_hops > max_hops {
171            return Err(format!(
172                "Invalid range: min ({}) > max ({})",
173                min_hops, max_hops
174            ));
175        }
176
177        let strategy = self.select_strategy(min_hops, max_hops, spec.is_bounded());
178
179        let pruning = if self.config.enable_path_pruning {
180            Some(self.create_pruning_config(&strategy))
181        } else {
182            None
183        };
184
185        Ok(VariableLengthPathPlan {
186            strategy,
187            edge_types: spec.edge_types.clone(),
188            direction: spec.direction,
189            min_hops,
190            max_hops,
191            properties: spec.properties.clone(),
192            pruning,
193        })
194    }
195
196    fn select_strategy(&self, min_hops: usize, max_hops: usize, is_bounded: bool) -> VLPStrategy {
197        if min_hops == max_hops {
198            return VLPStrategy::Exact;
199        }
200
201        if !is_bounded {
202            return VLPStrategy::Unbounded;
203        }
204
205        if self.config.use_bfs_for_shortest && min_hops == 1 {
206            return VLPStrategy::BFS;
207        }
208
209        if max_hops - min_hops <= 3 {
210            return VLPStrategy::Range;
211        }
212
213        VLPStrategy::Iterative
214    }
215
216    fn create_pruning_config(&self, strategy: &VLPStrategy) -> PruningConfig {
217        let pruning_strategy = match strategy {
218            VLPStrategy::BFS => PruningStrategy::ShortestPath,
219            VLPStrategy::Unbounded => PruningStrategy::NoCycles,
220            _ => PruningStrategy::NoCycles,
221        };
222
223        PruningConfig {
224            strategy: pruning_strategy,
225            enable_cycle_detection: true,
226            max_visited_nodes: self.config.batch_size * 10,
227        }
228    }
229
230    pub fn plan_from_repetition(
231        &self,
232        rep_type: &RepetitionType,
233        edge_types: Vec<String>,
234        direction: EdgeDirection,
235        properties: Vec<(Arc<str>, Value)>,
236    ) -> Result<VariableLengthPathPlan, PlannerError> {
237        let range = match rep_type {
238            RepetitionType::ZeroOrMore => EdgeRange::at_least(0),
239            RepetitionType::OneOrMore => EdgeRange::at_least(1),
240            RepetitionType::ZeroOrOne => EdgeRange::range(0, 1),
241            RepetitionType::Exactly(n) => EdgeRange::fixed(*n),
242            RepetitionType::Range(min, max) => EdgeRange::range(*min, *max),
243        };
244
245        let spec = VariableLengthPathSpec::new(edge_types, direction, range, properties);
246        self.plan(&spec)
247    }
248}
249
250impl Default for VariableLengthPathPlanner {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256pub struct PathPruner {
257    strategy: PruningStrategy,
258    visited: HashSet<Value>,
259    best_length: Option<usize>,
260    max_visited: usize,
261}
262
263impl PathPruner {
264    pub fn new(strategy: PruningStrategy) -> Self {
265        Self {
266            strategy,
267            visited: HashSet::new(),
268            best_length: None,
269            max_visited: usize::MAX,
270        }
271    }
272
273    pub fn with_max_visited(mut self, max: usize) -> Self {
274        self.max_visited = max;
275        self
276    }
277
278    pub fn should_prune(&mut self, path: &[Value]) -> bool {
279        match self.strategy {
280            PruningStrategy::None => false,
281
282            PruningStrategy::NoCycles => {
283                let mut seen = HashSet::new();
284                for vertex in path {
285                    if !seen.insert(vertex.clone()) {
286                        return true;
287                    }
288                }
289                false
290            }
291
292            PruningStrategy::ShortestPath => {
293                if let Some(best) = self.best_length {
294                    if path.len() > best {
295                        return true;
296                    }
297                }
298                false
299            }
300
301            PruningStrategy::PredicateBased => false,
302        }
303    }
304
305    pub fn should_prune_vertex(&mut self, vertex: &Value) -> bool {
306        if self.visited.len() >= self.max_visited {
307            return true;
308        }
309
310        match self.strategy {
311            PruningStrategy::NoCycles | PruningStrategy::ShortestPath => {
312                !self.visited.insert(vertex.clone())
313            }
314            _ => false,
315        }
316    }
317
318    pub fn update_best(&mut self, length: usize) {
319        if let Some(ref mut best) = self.best_length {
320            if length < *best {
321                *best = length;
322            }
323        } else {
324            self.best_length = Some(length);
325        }
326    }
327
328    pub fn reset(&mut self) {
329        self.visited.clear();
330        self.best_length = None;
331    }
332
333    pub fn visited_count(&self) -> usize {
334        self.visited.len()
335    }
336}
337
338pub struct PathExpansionStats {
339    pub paths_found: usize,
340    pub paths_pruned: usize,
341    pub max_depth_reached: usize,
342    pub vertices_visited: usize,
343}
344
345impl PathExpansionStats {
346    pub fn new() -> Self {
347        Self {
348            paths_found: 0,
349            paths_pruned: 0,
350            max_depth_reached: 0,
351            vertices_visited: 0,
352        }
353    }
354
355    pub fn record_path(&mut self, depth: usize) {
356        self.paths_found += 1;
357        if depth > self.max_depth_reached {
358            self.max_depth_reached = depth;
359        }
360    }
361
362    pub fn record_prune(&mut self) {
363        self.paths_pruned += 1;
364    }
365
366    pub fn record_vertex(&mut self) {
367        self.vertices_visited += 1;
368    }
369}
370
371impl Default for PathExpansionStats {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_vlp_config_default() {
383        let config = VLPConfig::default();
384        assert_eq!(config.max_unbounded_depth, 100);
385        assert!(config.enable_path_pruning);
386        assert!(config.use_bfs_for_shortest);
387    }
388
389    #[test]
390    fn test_vlp_spec_min_max_hops() {
391        let spec = VariableLengthPathSpec::new(
392            vec!["KNOWS".to_string()],
393            EdgeDirection::Out,
394            EdgeRange::range(1, 5),
395            vec![],
396        );
397
398        assert_eq!(spec.min_hops(), 1);
399        assert_eq!(spec.max_hops(100), 5);
400        assert!(spec.is_bounded());
401        assert!(!spec.is_single_hop());
402    }
403
404    #[test]
405    fn test_vlp_spec_single_hop() {
406        let spec = VariableLengthPathSpec::new(
407            vec!["KNOWS".to_string()],
408            EdgeDirection::Out,
409            EdgeRange::fixed(1),
410            vec![],
411        );
412
413        assert!(spec.is_single_hop());
414    }
415
416    #[test]
417    fn test_vlp_spec_zero_inclusive() {
418        let spec = VariableLengthPathSpec::new(
419            vec!["KNOWS".to_string()],
420            EdgeDirection::Out,
421            EdgeRange::at_least(0),
422            vec![],
423        );
424
425        assert!(spec.is_zero_inclusive());
426    }
427
428    #[test]
429    fn test_vlp_planner_exact() {
430        let planner = VariableLengthPathPlanner::new();
431        let spec = VariableLengthPathSpec::new(
432            vec!["KNOWS".to_string()],
433            EdgeDirection::Out,
434            EdgeRange::fixed(3),
435            vec![],
436        );
437
438        let plan = planner.plan(&spec).unwrap();
439        assert_eq!(plan.strategy, VLPStrategy::Exact);
440        assert_eq!(plan.min_hops, 3);
441        assert_eq!(plan.max_hops, 3);
442    }
443
444    #[test]
445    fn test_vlp_planner_range() {
446        let planner = VariableLengthPathPlanner::new();
447        let spec = VariableLengthPathSpec::new(
448            vec!["KNOWS".to_string()],
449            EdgeDirection::Out,
450            EdgeRange::range(1, 3),
451            vec![],
452        );
453
454        let plan = planner.plan(&spec).unwrap();
455        assert_eq!(plan.strategy, VLPStrategy::BFS);
456        assert_eq!(plan.min_hops, 1);
457        assert_eq!(plan.max_hops, 3);
458    }
459
460    #[test]
461    fn test_vlp_planner_unbounded() {
462        let planner = VariableLengthPathPlanner::new();
463        let spec = VariableLengthPathSpec::new(
464            vec!["KNOWS".to_string()],
465            EdgeDirection::Out,
466            EdgeRange::at_least(1),
467            vec![],
468        );
469
470        let plan = planner.plan(&spec).unwrap();
471        assert_eq!(plan.strategy, VLPStrategy::Unbounded);
472        assert_eq!(plan.min_hops, 1);
473        assert_eq!(plan.max_hops, 100);
474    }
475
476    #[test]
477    fn test_path_pruner_no_cycles() {
478        let mut pruner = PathPruner::new(PruningStrategy::NoCycles);
479
480        let path = vec![Value::string("a"), Value::string("b"), Value::string("a")];
481
482        assert!(pruner.should_prune(&path));
483    }
484
485    #[test]
486    fn test_path_pruner_shortest_path() {
487        let mut pruner = PathPruner::new(PruningStrategy::ShortestPath);
488
489        pruner.update_best(3);
490
491        let short_path = vec![Value::string("a"), Value::string("b")];
492        assert!(!pruner.should_prune(&short_path));
493
494        let long_path = vec![
495            Value::string("a"),
496            Value::string("b"),
497            Value::string("c"),
498            Value::string("d"),
499        ];
500        assert!(pruner.should_prune(&long_path));
501    }
502
503    #[test]
504    fn test_path_pruner_vertex_tracking() {
505        let mut pruner = PathPruner::new(PruningStrategy::NoCycles);
506
507        assert!(!pruner.should_prune_vertex(&Value::string("a")));
508        assert!(pruner.should_prune_vertex(&Value::string("a")));
509        assert!(!pruner.should_prune_vertex(&Value::string("b")));
510
511        assert_eq!(pruner.visited_count(), 2);
512    }
513
514    #[test]
515    fn test_path_expansion_stats() {
516        let mut stats = PathExpansionStats::new();
517
518        stats.record_path(3);
519        stats.record_path(5);
520        stats.record_prune();
521        stats.record_vertex();
522
523        assert_eq!(stats.paths_found, 2);
524        assert_eq!(stats.paths_pruned, 1);
525        assert_eq!(stats.max_depth_reached, 5);
526        assert_eq!(stats.vertices_visited, 1);
527    }
528}