Skip to main content

ipfrs_tensorlogic/
query_optimizer.rs

1//! TensorQueryOptimizer — Rewrites TensorLogic query plans before execution.
2//!
3//! Supported rules:
4//! - [`OptimizationRule::PushFilterDown`]       — hoist filter below join
5//! - [`OptimizationRule::EliminateDeadProject`] — drop empty projection
6//! - [`OptimizationRule::ReorderJoin`]          — put cheaper side on left
7//! - [`OptimizationRule::FoldConstantLimit`]    — zero-row limit → empty scan
8//! - [`OptimizationRule::FlattenNestedFilter`]  — merge double filter
9
10// ---------------------------------------------------------------------------
11// QueryNode
12// ---------------------------------------------------------------------------
13
14/// A node in a logical query plan.
15#[derive(Clone, Debug, PartialEq)]
16pub enum QueryNode {
17    /// Leaf table scan with an optional predicate string and cost hint.
18    Scan {
19        predicate: String,
20        estimated_rows: u64,
21    },
22    /// Apply a boolean condition on top of a child plan.
23    Filter {
24        child: Box<QueryNode>,
25        condition: String,
26    },
27    /// Hash / nested-loop join on a single key.
28    Join {
29        left: Box<QueryNode>,
30        right: Box<QueryNode>,
31        join_key: String,
32    },
33    /// Column projection (field list).
34    Project {
35        child: Box<QueryNode>,
36        fields: Vec<String>,
37    },
38    /// Row-count cap.
39    Limit {
40        child: Box<QueryNode>,
41        max_rows: u64,
42    },
43}
44
45// ---------------------------------------------------------------------------
46// OptimizationRule
47// ---------------------------------------------------------------------------
48
49/// Rewriting rule that can be applied by [`TensorQueryOptimizer`].
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum OptimizationRule {
52    /// Move a Filter node below a Join node so it is applied earlier.
53    PushFilterDown,
54    /// Remove a Project node whose field list is empty.
55    EliminateDeadProject,
56    /// Swap Join children so the cheaper (smaller) side is on the left.
57    ReorderJoin,
58    /// Replace a `Limit { max_rows: 0, .. }` with an empty scan.
59    FoldConstantLimit,
60    /// Merge two consecutive Filter nodes into one with `" AND "`.
61    FlattenNestedFilter,
62}
63
64// ---------------------------------------------------------------------------
65// Cost model
66// ---------------------------------------------------------------------------
67
68/// Recursively estimate the cost of executing `node`.
69pub fn estimated_cost(node: &QueryNode) -> u64 {
70    match node {
71        QueryNode::Scan { estimated_rows, .. } => *estimated_rows,
72        QueryNode::Filter { child, .. } => estimated_cost(child).saturating_div(2),
73        QueryNode::Join { left, right, .. } => {
74            let lc = estimated_cost(left);
75            let rc = estimated_cost(right);
76            lc.saturating_mul(rc).saturating_div(100).saturating_add(1)
77        }
78        QueryNode::Project { child, .. } => estimated_cost(child),
79        QueryNode::Limit { child, max_rows } => (*max_rows).min(estimated_cost(child)),
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Rule application (single node, bottom-up)
85// ---------------------------------------------------------------------------
86
87/// Apply a single rule to the root node only (children already optimised).
88/// Returns `(new_node, changed)`.
89fn apply_rule(rule: OptimizationRule, node: QueryNode) -> (QueryNode, bool) {
90    match rule {
91        OptimizationRule::PushFilterDown => push_filter_down(node),
92        OptimizationRule::EliminateDeadProject => eliminate_dead_project(node),
93        OptimizationRule::ReorderJoin => reorder_join(node),
94        OptimizationRule::FoldConstantLimit => fold_constant_limit(node),
95        OptimizationRule::FlattenNestedFilter => flatten_nested_filter(node),
96    }
97}
98
99fn push_filter_down(node: QueryNode) -> (QueryNode, bool) {
100    match node {
101        QueryNode::Filter { child, condition } => {
102            match *child {
103                QueryNode::Join {
104                    left,
105                    right,
106                    join_key,
107                } => {
108                    // Push the filter onto the left branch of the join.
109                    let new_left = Box::new(QueryNode::Filter {
110                        child: left,
111                        condition: condition.clone(),
112                    });
113                    let new_node = QueryNode::Join {
114                        left: new_left,
115                        right,
116                        join_key,
117                    };
118                    (new_node, true)
119                }
120                other => (
121                    QueryNode::Filter {
122                        child: Box::new(other),
123                        condition,
124                    },
125                    false,
126                ),
127            }
128        }
129        other => (other, false),
130    }
131}
132
133fn eliminate_dead_project(node: QueryNode) -> (QueryNode, bool) {
134    match node {
135        QueryNode::Project { child, fields } if fields.is_empty() => (*child, true),
136        other => (other, false),
137    }
138}
139
140fn reorder_join(node: QueryNode) -> (QueryNode, bool) {
141    match node {
142        QueryNode::Join {
143            left,
144            right,
145            join_key,
146        } => {
147            let lc = estimated_cost(&left);
148            let rc = estimated_cost(&right);
149            if rc < lc {
150                (
151                    QueryNode::Join {
152                        left: right,
153                        right: left,
154                        join_key,
155                    },
156                    true,
157                )
158            } else {
159                (
160                    QueryNode::Join {
161                        left,
162                        right,
163                        join_key,
164                    },
165                    false,
166                )
167            }
168        }
169        other => (other, false),
170    }
171}
172
173fn fold_constant_limit(node: QueryNode) -> (QueryNode, bool) {
174    match node {
175        QueryNode::Limit { max_rows: 0, .. } => (
176            QueryNode::Scan {
177                predicate: "empty".to_string(),
178                estimated_rows: 0,
179            },
180            true,
181        ),
182        other => (other, false),
183    }
184}
185
186fn flatten_nested_filter(node: QueryNode) -> (QueryNode, bool) {
187    match node {
188        QueryNode::Filter {
189            child,
190            condition: c1,
191        } => match *child {
192            QueryNode::Filter {
193                child: inner,
194                condition: c2,
195            } => {
196                let merged = format!("{c2} AND {c1}");
197                (
198                    QueryNode::Filter {
199                        child: inner,
200                        condition: merged,
201                    },
202                    true,
203                )
204            }
205            other => (
206                QueryNode::Filter {
207                    child: Box::new(other),
208                    condition: c1,
209                },
210                false,
211            ),
212        },
213        other => (other, false),
214    }
215}
216
217// ---------------------------------------------------------------------------
218// Tree-level rule application (recurse then rewrite root)
219// ---------------------------------------------------------------------------
220
221/// Recursively apply `rule` bottom-up across the whole tree.
222/// Returns `(new_tree, changed_anywhere)`.
223fn apply_rule_tree(rule: OptimizationRule, node: QueryNode) -> (QueryNode, bool) {
224    // Recurse into children first, then apply rule at this level.
225    let (node, child_changed) = recurse_children(rule, node);
226    let (node, self_changed) = apply_rule(rule, node);
227    (node, child_changed || self_changed)
228}
229
230/// Descend into children, applying `rule` bottom-up, then reassemble.
231fn recurse_children(rule: OptimizationRule, node: QueryNode) -> (QueryNode, bool) {
232    match node {
233        QueryNode::Scan { .. } => (node, false),
234        QueryNode::Filter { child, condition } => {
235            let (new_child, changed) = apply_rule_tree(rule, *child);
236            (
237                QueryNode::Filter {
238                    child: Box::new(new_child),
239                    condition,
240                },
241                changed,
242            )
243        }
244        QueryNode::Join {
245            left,
246            right,
247            join_key,
248        } => {
249            let (new_left, cl) = apply_rule_tree(rule, *left);
250            let (new_right, cr) = apply_rule_tree(rule, *right);
251            (
252                QueryNode::Join {
253                    left: Box::new(new_left),
254                    right: Box::new(new_right),
255                    join_key,
256                },
257                cl || cr,
258            )
259        }
260        QueryNode::Project { child, fields } => {
261            let (new_child, changed) = apply_rule_tree(rule, *child);
262            (
263                QueryNode::Project {
264                    child: Box::new(new_child),
265                    fields,
266                },
267                changed,
268            )
269        }
270        QueryNode::Limit { child, max_rows } => {
271            let (new_child, changed) = apply_rule_tree(rule, *child);
272            (
273                QueryNode::Limit {
274                    child: Box::new(new_child),
275                    max_rows,
276                },
277                changed,
278            )
279        }
280    }
281}
282
283// ---------------------------------------------------------------------------
284// OptimizationResult
285// ---------------------------------------------------------------------------
286
287/// Summary of a single [`TensorQueryOptimizer::optimize`] call.
288#[derive(Clone, Debug)]
289pub struct OptimizationResult {
290    /// Estimated cost of the original (unoptimised) plan.
291    pub original_cost: u64,
292    /// Estimated cost of the optimised plan.
293    pub optimized_cost: u64,
294    /// Ordered list of rules that fired at least once during this run.
295    pub rules_applied: Vec<OptimizationRule>,
296}
297
298impl OptimizationResult {
299    /// Percentage improvement: `(original - optimized) / original * 100`.
300    /// Returns `0.0` when `original_cost == 0`.
301    pub fn improvement_pct(&self) -> f64 {
302        if self.original_cost == 0 {
303            return 0.0;
304        }
305        let saved = self.original_cost.saturating_sub(self.optimized_cost) as f64;
306        saved / self.original_cost as f64 * 100.0
307    }
308}
309
310// ---------------------------------------------------------------------------
311// OptimizerStats
312// ---------------------------------------------------------------------------
313
314/// Lifetime statistics for a [`TensorQueryOptimizer`] instance.
315#[derive(Clone, Debug)]
316pub struct OptimizerStats {
317    /// Number of times [`TensorQueryOptimizer::optimize`] has been called.
318    pub total_optimizations: u64,
319    /// Sum of cost reductions across all optimisation calls.
320    pub total_cost_saved: u64,
321    /// Number of rules currently enabled in the optimizer.
322    pub enabled_rules: usize,
323}
324
325// ---------------------------------------------------------------------------
326// TensorQueryOptimizer
327// ---------------------------------------------------------------------------
328
329/// Optimises TensorLogic query plans by repeatedly applying rewriting rules.
330pub struct TensorQueryOptimizer {
331    /// Ordered list of rules to apply during each pass.
332    pub enabled_rules: Vec<OptimizationRule>,
333    /// Cumulative count of [`Self::optimize`] invocations.
334    pub total_optimizations: u64,
335    /// Cumulative cost units saved across all invocations.
336    pub total_cost_saved: u64,
337}
338
339impl TensorQueryOptimizer {
340    /// Create a new optimizer with the given rule set.
341    pub fn new(rules: Vec<OptimizationRule>) -> Self {
342        Self {
343            enabled_rules: rules,
344            total_optimizations: 0,
345            total_cost_saved: 0,
346        }
347    }
348
349    /// Optimise `root`, returning the new plan and an [`OptimizationResult`].
350    ///
351    /// Rules are applied in order; the process repeats until the plan is
352    /// stable or 10 passes have been completed.
353    pub fn optimize(&mut self, root: QueryNode) -> (QueryNode, OptimizationResult) {
354        let original_cost = estimated_cost(&root);
355        let mut current = root;
356        let mut rules_applied: Vec<OptimizationRule> = Vec::new();
357
358        const MAX_PASSES: usize = 10;
359
360        for _ in 0..MAX_PASSES {
361            let mut pass_changed = false;
362
363            for &rule in &self.enabled_rules {
364                let (new_node, changed) = apply_rule_tree(rule, current);
365                current = new_node;
366                if changed {
367                    pass_changed = true;
368                    if !rules_applied.contains(&rule) {
369                        rules_applied.push(rule);
370                    }
371                }
372            }
373
374            if !pass_changed {
375                break;
376            }
377        }
378
379        let optimized_cost = estimated_cost(&current);
380        let result = OptimizationResult {
381            original_cost,
382            optimized_cost,
383            rules_applied,
384        };
385
386        self.total_optimizations += 1;
387        self.total_cost_saved = self
388            .total_cost_saved
389            .saturating_add(original_cost.saturating_sub(optimized_cost));
390
391        (current, result)
392    }
393
394    /// Return a snapshot of lifetime statistics.
395    pub fn stats(&self) -> OptimizerStats {
396        OptimizerStats {
397            total_optimizations: self.total_optimizations,
398            total_cost_saved: self.total_cost_saved,
399            enabled_rules: self.enabled_rules.len(),
400        }
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Tests
406// ---------------------------------------------------------------------------
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    // --- helpers ------------------------------------------------------------
413
414    fn scan(rows: u64) -> QueryNode {
415        QueryNode::Scan {
416            predicate: "true".to_string(),
417            estimated_rows: rows,
418        }
419    }
420
421    fn filter(child: QueryNode, cond: &str) -> QueryNode {
422        QueryNode::Filter {
423            child: Box::new(child),
424            condition: cond.to_string(),
425        }
426    }
427
428    fn join(left: QueryNode, right: QueryNode) -> QueryNode {
429        QueryNode::Join {
430            left: Box::new(left),
431            right: Box::new(right),
432            join_key: "id".to_string(),
433        }
434    }
435
436    fn project(child: QueryNode, fields: Vec<&str>) -> QueryNode {
437        QueryNode::Project {
438            child: Box::new(child),
439            fields: fields.iter().map(|s| s.to_string()).collect(),
440        }
441    }
442
443    fn limit(child: QueryNode, max_rows: u64) -> QueryNode {
444        QueryNode::Limit {
445            child: Box::new(child),
446            max_rows,
447        }
448    }
449
450    // --- estimated_cost tests -----------------------------------------------
451
452    #[test]
453    fn cost_scan() {
454        assert_eq!(estimated_cost(&scan(500)), 500);
455    }
456
457    #[test]
458    fn cost_filter() {
459        // Filter costs half of child (integer division).
460        let node = filter(scan(100), "x > 5");
461        assert_eq!(estimated_cost(&node), 50);
462    }
463
464    #[test]
465    fn cost_join() {
466        // (100 * 200) / 100 + 1 = 200 + 1 = 201
467        let node = join(scan(100), scan(200));
468        assert_eq!(estimated_cost(&node), 201);
469    }
470
471    #[test]
472    fn cost_project() {
473        let node = project(scan(300), vec!["a", "b"]);
474        assert_eq!(estimated_cost(&node), 300);
475    }
476
477    #[test]
478    fn cost_limit() {
479        let node = limit(scan(1000), 50);
480        assert_eq!(estimated_cost(&node), 50);
481
482        let node2 = limit(scan(10), 50);
483        assert_eq!(estimated_cost(&node2), 10);
484    }
485
486    // --- PushFilterDown tests -----------------------------------------------
487
488    #[test]
489    fn push_filter_down_applied() {
490        let plan = filter(join(scan(100), scan(200)), "a = 1");
491        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::PushFilterDown]);
492        let (result, info) = opt.optimize(plan);
493
494        // After push-down the outer Filter should be gone; the Join's left
495        // child should now be a Filter.
496        match result {
497            QueryNode::Join { left, .. } => match *left {
498                QueryNode::Filter { condition, .. } => {
499                    assert_eq!(condition, "a = 1");
500                }
501                other => panic!("expected Filter on left, got {other:?}"),
502            },
503            other => panic!("expected Join at root, got {other:?}"),
504        }
505        assert!(info
506            .rules_applied
507            .contains(&OptimizationRule::PushFilterDown));
508    }
509
510    #[test]
511    fn push_filter_down_no_op_on_non_join() {
512        // Filter over Scan — rule should not fire.
513        let plan = filter(scan(100), "a = 1");
514        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::PushFilterDown]);
515        let (result, info) = opt.optimize(plan.clone());
516
517        assert_eq!(result, plan);
518        assert!(info.rules_applied.is_empty());
519    }
520
521    // --- EliminateDeadProject tests -----------------------------------------
522
523    #[test]
524    fn eliminate_dead_project_removes_empty() {
525        let plan = project(scan(100), vec![]);
526        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::EliminateDeadProject]);
527        let (result, info) = opt.optimize(plan);
528
529        assert_eq!(result, scan(100));
530        assert!(info
531            .rules_applied
532            .contains(&OptimizationRule::EliminateDeadProject));
533    }
534
535    #[test]
536    fn eliminate_dead_project_keeps_non_empty() {
537        let plan = project(scan(100), vec!["a", "b"]);
538        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::EliminateDeadProject]);
539        let (result, info) = opt.optimize(plan.clone());
540
541        assert_eq!(result, plan);
542        assert!(info.rules_applied.is_empty());
543    }
544
545    // --- ReorderJoin tests --------------------------------------------------
546
547    #[test]
548    fn reorder_join_swaps_when_right_cheaper() {
549        // left cost = 1000, right cost = 10 → should swap
550        let plan = join(scan(1000), scan(10));
551        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::ReorderJoin]);
552        let (result, info) = opt.optimize(plan);
553
554        match result {
555            QueryNode::Join { left, right, .. } => {
556                assert_eq!(estimated_cost(&left), 10);
557                assert_eq!(estimated_cost(&right), 1000);
558            }
559            other => panic!("expected Join, got {other:?}"),
560        }
561        assert!(info.rules_applied.contains(&OptimizationRule::ReorderJoin));
562    }
563
564    #[test]
565    fn reorder_join_no_op_when_left_already_smaller() {
566        let plan = join(scan(10), scan(1000));
567        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::ReorderJoin]);
568        let (result, info) = opt.optimize(plan.clone());
569
570        assert_eq!(result, plan);
571        assert!(info.rules_applied.is_empty());
572    }
573
574    // --- FoldConstantLimit tests --------------------------------------------
575
576    #[test]
577    fn fold_constant_limit_zero_replaces_subtree() {
578        let plan = limit(scan(500), 0);
579        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FoldConstantLimit]);
580        let (result, info) = opt.optimize(plan);
581
582        assert_eq!(
583            result,
584            QueryNode::Scan {
585                predicate: "empty".to_string(),
586                estimated_rows: 0
587            }
588        );
589        assert!(info
590            .rules_applied
591            .contains(&OptimizationRule::FoldConstantLimit));
592    }
593
594    #[test]
595    fn fold_constant_limit_nonzero_no_op() {
596        let plan = limit(scan(500), 10);
597        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FoldConstantLimit]);
598        let (result, info) = opt.optimize(plan.clone());
599
600        assert_eq!(result, plan);
601        assert!(info.rules_applied.is_empty());
602    }
603
604    // --- FlattenNestedFilter tests ------------------------------------------
605
606    #[test]
607    fn flatten_nested_filter_merges_conditions() {
608        let plan = filter(filter(scan(100), "b = 2"), "a = 1");
609        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FlattenNestedFilter]);
610        let (result, info) = opt.optimize(plan);
611
612        match result {
613            QueryNode::Filter { condition, child } => {
614                assert_eq!(condition, "b = 2 AND a = 1");
615                assert_eq!(*child, scan(100));
616            }
617            other => panic!("expected Filter, got {other:?}"),
618        }
619        assert!(info
620            .rules_applied
621            .contains(&OptimizationRule::FlattenNestedFilter));
622    }
623
624    #[test]
625    fn flatten_nested_filter_no_op_single_filter() {
626        let plan = filter(scan(100), "a = 1");
627        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FlattenNestedFilter]);
628        let (result, info) = opt.optimize(plan.clone());
629
630        assert_eq!(result, plan);
631        assert!(info.rules_applied.is_empty());
632    }
633
634    // --- Multi-rule / convergence tests -------------------------------------
635
636    #[test]
637    fn optimize_applies_multiple_rules_in_one_pass() {
638        // Plan: Project([], Filter(Join(scan(1000), scan(10)), "x=1"))
639        // EliminateDeadProject removes Project
640        // PushFilterDown moves Filter into Join's left child
641        // ReorderJoin ensures cheaper side is left
642        let plan = project(filter(join(scan(1000), scan(10)), "x = 1"), vec![]);
643        let mut opt = TensorQueryOptimizer::new(vec![
644            OptimizationRule::EliminateDeadProject,
645            OptimizationRule::PushFilterDown,
646            OptimizationRule::ReorderJoin,
647        ]);
648        let (_, info) = opt.optimize(plan);
649        // At least two rules must have fired.
650        assert!(info.rules_applied.len() >= 2);
651    }
652
653    #[test]
654    fn optimize_repeats_until_stable() {
655        // Deeply nested filters — FlattenNestedFilter needs multiple passes.
656        let plan = filter(filter(filter(scan(100), "c = 3"), "b = 2"), "a = 1");
657        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FlattenNestedFilter]);
658        let (result, _) = opt.optimize(plan);
659
660        // After convergence there should be exactly one Filter node.
661        match result {
662            QueryNode::Filter { child, .. } => {
663                assert!(!matches!(*child, QueryNode::Filter { .. }));
664            }
665            other => panic!("expected Filter at root, got {other:?}"),
666        }
667    }
668
669    // --- improvement_pct tests ----------------------------------------------
670
671    #[test]
672    fn improvement_pct_computed_correctly() {
673        let result = OptimizationResult {
674            original_cost: 200,
675            optimized_cost: 100,
676            rules_applied: vec![],
677        };
678        let pct = result.improvement_pct();
679        assert!((pct - 50.0).abs() < 1e-9, "expected 50.0, got {pct}");
680    }
681
682    #[test]
683    fn improvement_pct_zero_when_original_zero() {
684        let result = OptimizationResult {
685            original_cost: 0,
686            optimized_cost: 0,
687            rules_applied: vec![],
688        };
689        assert_eq!(result.improvement_pct(), 0.0);
690    }
691
692    // --- Accumulation / stats tests -----------------------------------------
693
694    #[test]
695    fn total_optimizations_increments() {
696        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FoldConstantLimit]);
697        opt.optimize(limit(scan(100), 0));
698        opt.optimize(limit(scan(200), 0));
699        assert_eq!(opt.total_optimizations, 2);
700    }
701
702    #[test]
703    fn total_cost_saved_accumulates() {
704        // Verify that total_cost_saved accumulates correctly across calls.
705        // We use two calls and check that the optimizer's accumulator equals
706        // the sum of (original_cost - optimized_cost) from each call.
707        let mut opt = TensorQueryOptimizer::new(vec![
708            OptimizationRule::EliminateDeadProject,
709            OptimizationRule::FlattenNestedFilter,
710        ]);
711        let (_, r1) = opt.optimize(project(scan(100), vec![]));
712        let (_, r2) = opt.optimize(filter(filter(scan(200), "b=2"), "a=1"));
713        let expected = r1
714            .original_cost
715            .saturating_sub(r1.optimized_cost)
716            .saturating_add(r2.original_cost.saturating_sub(r2.optimized_cost));
717        assert_eq!(opt.total_cost_saved, expected);
718        assert_eq!(opt.total_optimizations, 2);
719    }
720
721    #[test]
722    fn stats_correct() {
723        let mut opt = TensorQueryOptimizer::new(vec![
724            OptimizationRule::PushFilterDown,
725            OptimizationRule::ReorderJoin,
726        ]);
727        opt.optimize(scan(0));
728        let s = opt.stats();
729        assert_eq!(s.total_optimizations, 1);
730        assert_eq!(s.enabled_rules, 2);
731    }
732
733    #[test]
734    fn optimizer_with_empty_rules_no_ops() {
735        let plan = filter(join(scan(100), scan(200)), "x = 1");
736        let mut opt = TensorQueryOptimizer::new(vec![]);
737        let (result, info) = opt.optimize(plan.clone());
738
739        assert_eq!(result, plan);
740        assert!(info.rules_applied.is_empty());
741    }
742
743    #[test]
744    fn rules_applied_tracks_correctly() {
745        let plan = limit(scan(500), 0);
746        let mut opt = TensorQueryOptimizer::new(vec![
747            OptimizationRule::FoldConstantLimit,
748            OptimizationRule::PushFilterDown,
749        ]);
750        let (_, info) = opt.optimize(plan);
751
752        assert!(info
753            .rules_applied
754            .contains(&OptimizationRule::FoldConstantLimit));
755        assert!(!info
756            .rules_applied
757            .contains(&OptimizationRule::PushFilterDown));
758    }
759
760    #[test]
761    fn optimize_returns_original_when_no_rules_match() {
762        let plan = project(scan(42), vec!["name"]);
763        let mut opt = TensorQueryOptimizer::new(vec![
764            OptimizationRule::PushFilterDown,
765            OptimizationRule::ReorderJoin,
766        ]);
767        let (result, info) = opt.optimize(plan.clone());
768
769        assert_eq!(result, plan);
770        assert!(info.rules_applied.is_empty());
771    }
772
773    #[test]
774    fn limit_nonzero_not_folded() {
775        let plan = limit(scan(500), 1);
776        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::FoldConstantLimit]);
777        let (result, _) = opt.optimize(plan.clone());
778        assert_eq!(result, plan);
779    }
780
781    // Additional edge-case tests to reach ≥22 total -------------------------
782
783    #[test]
784    fn cost_nested_filter() {
785        // Filter(Filter(scan(400))) → 400/2/2 = 100
786        let node = filter(filter(scan(400), "a"), "b");
787        assert_eq!(estimated_cost(&node), 100);
788    }
789
790    #[test]
791    fn cost_limit_zero() {
792        let node = limit(scan(999), 0);
793        assert_eq!(estimated_cost(&node), 0);
794    }
795
796    #[test]
797    fn push_filter_down_does_not_affect_project_child() {
798        // Filter(Project(scan)) — Project is not a Join, so no pushdown.
799        let plan = filter(project(scan(100), vec!["x"]), "x = 1");
800        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::PushFilterDown]);
801        let (result, info) = opt.optimize(plan.clone());
802        assert_eq!(result, plan);
803        assert!(info.rules_applied.is_empty());
804    }
805
806    #[test]
807    fn reorder_join_equal_costs_no_op() {
808        let plan = join(scan(100), scan(100));
809        let mut opt = TensorQueryOptimizer::new(vec![OptimizationRule::ReorderJoin]);
810        let (result, info) = opt.optimize(plan.clone());
811        assert_eq!(result, plan);
812        assert!(info.rules_applied.is_empty());
813    }
814
815    #[test]
816    fn flatten_then_push_filter_down_combined() {
817        // FlattenNestedFilter then PushFilterDown
818        let plan = filter(filter(join(scan(100), scan(200)), "b = 2"), "a = 1");
819        let mut opt = TensorQueryOptimizer::new(vec![
820            OptimizationRule::FlattenNestedFilter,
821            OptimizationRule::PushFilterDown,
822        ]);
823        let (result, info) = opt.optimize(plan);
824
825        // End result: Join with merged-filter on left side.
826        match result {
827            QueryNode::Join { left, .. } => match *left {
828                QueryNode::Filter { condition, .. } => {
829                    assert!(condition.contains("AND"), "condition was: {condition}");
830                }
831                other => panic!("expected Filter on left, got {other:?}"),
832            },
833            other => panic!("expected Join at root, got {other:?}"),
834        }
835        assert!(info
836            .rules_applied
837            .contains(&OptimizationRule::FlattenNestedFilter));
838        assert!(info
839            .rules_applied
840            .contains(&OptimizationRule::PushFilterDown));
841    }
842
843    #[test]
844    fn improvement_pct_no_improvement() {
845        let result = OptimizationResult {
846            original_cost: 100,
847            optimized_cost: 100,
848            rules_applied: vec![],
849        };
850        assert_eq!(result.improvement_pct(), 0.0);
851    }
852
853    #[test]
854    fn improvement_pct_full_elimination() {
855        // optimized_cost = 0 → 100% improvement
856        let result = OptimizationResult {
857            original_cost: 100,
858            optimized_cost: 0,
859            rules_applied: vec![],
860        };
861        assert!((result.improvement_pct() - 100.0).abs() < 1e-9);
862    }
863}