Skip to main content

linkrs_query/optimizer/cost_based/aggregate_strategy/
selector.rs

1//! Cost-based aggregation strategy selector.
2
3use std::sync::Arc;
4
5use crate::optimizer::cost::CostCalculator;
6use crate::optimizer::cost::SelectivityEstimator;
7use crate::optimizer::cost_based::ndv::estimate_group_count;
8use crate::optimizer::cost_based::row_estimates::estimate_node_output_rows_logical;
9use crate::optimizer::cost_based::traversal_logical::rewrite_children_logical;
10use crate::optimizer::decision::OptimizationDecision;
11use crate::optimizer::stats::StatsView;
12use crate::planning::plan::logical::logical_node_traits::LogicalSingleInputNode;
13use crate::planning::plan::logical::LogicalNodeEnum;
14use linkrs_core::types::ContextualExpression;
15
16use super::context::AggregateContext;
17use super::strategy::{AggregateStrategy, AggregateStrategyDecision, SelectionReason};
18
19/// Aggregation Policy Selector
20///
21/// Selecting the optimal aggregation execution strategy based on a cost model
22#[derive(Debug)]
23pub struct AggregateStrategySelector {
24    cost_calculator: Arc<CostCalculator>,
25}
26
27impl AggregateStrategySelector {
28    /// Create a new selector for aggregating policies.
29    pub fn new(cost_calculator: Arc<CostCalculator>) -> Self {
30        Self { cost_calculator }
31    }
32
33    /// Selecting the optimal aggregation strategy
34    ///
35    /// # Parameters
36    /// Context: Information about the context in which the aggregation operation is taking place.
37    ///
38    /// # Return
39    /// Aggregation policy decision-making, including the selected policy and the estimated cost.
40    pub fn select_strategy(
41        &self,
42        space: &str,
43        context: &AggregateContext,
44    ) -> AggregateStrategyDecision {
45        self.select_strategy_internal(space, context, false)
46    }
47
48    /// Selecting the optimal aggregation strategy with memory pressure awareness
49    ///
50    /// This method considers the global memory pressure threshold from the cost model configuration
51    /// to make more robust decisions for large-scale aggregations.
52    ///
53    /// # Parameters
54    /// - `context`: Information about the context in which the aggregation operation is taking place.
55    ///
56    /// # Return
57    /// Aggregation policy decision-making, including the selected policy and the estimated cost.
58    pub fn select_strategy_with_memory_pressure(
59        &self,
60        space: &str,
61        context: &AggregateContext,
62    ) -> AggregateStrategyDecision {
63        self.select_strategy_internal(space, context, true)
64    }
65
66    /// Internal method for selecting aggregation strategy
67    fn select_strategy_internal(
68        &self,
69        space: &str,
70        context: &AggregateContext,
71        consider_memory_pressure: bool,
72    ) -> AggregateStrategyDecision {
73        // If the input is already sorted and the sorting key matches the grouping key, stream aggregation should be used preferentially.
74        if context.input_is_sorted && context.sort_keys_match_group_keys {
75            return self.create_streaming_aggregate_decision(space, context);
76        }
77
78        // If the expression is non-deterministic, hash aggregation should be used preferentially (to avoid the uncertainties associated with sorting).
79        if !context.is_deterministic {
80            let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
81            let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
82            let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
83            return AggregateStrategyDecision {
84                strategy: AggregateStrategy::HashAggregate,
85                estimated_output_rows: group_by_cardinality.max(1),
86                estimated_cost: hash_cost,
87                estimated_memory_bytes: hash_memory,
88                reason: SelectionReason::CostBased {
89                    hash_cost,
90                    sort_cost: hash_cost * 1.5, // Assume that the cost of sorting is higher.
91                },
92            };
93        }
94
95        // Estimating the cardinality of the group key
96        let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
97
98        // Calculate the cost of each strategy.
99        let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
100        let sort_cost = self.calculate_sort_aggregate_cost(context);
101
102        // Check the memory limitations.
103        let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
104        let memory_constrained = context.memory_limit > 0 && hash_memory > context.memory_limit;
105
106        // Check global memory pressure if enabled
107        let memory_pressure_threshold =
108            self.cost_calculator.config().memory_pressure_threshold as u64;
109        let under_memory_pressure =
110            consider_memory_pressure && hash_memory > memory_pressure_threshold;
111
112        // Decision-making logic
113        let (strategy, reason) = if memory_constrained || under_memory_pressure {
114            // Memory is limited or under global memory pressure
115            // Priority should be given to sorting and aggregation operations
116            if under_memory_pressure && sort_cost < hash_cost * 1.5 {
117                // Allow up to 50% performance loss to avoid memory pressure
118                (
119                    AggregateStrategy::SortAggregate,
120                    SelectionReason::MemoryConstrained,
121                )
122            } else if memory_constrained {
123                (
124                    AggregateStrategy::SortAggregate,
125                    SelectionReason::MemoryConstrained,
126                )
127            } else {
128                // Even under pressure, hash is significantly better
129                (
130                    AggregateStrategy::HashAggregate,
131                    SelectionReason::CostBased {
132                        hash_cost,
133                        sort_cost,
134                    },
135                )
136            }
137        } else if context.input_rows
138            < self
139                .cost_calculator
140                .config()
141                .strategy_thresholds
142                .small_dataset_threshold
143        {
144            // With small amounts of data, hash aggregation is simpler and more efficient.
145            (
146                AggregateStrategy::HashAggregate,
147                SelectionReason::SmallDataSet,
148            )
149        } else if group_by_cardinality
150            < self
151                .cost_calculator
152                .config()
153                .strategy_thresholds
154                .low_cardinality_threshold
155        {
156            // With a small base size, sorting and aggregation may be more advantageous (as the data becomes more localized after sorting).
157            if sort_cost < hash_cost * 1.2 {
158                (
159                    AggregateStrategy::SortAggregate,
160                    SelectionReason::LowCardinality,
161                )
162            } else {
163                (
164                    AggregateStrategy::HashAggregate,
165                    SelectionReason::CostBased {
166                        hash_cost,
167                        sort_cost,
168                    },
169                )
170            }
171        } else if group_by_cardinality
172            > (context.input_rows as f64
173                * self
174                    .cost_calculator
175                    .config()
176                    .strategy_thresholds
177                    .high_cardinality_ratio) as u64
178        {
179            // For high cardinalities (close to unique values), hash aggregation is more advantageous.
180            (
181                AggregateStrategy::HashAggregate,
182                SelectionReason::HighCardinality,
183            )
184        } else {
185            // Based on cost comparison
186            if hash_cost <= sort_cost {
187                (
188                    AggregateStrategy::HashAggregate,
189                    SelectionReason::CostBased {
190                        hash_cost,
191                        sort_cost,
192                    },
193                )
194            } else {
195                (
196                    AggregateStrategy::SortAggregate,
197                    SelectionReason::CostBased {
198                        hash_cost,
199                        sort_cost,
200                    },
201                )
202            }
203        };
204
205        let estimated_cost = match strategy {
206            AggregateStrategy::HashAggregate => hash_cost,
207            AggregateStrategy::SortAggregate => sort_cost,
208            AggregateStrategy::StreamingAggregate => {
209                self.calculate_streaming_aggregate_cost(context)
210            }
211        };
212
213        AggregateStrategyDecision {
214            strategy,
215            estimated_output_rows: group_by_cardinality.max(1),
216            estimated_cost,
217            estimated_memory_bytes: match strategy {
218                AggregateStrategy::HashAggregate => hash_memory,
219                AggregateStrategy::SortAggregate => self.estimate_sort_memory_usage(context),
220                AggregateStrategy::StreamingAggregate => {
221                    self.estimate_streaming_memory_usage(context)
222                }
223            },
224            reason,
225        }
226    }
227
228    /// Rapid selection strategy (simplified version, for use in decision-making caching)
229    pub fn select_strategy_quick(
230        &self,
231        input_rows: u64,
232        group_key_count: usize,
233        _agg_function_count: usize,
234    ) -> AggregateStrategy {
235        let thresholds = self.cost_calculator.config().strategy_thresholds;
236        if input_rows < thresholds.small_dataset_threshold {
237            return AggregateStrategy::HashAggregate;
238        }
239
240        // Estimating the cardinality of the grouping key
241        let cardinality = self.estimate_cardinality_quick(input_rows, group_key_count);
242
243        if cardinality < thresholds.low_cardinality_threshold {
244            AggregateStrategy::SortAggregate
245        } else {
246            AggregateStrategy::HashAggregate
247        }
248    }
249
250    /// Estimating the cardinality of the grouping key
251    ///
252    /// First tries to use property combination statistics if available,
253    /// then falls back to heuristic estimation.
254    fn estimate_group_by_cardinality(&self, space: &str, context: &AggregateContext) -> u64 {
255        // Try to use property combination statistics for better estimation
256        if let Some(table_name) = self.get_table_name_from_context(context) {
257            if let Some(cardinality) = self
258                .cost_calculator
259                .statistics_manager()
260                .get_combined_cardinality(space, Some(&table_name), &context.group_keys)
261            {
262                return cardinality.min(context.input_rows).max(1);
263            }
264        }
265
266        // Fall back to heuristic estimation
267        self.estimate_cardinality_quick(context.input_rows, context.group_keys.len())
268    }
269
270    /// Get table name from aggregate context
271    /// This is a helper method to extract table name for statistics lookup
272    fn get_table_name_from_context(&self, context: &AggregateContext) -> Option<String> {
273        context.table_name.clone()
274    }
275
276    /// Quick estimation of the base number
277    fn estimate_cardinality_quick(&self, input_rows: u64, key_count: usize) -> u64 {
278        estimate_group_count(input_rows, key_count)
279    }
280
281    /// Calculating the cost of hash aggregation
282    fn calculate_hash_aggregate_cost(
283        &self,
284        context: &AggregateContext,
285        _group_by_cardinality: u64,
286    ) -> f64 {
287        self.cost_calculator.calculate_aggregate_cost(
288            context.input_rows,
289            context.agg_function_count,
290            context.group_keys.len(),
291        )
292    }
293
294    /// Calculating the cost of sorting and aggregation operations
295    fn calculate_sort_aggregate_cost(&self, context: &AggregateContext) -> f64 {
296        // Sorting cost + Aggregation cost
297        let sort_cost = self.cost_calculator.calculate_sort_cost(
298            context.input_rows,
299            context.group_keys.len(),
300            None,
301        );
302
303        // The aggregated cost after sorting is lower (the data has been grouped).
304        let agg_cost = context.input_rows as f64
305            * context.agg_function_count as f64
306            * self.cost_calculator.config().cpu_operator_cost
307            * 0.5; // The cost of aggregation is reduced by half after sorting.
308
309        sort_cost + agg_cost
310    }
311
312    /// Calculating the cost of stream aggregation
313    fn calculate_streaming_aggregate_cost(&self, context: &AggregateContext) -> f64 {
314        // Stream aggregation only requires the cost of aggregation; sorting is not necessary.
315        context.input_rows as f64
316            * context.agg_function_count as f64
317            * self.cost_calculator.config().cpu_operator_cost
318    }
319
320    /// Estimating the memory usage of hash aggregation
321    fn estimate_hash_memory_usage(
322        &self,
323        _context: &AggregateContext,
324        group_by_cardinality: u64,
325    ) -> u64 {
326        // Single row-width source shared with the cost calculator and the
327        // memory budget allocator; hash overhead is a fixed factor.
328        group_by_cardinality * self.cost_calculator.config().row_width_bytes as u64 * 2
329    }
330
331    /// Estimating the memory usage for sorting and aggregation operations
332    fn estimate_sort_memory_usage(&self, context: &AggregateContext) -> u64 {
333        // Sorting may require caching all the data; width from shared config.
334        let row_size = self.cost_calculator.config().row_width_bytes as u64;
335        context.input_rows * row_size
336    }
337
338    /// Estimating the memory usage of stream aggregation
339    fn estimate_streaming_memory_usage(&self, _context: &AggregateContext) -> u64 {
340        // Streaming keeps a small window; width still from the shared source.
341        self.cost_calculator.config().row_width_bytes as u64 * 2
342    }
343
344    /// Creating streaming aggregation decisions
345    fn create_streaming_aggregate_decision(
346        &self,
347        space: &str,
348        context: &AggregateContext,
349    ) -> AggregateStrategyDecision {
350        let estimated_cost = self.calculate_streaming_aggregate_cost(context);
351        let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
352
353        AggregateStrategyDecision {
354            strategy: AggregateStrategy::StreamingAggregate,
355            estimated_output_rows: group_by_cardinality.max(1),
356            estimated_cost,
357            estimated_memory_bytes: self.estimate_streaming_memory_usage(context),
358            reason: SelectionReason::InputAlreadySorted,
359        }
360    }
361
362    /// Update and optimize the information on the aggregation strategies used in decision-making processes.
363    pub fn update_decision(
364        &self,
365        space: &str,
366        decision: &mut OptimizationDecision,
367        context: &AggregateContext,
368    ) {
369        let strategy_decision = self.select_strategy(space, context);
370
371        // Encode the aggregation policy information into the `rewrite_rules` used for decision-making.
372        // Use a specific rule ID to indicate the selection of the aggregation policy.
373        let rule_id = match strategy_decision.strategy {
374            AggregateStrategy::HashAggregate => {
375                crate::optimizer::decision::RewriteRuleId::AggregateOptimization
376            }
377            AggregateStrategy::SortAggregate => {
378                crate::optimizer::decision::RewriteRuleId::AggregateOptimization
379            }
380            AggregateStrategy::StreamingAggregate => {
381                crate::optimizer::decision::RewriteRuleId::AggregateOptimization
382            }
383        };
384
385        if !decision.rewrite_rules.contains(&rule_id) {
386            decision.rewrite_rules.push(rule_id);
387        }
388    }
389
390    /// Analyze aggregation expression complexity using expression cost
391    ///
392    /// Returns the total expression cost for all aggregation functions.
393    /// Higher cost indicates more complex expressions that benefit from
394    /// HashAggregate (to avoid redundant computations).
395    pub fn analyze_aggregation_complexity(&self, agg_functions: &[ContextualExpression]) -> f64 {
396        agg_functions
397            .iter()
398            .filter_map(|f| f.get_expression())
399            .map(|expr| self.cost_calculator.calculate_expression_cost(&expr))
400            .sum()
401    }
402
403    /// Select strategy considering both memory pressure and expression complexity
404    ///
405    /// This is the most comprehensive strategy selection method that considers:
406    /// - Input sorting
407    /// - Memory constraints and pressure
408    /// - Expression complexity
409    /// - Cost comparison
410    pub fn select_strategy_comprehensive(
411        &self,
412        space: &str,
413        context: &AggregateContext,
414        agg_functions: &[ContextualExpression],
415    ) -> AggregateStrategyDecision {
416        // First check if we can use streaming aggregation
417        if context.input_is_sorted && context.sort_keys_match_group_keys {
418            return self.create_streaming_aggregate_decision(space, context);
419        }
420
421        // Analyze expression complexity
422        let total_expression_cost = self.analyze_aggregation_complexity(agg_functions);
423        let avg_expression_cost = if agg_functions.is_empty() {
424            0.0
425        } else {
426            total_expression_cost / agg_functions.len() as f64
427        };
428
429        // Get base decision with memory pressure awareness
430        let mut decision = self.select_strategy_with_memory_pressure(space, context);
431
432        // If expressions are complex and we're using SortAggregate,
433        // consider switching to HashAggregate to avoid redundant computations
434        if decision.strategy == AggregateStrategy::SortAggregate
435            && avg_expression_cost > self.cost_calculator.config().function_call_base_cost * 2.0
436        {
437            let group_by_cardinality = self.estimate_group_by_cardinality(space, context);
438            let hash_cost = self.calculate_hash_aggregate_cost(context, group_by_cardinality);
439            let sort_cost = decision.estimated_cost;
440
441            // If hash aggregation is not too much more expensive, use it for complex expressions
442            if hash_cost < sort_cost * 1.3 {
443                let hash_memory = self.estimate_hash_memory_usage(context, group_by_cardinality);
444                decision = AggregateStrategyDecision {
445                    strategy: AggregateStrategy::HashAggregate,
446                    estimated_output_rows: group_by_cardinality.max(1),
447                    estimated_cost: hash_cost,
448                    estimated_memory_bytes: hash_memory,
449                    reason: SelectionReason::CostBased {
450                        hash_cost,
451                        sort_cost,
452                    },
453                };
454            }
455        }
456
457        decision
458    }
459}
460
461// =====================================================================
462// Logical-plan variant (PlanNodeEnum logic/physical separation).
463//
464// The aggregation strategy decision is taken on the pure logical tree
465// (`LogicalNodeEnum`) and emits the same `aggregate:` note as the physical
466// walker in the engine. The tree is returned unchanged — the strategy is a
467// decision note consumed by the physical planner.
468// =====================================================================
469
470/// Walk a logical plan tree and record the aggregation strategy decision
471/// for every aggregate node as a CBO note (cost-driven, observable in
472/// EXPLAIN). The tree itself is returned unchanged.
473pub fn walk_aggregate_strategies_logical(
474    node: &LogicalNodeEnum,
475    stats: &StatsView,
476    selector: &AggregateStrategySelector,
477    selectivity: &SelectivityEstimator,
478    notes: &mut Vec<String>,
479) -> LogicalNodeEnum {
480    use LogicalNodeEnum::*;
481
482    if let Aggregate(aggregate) = node {
483        let input_rows = estimate_node_output_rows_logical(aggregate.input(), stats, selectivity);
484        let context = AggregateContext {
485            input_rows,
486            group_keys: aggregate
487                .group_key_exprs
488                .iter()
489                .map(|e| e.to_expression_string())
490                .collect(),
491            agg_function_count: aggregate.aggregation_functions.len(),
492            memory_limit: 0,
493            input_is_sorted: false,
494            sort_keys_match_group_keys: false,
495            is_deterministic: true,
496            complexity_score: 0,
497            table_name: None,
498        };
499        let decision = selector.select_strategy(stats.space().unwrap_or(""), &context);
500        notes.push(format!(
501            "aggregate: strategy={:?} (reason={:?}, est_rows={})",
502            decision.strategy, decision.reason, decision.estimated_output_rows
503        ));
504    }
505
506    let mut closure = |child: &LogicalNodeEnum| {
507        walk_aggregate_strategies_logical(child, stats, selector, selectivity, notes)
508    };
509    rewrite_children_logical(node, &mut closure)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::optimizer::stats::StatisticsManager;
516
517    fn create_test_selector() -> AggregateStrategySelector {
518        let stats_manager = Arc::new(StatisticsManager::new());
519        let cost_calculator = Arc::new(CostCalculator::new(stats_manager));
520        AggregateStrategySelector::new(cost_calculator)
521    }
522
523    #[test]
524    fn test_streaming_aggregate_when_sorted() {
525        let selector = create_test_selector();
526        let context = AggregateContext {
527            input_rows: 10000,
528            group_keys: vec!["category".to_string()],
529            agg_function_count: 2,
530            memory_limit: 0,
531            input_is_sorted: true,
532            sort_keys_match_group_keys: true,
533            is_deterministic: true,
534            complexity_score: 0,
535            table_name: None,
536        };
537
538        let decision = selector.select_strategy("test", &context);
539        assert_eq!(decision.strategy, AggregateStrategy::StreamingAggregate);
540        matches!(decision.reason, SelectionReason::InputAlreadySorted);
541    }
542
543    #[test]
544    fn test_hash_aggregate_for_small_data() {
545        let selector = create_test_selector();
546        let context = AggregateContext {
547            input_rows: 500,
548            group_keys: vec!["category".to_string()],
549            agg_function_count: 1,
550            memory_limit: 0,
551            input_is_sorted: false,
552            sort_keys_match_group_keys: false,
553            is_deterministic: true,
554            complexity_score: 0,
555            table_name: None,
556        };
557
558        let decision = selector.select_strategy("test", &context);
559        assert_eq!(decision.strategy, AggregateStrategy::HashAggregate);
560        matches!(decision.reason, SelectionReason::SmallDataSet);
561    }
562
563    #[test]
564    fn test_memory_constrained_fallback() {
565        let selector = create_test_selector();
566        let context = AggregateContext {
567            input_rows: 100000,
568            group_keys: vec!["category".to_string(), "subcategory".to_string()],
569            agg_function_count: 3,
570            memory_limit: 1024, // Memory limit of 1 KB (very small)
571            input_is_sorted: false,
572            sort_keys_match_group_keys: false,
573            is_deterministic: true,
574            complexity_score: 0,
575            table_name: None,
576        };
577
578        let decision = selector.select_strategy("test", &context);
579        assert_eq!(decision.strategy, AggregateStrategy::SortAggregate);
580        matches!(decision.reason, SelectionReason::MemoryConstrained);
581    }
582
583    #[test]
584    fn test_quick_selection() {
585        let selector = create_test_selector();
586
587        // For small amounts of data, hash aggregation should be chosen.
588        let strategy = selector.select_strategy_quick(500, 1, 1);
589        assert_eq!(strategy, AggregateStrategy::HashAggregate);
590
591        // For large amounts of data with multiple keys (and a low cardinality), sorting and aggregation should be chosen as the appropriate methods for processing the data.
592        let strategy = selector.select_strategy_quick(100000, 10, 1);
593        assert_eq!(strategy, AggregateStrategy::SortAggregate);
594
595        // For large volumes of data with a high number of unique keys (high cardinality), hash aggregation should be chosen.
596        let strategy = selector.select_strategy_quick(100000, 1, 1);
597        assert_eq!(strategy, AggregateStrategy::HashAggregate);
598    }
599
600    #[test]
601    fn test_cardinality_estimation() {
602        let selector = create_test_selector();
603
604        // If there is no grouping key, the result should be 1.
605        let cardinality = selector.estimate_cardinality_quick(1000, 0);
606        assert_eq!(cardinality, 1);
607
608        // The cardinality of a single key should be high.
609        let cardinality = selector.estimate_cardinality_quick(10000, 1);
610        assert!(cardinality > 100);
611
612        // The cardinality of multiple keys should be relatively low.
613        let cardinality = selector.estimate_cardinality_quick(10000, 5);
614        assert!(cardinality < 1000);
615    }
616
617    #[test]
618    fn test_memory_pressure_aware_selection() {
619        let selector = create_test_selector();
620
621        // Create a context that would normally choose HashAggregate
622        // but with high memory usage that exceeds the threshold
623        let context = AggregateContext {
624            input_rows: 100000,
625            group_keys: vec!["category".to_string()],
626            agg_function_count: 2,
627            memory_limit: 0, // No explicit limit
628            input_is_sorted: false,
629            sort_keys_match_group_keys: false,
630            is_deterministic: true,
631            complexity_score: 0,
632            table_name: None,
633        };
634
635        // Without memory pressure, should likely choose HashAggregate for high cardinality
636        let _normal_decision = selector.select_strategy("test", &context);
637
638        // With memory pressure awareness, check if it considers memory threshold
639        let pressure_decision = selector.select_strategy_with_memory_pressure("test", &context);
640
641        // Both should return valid decisions
642        assert!(pressure_decision.estimated_cost > 0.0);
643        assert!(pressure_decision.estimated_memory_bytes > 0);
644    }
645
646    #[test]
647    fn test_analyze_aggregation_complexity() {
648        let selector = create_test_selector();
649
650        // Test with empty functions
651        let empty_complexity = selector.analyze_aggregation_complexity(&[]);
652        assert_eq!(empty_complexity, 0.0);
653
654        // Test with simple expressions
655        use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
656        use linkrs_core::types::expr::Expression;
657        use linkrs_core::value::Value;
658
659        let ctx = ExpressionAnalysisContext::new();
660        let expr = Expression::Literal(Value::Int(42));
661        let id = ctx.register_expression(linkrs_core::types::expr::ExpressionMeta::new(expr));
662        let simple_expr = ContextualExpression::new(id, std::sync::Arc::new(ctx));
663        let simple_complexity = selector.analyze_aggregation_complexity(&[simple_expr]);
664        assert!(simple_complexity >= 0.0);
665    }
666
667    #[test]
668    fn test_comprehensive_strategy_selection() {
669        let selector = create_test_selector();
670
671        let context = AggregateContext {
672            input_rows: 10000,
673            group_keys: vec!["category".to_string()],
674            agg_function_count: 2,
675            memory_limit: 0,
676            input_is_sorted: false,
677            sort_keys_match_group_keys: false,
678            is_deterministic: true,
679            complexity_score: 0,
680            table_name: None,
681        };
682
683        // Test comprehensive selection with empty functions
684        let decision = selector.select_strategy_comprehensive("test", &context, &[]);
685        assert!(decision.estimated_cost > 0.0);
686        assert!(decision.estimated_memory_bytes > 0);
687    }
688
689    #[test]
690    fn logical_walk_emits_aggregate_strategy_note() {
691        use crate::optimizer::cost::SelectivityEstimator;
692        use crate::optimizer::stats::StatsView;
693        use crate::planning::plan::logical::logical_nodes::access::LogicalScanVerticesNode;
694        use crate::planning::plan::logical::logical_nodes::operation::LogicalAggregateNode;
695        use crate::planning::plan::logical::LogicalNodeEnum;
696        use linkrs_core::types::operators::AggregateFunction;
697
698        let stats_manager = Arc::new(StatisticsManager::new());
699        let cost_calculator = Arc::new(CostCalculator::new(stats_manager.clone()));
700        let selector = AggregateStrategySelector::new(cost_calculator);
701        let selectivity = SelectivityEstimator::new(stats_manager.clone());
702        let stats = StatsView::new(&stats_manager, Some("test"));
703
704        let scan = LogicalNodeEnum::ScanVertices(LogicalScanVerticesNode {
705            id: 1,
706            space_id: 1,
707            space_name: "test".to_string(),
708            tag: Some("person".to_string()),
709            expression: None,
710            limit: None,
711            projected_properties: vec![],
712            index_hint: None,
713            estimated_cardinality: None,
714            output_var: None,
715            col_names: vec![],
716            column_types: vec![],
717        });
718        let ctx = std::sync::Arc::new(
719            linkrs_core::types::expr::expression_context::ExpressionAnalysisContext::new(),
720        );
721        let expr = linkrs_core::Expression::Variable("n.age".to_string());
722        let meta = linkrs_core::types::expr::ExpressionMeta::new(expr);
723        let id = ctx.register_expression(meta);
724        let ctx_expr = linkrs_core::types::expr::contextual::ContextualExpression::new(id, ctx);
725        let aggregate = LogicalNodeEnum::Aggregate(LogicalAggregateNode {
726            id: 2,
727            input: Some(Box::new(scan)),
728            group_key_exprs: vec![ctx_expr],
729            aggregation_functions: vec![AggregateFunction::Count],
730            aggregation_args: vec![],
731            aggregation_distinct: vec![],
732            aggregation_filters: vec![],
733            grouping_sets: vec![],
734            output_var: None,
735            col_names: vec![],
736            column_types: vec![],
737        });
738
739        let mut notes = Vec::new();
740        let rewritten = walk_aggregate_strategies_logical(
741            &aggregate,
742            &stats,
743            &selector,
744            &selectivity,
745            &mut notes,
746        );
747
748        // The logical tree is returned unchanged; the decision is a note.
749        assert!(matches!(rewritten, LogicalNodeEnum::Aggregate(_)));
750        assert_eq!(notes.len(), 1);
751        assert!(notes[0].starts_with("aggregate: strategy="));
752        assert!(notes[0].contains("est_rows="));
753    }
754}