Skip to main content

oxirs_arq/
join_optimizer.rs

1//! Cost-based join reordering for SPARQL query optimization.
2//!
3//! Implements two strategies:
4//! * **Greedy left-deep** — iteratively pick the cheapest next join partner.
5//! * **Dynamic programming** — exact optimal plan for small pattern sets (≤12).
6
7// ── Data structures ───────────────────────────────────────────────────────────
8
9/// A single triple pattern annotated with cost-estimation metadata.
10#[derive(Debug, Clone, PartialEq)]
11pub struct JoinPattern {
12    /// Estimated number of result rows produced by this pattern.
13    pub estimated_size: usize,
14    /// Variables that are already bound when this pattern is reached.
15    pub bound_vars: Vec<String>,
16    /// Variables that this pattern will bind (free before evaluation).
17    pub free_vars: Vec<String>,
18    /// Human-readable identifier (e.g. triple pattern string).
19    pub label: String,
20}
21
22impl JoinPattern {
23    /// Convenience constructor.
24    pub fn new(
25        label: impl Into<String>,
26        estimated_size: usize,
27        bound_vars: Vec<String>,
28        free_vars: Vec<String>,
29    ) -> Self {
30        Self {
31            estimated_size,
32            bound_vars,
33            free_vars,
34            label: label.into(),
35        }
36    }
37}
38
39/// An edge in the join graph connecting two patterns that share variables.
40#[derive(Debug, Clone, PartialEq)]
41pub struct JoinEdge {
42    /// Index of the left pattern.
43    pub left: usize,
44    /// Index of the right pattern.
45    pub right: usize,
46    /// Variables shared by both patterns (basis for join selectivity).
47    pub shared_vars: Vec<String>,
48    /// Estimated fraction of cross-product surviving the join (0.0–1.0).
49    pub join_selectivity: f64,
50}
51
52/// An ordered join plan produced by the optimizer.
53#[derive(Debug, Clone, PartialEq)]
54pub struct JoinPlan {
55    /// Ordered indices into the original `patterns` slice.
56    pub order: Vec<usize>,
57    /// Estimated total cost of executing this join order.
58    pub estimated_cost: f64,
59}
60
61// ── Optimizer ─────────────────────────────────────────────────────────────────
62
63/// Cost-based join reordering optimizer.
64#[derive(Debug, Clone)]
65pub struct JoinOptimizer {
66    /// Maximum number of patterns handled by the DP algorithm before falling
67    /// back to the greedy heuristic.
68    pub max_patterns: usize,
69}
70
71impl JoinOptimizer {
72    /// Create a new optimizer.
73    ///
74    /// `max_patterns` controls the DP threshold (recommended: ≤12).
75    pub fn new(max_patterns: usize) -> Self {
76        Self { max_patterns }
77    }
78
79    /// Choose the best join order.
80    ///
81    /// Uses DP when `patterns.len() <= max_patterns`, greedy otherwise.
82    pub fn optimize(&self, patterns: Vec<JoinPattern>) -> JoinPlan {
83        if patterns.is_empty() {
84            return JoinPlan {
85                order: vec![],
86                estimated_cost: 0.0,
87            };
88        }
89        if patterns.len() <= self.max_patterns {
90            self.dynamic_programming(&patterns)
91        } else {
92            Self::greedy(&patterns)
93        }
94    }
95
96    // ── Greedy left-deep ──────────────────────────────────────────────────────
97
98    /// Greedy left-deep join reordering.
99    ///
100    /// At each step we pick the unplaced pattern that, when joined with the
101    /// current pipeline, yields the lowest cost increment.
102    pub fn greedy(patterns: &[JoinPattern]) -> JoinPlan {
103        if patterns.is_empty() {
104            return JoinPlan {
105                order: vec![],
106                estimated_cost: 0.0,
107            };
108        }
109
110        let n = patterns.len();
111        let mut remaining: Vec<usize> = (0..n).collect();
112        let mut order: Vec<usize> = Vec::with_capacity(n);
113        let mut total_cost = 0.0_f64;
114
115        // Seed: choose the pattern with the smallest estimated size.
116        let (seed_pos, _) = remaining
117            .iter()
118            .enumerate()
119            .min_by_key(|&(_, &idx)| patterns[idx].estimated_size)
120            .unwrap_or((0, &0));
121        let seed_idx = remaining.remove(seed_pos);
122        order.push(seed_idx);
123        let mut accumulated = patterns[seed_idx].clone();
124        total_cost += accumulated.estimated_size as f64;
125
126        while !remaining.is_empty() {
127            let mut best_pos = 0_usize;
128            let mut best_cost = f64::MAX;
129
130            for (pos, &candidate) in remaining.iter().enumerate() {
131                let incremental = Self::cost_join(&accumulated, &patterns[candidate]);
132                if incremental < best_cost {
133                    best_cost = incremental;
134                    best_pos = pos;
135                }
136            }
137
138            let chosen = remaining.remove(best_pos);
139            accumulated = Self::merge_patterns(&accumulated, &patterns[chosen]);
140            total_cost += best_cost;
141            order.push(chosen);
142        }
143
144        JoinPlan {
145            order,
146            estimated_cost: total_cost,
147        }
148    }
149
150    // ── Dynamic programming ───────────────────────────────────────────────────
151
152    /// Exact optimal join order via dynamic programming (subset enumeration).
153    ///
154    /// Limited to `patterns.len() ≤ 12` to keep exponential blowup manageable.
155    pub fn dynamic_programming(&self, patterns: &[JoinPattern]) -> JoinPlan {
156        let n = patterns.len();
157        if n == 0 {
158            return JoinPlan {
159                order: vec![],
160                estimated_cost: 0.0,
161            };
162        }
163        if n == 1 {
164            return JoinPlan {
165                order: vec![0],
166                estimated_cost: patterns[0].estimated_size as f64,
167            };
168        }
169        // Cap to avoid exponential blowup.
170        if n > 20 {
171            return Self::greedy(patterns);
172        }
173
174        let num_subsets = 1usize << n;
175        // dp[mask] = (cost, last_added)
176        let mut dp = vec![(f64::MAX, usize::MAX); num_subsets];
177        // accumulated_pattern[mask] holds the merged pattern for that subset.
178        let mut acc: Vec<Option<JoinPattern>> = vec![None; num_subsets];
179
180        // Initialise singletons.
181        // `i` is intentionally used as both a bit-position index and an index into
182        // `patterns` — using enumerate() would not simplify this.
183        #[allow(clippy::needless_range_loop)]
184        for i in 0..n {
185            let mask = 1usize << i;
186            dp[mask] = (patterns[i].estimated_size as f64, i);
187            acc[mask] = Some(patterns[i].clone());
188        }
189
190        for mask in 1..num_subsets {
191            if dp[mask].0 == f64::MAX && mask.count_ones() > 1 {
192                continue;
193            }
194            // Try extending with each remaining pattern.
195            // `i` is used as both bit-position and pattern index.
196            #[allow(clippy::needless_range_loop)]
197            for i in 0..n {
198                if mask & (1 << i) != 0 {
199                    continue; // already in subset
200                }
201                let new_mask = mask | (1 << i);
202                if let Some(ref current) = acc[mask] {
203                    let incremental = Self::cost_join(current, &patterns[i]);
204                    let new_cost = dp[mask].0 + incremental;
205                    if new_cost < dp[new_mask].0 {
206                        dp[new_mask] = (new_cost, i);
207                        acc[new_mask] = Some(Self::merge_patterns(current, &patterns[i]));
208                    }
209                }
210            }
211        }
212
213        // Reconstruct the order by backtracking through the DP table.
214        let full_mask = num_subsets - 1;
215        let total_cost = dp[full_mask].0;
216        let order = Self::reconstruct_order(&dp, n, full_mask);
217
218        JoinPlan {
219            order,
220            estimated_cost: total_cost,
221        }
222    }
223
224    // ── Cost function ─────────────────────────────────────────────────────────
225
226    /// Incremental join cost when appending `right` to a pipeline ending with `left`.
227    ///
228    /// Considers:
229    /// * base sizes of both patterns
230    /// * selectivity from shared variables (each shared variable reduces cost)
231    pub fn cost_join(left: &JoinPattern, right: &JoinPattern) -> f64 {
232        let shared = Self::shared_variables(left, right);
233        let base = (left.estimated_size as f64) * (right.estimated_size as f64).max(1.0);
234        let selectivity = if shared.is_empty() {
235            1.0
236        } else {
237            // Each shared variable reduces result set size.
238            let factor = 0.1_f64.powi(shared.len() as i32);
239            factor.max(1e-6)
240        };
241        base * selectivity
242    }
243
244    // ── Join graph ────────────────────────────────────────────────────────────
245
246    /// Build a join graph: one edge for every pair of patterns that share at
247    /// least one variable.
248    pub fn build_join_graph(patterns: &[JoinPattern]) -> Vec<JoinEdge> {
249        let mut edges = Vec::new();
250        let n = patterns.len();
251        // Indices `i` and `j` are stored directly in `JoinEdge::left/right`,
252        // so we use range loops rather than enumerate.
253        #[allow(clippy::needless_range_loop)]
254        for i in 0..n {
255            for j in (i + 1)..n {
256                let shared = Self::shared_variables(&patterns[i], &patterns[j]);
257                if !shared.is_empty() {
258                    let left_size = patterns[i].estimated_size.max(1) as f64;
259                    let right_size = patterns[j].estimated_size.max(1) as f64;
260                    let cross = left_size * right_size;
261                    let join_selectivity = (1.0 / cross * shared.len() as f64).min(1.0);
262                    edges.push(JoinEdge {
263                        left: i,
264                        right: j,
265                        shared_vars: shared,
266                        join_selectivity,
267                    });
268                }
269            }
270        }
271        edges
272    }
273
274    // ── Helpers ───────────────────────────────────────────────────────────────
275
276    /// Return variables that appear in both `a` and `b`.
277    pub fn shared_variables(a: &JoinPattern, b: &JoinPattern) -> Vec<String> {
278        let mut shared: Vec<String> = a
279            .free_vars
280            .iter()
281            .chain(a.bound_vars.iter())
282            .filter(|v| b.free_vars.contains(v) || b.bound_vars.contains(v))
283            .cloned()
284            .collect();
285        shared.sort();
286        shared.dedup();
287        shared
288    }
289
290    /// Merge two patterns into the combined pattern produced by their join.
291    pub fn merge_patterns(left: &JoinPattern, right: &JoinPattern) -> JoinPattern {
292        let shared = Self::shared_variables(left, right);
293        let merged_size = if shared.is_empty() {
294            left.estimated_size.saturating_mul(right.estimated_size)
295        } else {
296            // Each shared variable applies a selectivity factor of 0.1,
297            // consistent with cost_join's selectivity model.
298            let cross = (left.estimated_size as f64) * (right.estimated_size as f64);
299            let selectivity = 0.1_f64.powi(shared.len() as i32).max(1e-6);
300            (cross * selectivity).round().max(1.0) as usize
301        };
302
303        let mut bound: Vec<String> = left
304            .bound_vars
305            .iter()
306            .chain(left.free_vars.iter())
307            .chain(right.bound_vars.iter())
308            .cloned()
309            .collect();
310        bound.sort();
311        bound.dedup();
312
313        let free: Vec<String> = right
314            .free_vars
315            .iter()
316            .filter(|v| !bound.contains(v))
317            .cloned()
318            .collect();
319
320        JoinPattern {
321            estimated_size: merged_size,
322            bound_vars: bound,
323            free_vars: free,
324            label: format!("({} ⋈ {})", left.label, right.label),
325        }
326    }
327
328    // ── Private helpers ───────────────────────────────────────────────────────
329
330    fn reconstruct_order(dp: &[(f64, usize)], n: usize, mut mask: usize) -> Vec<usize> {
331        let mut order = Vec::with_capacity(n);
332        while mask != 0 {
333            let (_, last) = dp[mask];
334            if last == usize::MAX {
335                break;
336            }
337            order.push(last);
338            mask ^= 1 << last;
339        }
340        order.reverse();
341        order
342    }
343}
344
345// ── Tests ─────────────────────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn pat(label: &str, size: usize, bound: &[&str], free: &[&str]) -> JoinPattern {
352        JoinPattern::new(
353            label,
354            size,
355            bound.iter().map(|s| s.to_string()).collect(),
356            free.iter().map(|s| s.to_string()).collect(),
357        )
358    }
359
360    // ── JoinPattern ───────────────────────────────────────────────────────────
361
362    #[test]
363    fn test_pattern_new() {
364        let p = pat("p1", 100, &["x"], &["y"]);
365        assert_eq!(p.label, "p1");
366        assert_eq!(p.estimated_size, 100);
367        assert_eq!(p.bound_vars, vec!["x"]);
368        assert_eq!(p.free_vars, vec!["y"]);
369    }
370
371    #[test]
372    fn test_pattern_no_vars() {
373        let p = pat("empty", 1, &[], &[]);
374        assert!(p.bound_vars.is_empty());
375        assert!(p.free_vars.is_empty());
376    }
377
378    // ── shared_variables ─────────────────────────────────────────────────────
379
380    #[test]
381    fn test_shared_vars_empty() {
382        let a = pat("a", 10, &[], &["x"]);
383        let b = pat("b", 10, &[], &["y"]);
384        assert!(JoinOptimizer::shared_variables(&a, &b).is_empty());
385    }
386
387    #[test]
388    fn test_shared_vars_one() {
389        let a = pat("a", 10, &[], &["x", "y"]);
390        let b = pat("b", 10, &[], &["y", "z"]);
391        assert_eq!(JoinOptimizer::shared_variables(&a, &b), vec!["y"]);
392    }
393
394    #[test]
395    fn test_shared_vars_multiple() {
396        let a = pat("a", 10, &["x"], &["y", "z"]);
397        let b = pat("b", 10, &["x", "y"], &["w"]);
398        let shared = JoinOptimizer::shared_variables(&a, &b);
399        assert!(shared.contains(&"x".to_string()));
400        assert!(shared.contains(&"y".to_string()));
401    }
402
403    #[test]
404    fn test_shared_vars_between_bound_and_free() {
405        let a = pat("a", 5, &["s"], &["p"]);
406        let b = pat("b", 5, &[], &["s"]);
407        let shared = JoinOptimizer::shared_variables(&a, &b);
408        assert!(shared.contains(&"s".to_string()));
409    }
410
411    // ── cost_join ─────────────────────────────────────────────────────────────
412
413    #[test]
414    fn test_cost_join_no_shared() {
415        let a = pat("a", 100, &[], &["x"]);
416        let b = pat("b", 200, &[], &["y"]);
417        let cost = JoinOptimizer::cost_join(&a, &b);
418        // cross product with selectivity 1.0
419        assert!(cost > 0.0);
420    }
421
422    #[test]
423    fn test_cost_join_with_shared_lower() {
424        let a = pat("a", 100, &[], &["x"]);
425        let b_no_share = pat("b", 200, &[], &["y"]);
426        let b_shared = pat("b", 200, &[], &["x", "y"]);
427        let cost_no = JoinOptimizer::cost_join(&a, &b_no_share);
428        let cost_shared = JoinOptimizer::cost_join(&a, &b_shared);
429        assert!(cost_shared < cost_no);
430    }
431
432    #[test]
433    fn test_cost_join_symmetry_not_required() {
434        // cost_join is generally not symmetric, just check it returns a value
435        let a = pat("a", 50, &[], &["x"]);
436        let b = pat("b", 150, &[], &["x"]);
437        let c1 = JoinOptimizer::cost_join(&a, &b);
438        let c2 = JoinOptimizer::cost_join(&b, &a);
439        assert!(c1 > 0.0);
440        assert!(c2 > 0.0);
441    }
442
443    #[test]
444    fn test_cost_join_zero_size() {
445        let a = pat("a", 0, &[], &["x"]);
446        let b = pat("b", 100, &[], &["y"]);
447        let cost = JoinOptimizer::cost_join(&a, &b);
448        assert!(cost >= 0.0);
449    }
450
451    // ── merge_patterns ────────────────────────────────────────────────────────
452
453    #[test]
454    fn test_merge_label() {
455        let a = pat("A", 10, &[], &["x"]);
456        let b = pat("B", 20, &[], &["x", "y"]);
457        let merged = JoinOptimizer::merge_patterns(&a, &b);
458        assert!(merged.label.contains('A'));
459        assert!(merged.label.contains('B'));
460    }
461
462    #[test]
463    fn test_merge_size_with_shared() {
464        let a = pat("a", 100, &[], &["x"]);
465        let b = pat("b", 100, &[], &["x", "y"]);
466        let merged = JoinOptimizer::merge_patterns(&a, &b);
467        // Shared variable reduces size below cross product
468        assert!(merged.estimated_size < 100 * 100);
469    }
470
471    #[test]
472    fn test_merge_size_no_shared() {
473        let a = pat("a", 10, &[], &["x"]);
474        let b = pat("b", 10, &[], &["y"]);
475        let merged = JoinOptimizer::merge_patterns(&a, &b);
476        assert_eq!(merged.estimated_size, 100);
477    }
478
479    #[test]
480    fn test_merge_bound_vars_accumulate() {
481        let a = pat("a", 1, &[], &["x"]);
482        let b = pat("b", 1, &[], &["x", "y"]);
483        let merged = JoinOptimizer::merge_patterns(&a, &b);
484        assert!(merged.bound_vars.contains(&"x".to_string()));
485    }
486
487    // ── build_join_graph ──────────────────────────────────────────────────────
488
489    #[test]
490    fn test_join_graph_no_shared() {
491        let patterns = vec![pat("a", 10, &[], &["x"]), pat("b", 10, &[], &["y"])];
492        let edges = JoinOptimizer::build_join_graph(&patterns);
493        assert!(edges.is_empty());
494    }
495
496    #[test]
497    fn test_join_graph_one_edge() {
498        let patterns = vec![pat("a", 10, &[], &["x"]), pat("b", 10, &[], &["x", "y"])];
499        let edges = JoinOptimizer::build_join_graph(&patterns);
500        assert_eq!(edges.len(), 1);
501        assert_eq!(edges[0].left, 0);
502        assert_eq!(edges[0].right, 1);
503        assert!(edges[0].shared_vars.contains(&"x".to_string()));
504    }
505
506    #[test]
507    fn test_join_graph_selectivity_range() {
508        let patterns = vec![pat("a", 100, &[], &["x"]), pat("b", 100, &[], &["x", "y"])];
509        let edges = JoinOptimizer::build_join_graph(&patterns);
510        assert!(!edges.is_empty());
511        let sel = edges[0].join_selectivity;
512        assert!((0.0..=1.0).contains(&sel));
513    }
514
515    #[test]
516    fn test_join_graph_multiple_edges() {
517        let patterns = vec![
518            pat("a", 10, &[], &["x", "y"]),
519            pat("b", 10, &[], &["y", "z"]),
520            pat("c", 10, &[], &["x", "z"]),
521        ];
522        let edges = JoinOptimizer::build_join_graph(&patterns);
523        assert_eq!(edges.len(), 3);
524    }
525
526    // ── greedy optimizer ──────────────────────────────────────────────────────
527
528    #[test]
529    fn test_greedy_empty() {
530        let plan = JoinOptimizer::greedy(&[]);
531        assert!(plan.order.is_empty());
532        assert_eq!(plan.estimated_cost, 0.0);
533    }
534
535    #[test]
536    fn test_greedy_single() {
537        let patterns = vec![pat("a", 42, &[], &["x"])];
538        let plan = JoinOptimizer::greedy(&patterns);
539        assert_eq!(plan.order, vec![0]);
540        assert!(plan.estimated_cost > 0.0);
541    }
542
543    #[test]
544    fn test_greedy_two_patterns_order() {
545        // Smaller pattern should be placed first
546        let patterns = vec![pat("big", 1000, &[], &["x"]), pat("small", 5, &[], &["y"])];
547        let plan = JoinOptimizer::greedy(&patterns);
548        assert_eq!(plan.order[0], 1); // small first
549    }
550
551    #[test]
552    fn test_greedy_all_patterns_included() {
553        let patterns = vec![
554            pat("a", 10, &[], &["x"]),
555            pat("b", 20, &[], &["y"]),
556            pat("c", 5, &[], &["z"]),
557        ];
558        let plan = JoinOptimizer::greedy(&patterns);
559        assert_eq!(plan.order.len(), 3);
560        let mut sorted = plan.order.clone();
561        sorted.sort();
562        assert_eq!(sorted, vec![0, 1, 2]);
563    }
564
565    #[test]
566    fn test_greedy_prefers_shared_vars() {
567        // Pattern sharing a variable with the pipeline should cost less.
568        let patterns = vec![
569            pat("seed", 10, &[], &["x"]),
570            pat("linked", 100, &[], &["x", "y"]),
571            pat("unrelated", 50, &[], &["z"]),
572        ];
573        let plan = JoinOptimizer::greedy(&patterns);
574        assert_eq!(plan.order.len(), 3);
575        assert!(plan.estimated_cost > 0.0);
576    }
577
578    // ── DP optimizer ─────────────────────────────────────────────────────────
579
580    #[test]
581    fn test_dp_empty() {
582        let opt = JoinOptimizer::new(12);
583        let plan = opt.dynamic_programming(&[]);
584        assert!(plan.order.is_empty());
585    }
586
587    #[test]
588    fn test_dp_single() {
589        let opt = JoinOptimizer::new(12);
590        let patterns = vec![pat("a", 10, &[], &["x"])];
591        let plan = opt.dynamic_programming(&patterns);
592        assert_eq!(plan.order, vec![0]);
593    }
594
595    #[test]
596    fn test_dp_two_patterns() {
597        let opt = JoinOptimizer::new(12);
598        let patterns = vec![pat("big", 500, &[], &["x"]), pat("small", 5, &[], &["y"])];
599        let plan = opt.dynamic_programming(&patterns);
600        assert_eq!(plan.order.len(), 2);
601    }
602
603    #[test]
604    fn test_dp_covers_all() {
605        let opt = JoinOptimizer::new(12);
606        let patterns = vec![
607            pat("a", 10, &[], &["x", "y"]),
608            pat("b", 20, &[], &["y", "z"]),
609            pat("c", 5, &[], &["z", "w"]),
610        ];
611        let plan = opt.dynamic_programming(&patterns);
612        assert_eq!(plan.order.len(), 3);
613        let mut sorted = plan.order.clone();
614        sorted.sort();
615        assert_eq!(sorted, vec![0, 1, 2]);
616    }
617
618    #[test]
619    fn test_dp_cost_not_negative() {
620        let opt = JoinOptimizer::new(12);
621        let patterns = vec![
622            pat("a", 100, &[], &["x"]),
623            pat("b", 200, &[], &["x", "y"]),
624            pat("c", 50, &[], &["y", "z"]),
625        ];
626        let plan = opt.dynamic_programming(&patterns);
627        assert!(plan.estimated_cost > 0.0);
628    }
629
630    // ── optimize (dispatch) ───────────────────────────────────────────────────
631
632    #[test]
633    fn test_optimize_small_uses_dp() {
634        let opt = JoinOptimizer::new(12);
635        let patterns = vec![pat("a", 10, &[], &["x"]), pat("b", 20, &[], &["y"])];
636        let plan = opt.optimize(patterns);
637        assert_eq!(plan.order.len(), 2);
638    }
639
640    #[test]
641    fn test_optimize_large_uses_greedy() {
642        let opt = JoinOptimizer::new(2); // threshold = 2
643        let patterns: Vec<_> = (0..5)
644            .map(|i| pat(&format!("p{i}"), 10 + i, &[], &[&format!("x{i}")]))
645            .collect();
646        let plan = opt.optimize(patterns);
647        assert_eq!(plan.order.len(), 5);
648    }
649
650    #[test]
651    fn test_optimize_empty() {
652        let opt = JoinOptimizer::new(12);
653        let plan = opt.optimize(vec![]);
654        assert!(plan.order.is_empty());
655        assert_eq!(plan.estimated_cost, 0.0);
656    }
657
658    #[test]
659    fn test_optimize_single() {
660        let opt = JoinOptimizer::new(12);
661        let plan = opt.optimize(vec![pat("solo", 7, &[], &["x"])]);
662        assert_eq!(plan.order, vec![0]);
663    }
664
665    // ── JoinEdge ─────────────────────────────────────────────────────────────
666
667    #[test]
668    fn test_join_edge_fields() {
669        let edge = JoinEdge {
670            left: 0,
671            right: 1,
672            shared_vars: vec!["x".to_string()],
673            join_selectivity: 0.5,
674        };
675        assert_eq!(edge.left, 0);
676        assert_eq!(edge.right, 1);
677        assert_eq!(edge.shared_vars, vec!["x"]);
678        assert!((edge.join_selectivity - 0.5).abs() < 1e-9);
679    }
680
681    // ── JoinPlan ─────────────────────────────────────────────────────────────
682
683    #[test]
684    fn test_join_plan_fields() {
685        let plan = JoinPlan {
686            order: vec![2, 0, 1],
687            estimated_cost: 123.45,
688        };
689        assert_eq!(plan.order, vec![2, 0, 1]);
690        assert!((plan.estimated_cost - 123.45).abs() < 1e-9);
691    }
692
693    // ── Integration ───────────────────────────────────────────────────────────
694
695    #[test]
696    fn test_star_shaped_query() {
697        // Classic star query: central node with many outgoing edges.
698        let patterns = vec![
699            pat("?s rdf:type :Person", 1000, &[], &["s"]),
700            pat("?s :name ?name", 5000, &["s"], &["name"]),
701            pat("?s :age ?age", 5000, &["s"], &["age"]),
702            pat("?s :email ?email", 4000, &["s"], &["email"]),
703        ];
704        let opt = JoinOptimizer::new(12);
705        let plan = opt.optimize(patterns);
706        assert_eq!(plan.order.len(), 4);
707        assert!(plan.estimated_cost > 0.0);
708    }
709
710    #[test]
711    fn test_chain_query() {
712        let patterns = vec![
713            pat("?a :knows ?b", 200, &[], &["a", "b"]),
714            pat("?b :knows ?c", 200, &["b"], &["c"]),
715            pat("?c :knows ?d", 200, &["c"], &["d"]),
716        ];
717        let opt = JoinOptimizer::new(12);
718        let plan = opt.optimize(patterns);
719        assert_eq!(plan.order.len(), 3);
720    }
721
722    #[test]
723    fn test_dp_better_or_equal_than_greedy_for_small() {
724        // DP should find cost ≤ greedy for small inputs.
725        let patterns = vec![
726            pat("a", 1000, &[], &["x"]),
727            pat("b", 100, &[], &["x", "y"]),
728            pat("c", 10, &[], &["y"]),
729        ];
730        let opt = JoinOptimizer::new(12);
731        let dp_plan = opt.dynamic_programming(&patterns);
732        let greedy_plan = JoinOptimizer::greedy(&patterns);
733        // DP cost should be ≤ greedy cost (or very close)
734        assert!(dp_plan.estimated_cost <= greedy_plan.estimated_cost + 1.0);
735    }
736
737    #[test]
738    fn test_large_pattern_set_greedy() {
739        let patterns: Vec<_> = (0..15)
740            .map(|i| {
741                pat(
742                    &format!("p{i}"),
743                    100 + i * 7,
744                    &[],
745                    &[&format!("v{i}"), &format!("v{}", i + 1)],
746                )
747            })
748            .collect();
749        let opt = JoinOptimizer::new(12);
750        let plan = opt.optimize(patterns);
751        assert_eq!(plan.order.len(), 15);
752    }
753
754    #[test]
755    fn test_merge_deduplicates_vars() {
756        let a = pat("a", 10, &["x"], &["x", "y"]);
757        let b = pat("b", 10, &["x", "y"], &["y", "z"]);
758        let merged = JoinOptimizer::merge_patterns(&a, &b);
759        // No duplicate "x" or "y"
760        let x_count = merged.bound_vars.iter().filter(|v| *v == "x").count();
761        let y_count = merged.bound_vars.iter().filter(|v| *v == "y").count();
762        assert_eq!(x_count, 1);
763        assert_eq!(y_count, 1);
764    }
765
766    #[test]
767    fn test_join_graph_empty_patterns() {
768        let edges = JoinOptimizer::build_join_graph(&[]);
769        assert!(edges.is_empty());
770    }
771
772    #[test]
773    fn test_join_graph_single_pattern() {
774        let patterns = vec![pat("a", 10, &[], &["x"])];
775        let edges = JoinOptimizer::build_join_graph(&patterns);
776        assert!(edges.is_empty());
777    }
778
779    #[test]
780    fn test_cost_join_large_size() {
781        let a = pat("a", 1_000_000, &[], &["x"]);
782        let b = pat("b", 1_000_000, &[], &["y"]);
783        let cost = JoinOptimizer::cost_join(&a, &b);
784        assert!(cost > 0.0);
785    }
786
787    #[test]
788    fn test_optimize_four_patterns() {
789        let patterns = vec![
790            pat("a", 10, &[], &["x"]),
791            pat("b", 50, &[], &["x", "y"]),
792            pat("c", 100, &[], &["y", "z"]),
793            pat("d", 20, &[], &["z", "w"]),
794        ];
795        let opt = JoinOptimizer::new(12);
796        let plan = opt.optimize(patterns);
797        assert_eq!(plan.order.len(), 4);
798        assert!(plan.estimated_cost > 0.0);
799    }
800
801    #[test]
802    fn test_optimizer_new() {
803        let opt = JoinOptimizer::new(8);
804        assert_eq!(opt.max_patterns, 8);
805    }
806
807    #[test]
808    fn test_dp_twelve_patterns() {
809        let opt = JoinOptimizer::new(12);
810        let patterns: Vec<_> = (0..12)
811            .map(|i| {
812                pat(
813                    &format!("p{i}"),
814                    10 + i,
815                    &[],
816                    &[&format!("v{i}"), &format!("v{}", i + 1)],
817                )
818            })
819            .collect();
820        let plan = opt.dynamic_programming(&patterns);
821        assert_eq!(plan.order.len(), 12);
822        assert!(plan.estimated_cost > 0.0);
823    }
824
825    #[test]
826    fn test_plan_order_is_permutation() {
827        let patterns: Vec<_> = (0..6)
828            .map(|i| pat(&format!("p{i}"), 10 + i * 3, &[], &[&format!("v{i}")]))
829            .collect();
830        let opt = JoinOptimizer::new(12);
831        let plan = opt.optimize(patterns);
832        let mut sorted = plan.order.clone();
833        sorted.sort();
834        assert_eq!(sorted, vec![0, 1, 2, 3, 4, 5]);
835    }
836}