Skip to main content

hawdb_optimizer/graph/
lowering.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::{
16    access_path::index_seek_from_filter,
17    costing::{
18        estimate_node_cartesian_product_cost, estimate_physical_plan_cost,
19        push_optional_relationship_count_sum_cost_decision,
20    },
21    logical_rewrite::{rewrite_logical_plan, LogicalRewriteOutput},
22    selected_trace::selected_plan_trace,
23    stages::{
24        ACCESS_PATH_SELECTION_STAGE, DIRECT_PHYSICAL_FALLBACK_STAGE, LOGICAL_GROUPING_STAGE,
25        PHYSICAL_SEARCH_STAGE, PLAN_FINALIZATION_STAGE, SELECTED_PLAN_COSTING_STAGE,
26    },
27    LogicalPlanRoot, LoweringReadyLogicalPlanRoot, OptimizationSearchReport, OptimizerCatalog,
28    OptimizerConfig, OptimizerTrace, PhysicalPlan, PhysicalPlanRoot, StageStats,
29};
30use crate::{
31    GroupId, Memo, OptimizerContext, OptimizerSearchDirective, OptimizerSearchDirectiveError,
32    RuleEvent, RuleOutcome, SelectedPlanTrace, StageTrace,
33};
34use hawdb_core::Value;
35use hawdb_cypher::RelationshipDirection;
36use hawdb_plan::{
37    AggregateFunction, AggregateTarget, GraphExpansionBudget, LogicalPlan, NodeProjectionAccess,
38    Predicate, Projection, ProjectionExpression, SortItem, SortKey,
39};
40use std::collections::{BTreeMap, BTreeSet};
41
42mod access;
43mod ddl;
44mod join;
45mod mutation;
46mod procedure;
47mod simple;
48mod traversal;
49
50#[cfg(test)]
51mod tests;
52
53type GraphMemo = Memo<GroupExpr>;
54
55const GRAPH_EXPANSION_FANOUT_PER_SEED_HOP: usize = 32;
56const GRAPH_EXPANSION_MAX_CANDIDATES: usize = 4_096;
57const GRAPH_EXPANSION_PAYLOAD_BYTE_LIMIT: usize = 4 * 1024 * 1024;
58
59#[derive(Debug)]
60struct GroupExpr {
61    logical: LogicalPlan,
62    children: Vec<GroupId>,
63}
64
65#[derive(Debug, Default, Clone)]
66pub struct CascadesOptimizer {
67    context: OptimizerContext,
68}
69
70impl CascadesOptimizer {
71    pub fn new(config: OptimizerConfig) -> Self {
72        Self {
73            context: OptimizerContext::from_config(config),
74        }
75    }
76
77    pub fn with_context(context: OptimizerContext) -> Self {
78        Self { context }
79    }
80
81    pub fn context(&self) -> &OptimizerContext {
82        &self.context
83    }
84
85    pub fn refresh_trace_for_physical_plan(
86        &self,
87        trace: &mut OptimizerTrace,
88        plan: &PhysicalPlan,
89        catalog: &OptimizerCatalog,
90    ) {
91        refresh_selected_plan_trace(trace, selected_plan_trace(plan, catalog, &self.context));
92    }
93
94    pub fn optimize(&self, logical: &LogicalPlan) -> PhysicalPlan {
95        self.optimize_root(&LogicalPlanRoot::new(logical.clone()))
96            .into_parts()
97            .0
98    }
99
100    pub fn optimize_with_trace(&self, logical: &LogicalPlan) -> (PhysicalPlan, OptimizerTrace) {
101        self.optimize_root(&LogicalPlanRoot::new(logical.clone()))
102            .into_parts()
103    }
104
105    pub fn optimize_with_catalog(
106        &self,
107        logical: &LogicalPlan,
108        catalog: &OptimizerCatalog,
109    ) -> (PhysicalPlan, OptimizerTrace) {
110        self.optimize_root_with_catalog(&LogicalPlanRoot::new(logical.clone()), catalog)
111            .into_parts()
112    }
113
114    pub fn optimize_root(&self, root: &LogicalPlanRoot) -> PhysicalPlanRoot {
115        self.optimize_root_with_catalog(root, &OptimizerCatalog::optimistic())
116    }
117
118    pub fn optimize_root_with_catalog(
119        &self,
120        root: &LogicalPlanRoot,
121        catalog: &OptimizerCatalog,
122    ) -> PhysicalPlanRoot {
123        self.optimize_lowering_ready_root_with_catalog_and_directive(
124            &root.clone().into_lowering_ready(),
125            catalog,
126            OptimizerSearchDirective::Auto,
127        )
128        .expect("automatic optimizer search cannot reject its directive")
129    }
130
131    pub fn optimize_lowering_ready_root_with_catalog(
132        &self,
133        root: &LoweringReadyLogicalPlanRoot,
134        catalog: &OptimizerCatalog,
135    ) -> PhysicalPlanRoot {
136        self.optimize_lowering_ready_root_with_catalog_and_directive(
137            root,
138            catalog,
139            OptimizerSearchDirective::Auto,
140        )
141        .expect("automatic optimizer search cannot reject its directive")
142    }
143
144    pub fn optimize_root_with_catalog_and_directive(
145        &self,
146        root: &LogicalPlanRoot,
147        catalog: &OptimizerCatalog,
148        directive: OptimizerSearchDirective,
149    ) -> Result<PhysicalPlanRoot, OptimizerSearchDirectiveError> {
150        self.optimize_lowering_ready_root_with_catalog_and_directive(
151            &root.clone().into_lowering_ready(),
152            catalog,
153            directive,
154        )
155    }
156
157    pub fn optimize_lowering_ready_root_with_catalog_and_directive(
158        &self,
159        root: &LoweringReadyLogicalPlanRoot,
160        catalog: &OptimizerCatalog,
161        directive: OptimizerSearchDirective,
162    ) -> Result<PhysicalPlanRoot, OptimizerSearchDirectiveError> {
163        let rewrite = rewrite_logical_plan(root.plan());
164        let logical = rewrite.plan();
165        let required_groups = logical_group_count(logical);
166        let max_groups = self.context.optimizer_config().max_groups;
167        if directive == OptimizerSearchDirective::Memo && required_groups > max_groups {
168            return Err(OptimizerSearchDirectiveError::MemoGroupBudgetExceeded {
169                required_groups,
170                max_groups,
171            });
172        }
173        if directive == OptimizerSearchDirective::DirectFallback
174            || (directive == OptimizerSearchDirective::Auto && required_groups > max_groups)
175        {
176            let mut decisions = Vec::new();
177            let mut stage_events = Vec::new();
178            let plan = lower_logical(
179                logical,
180                LoweringChildren::Direct,
181                catalog,
182                &self.context,
183                &mut decisions,
184                &mut stage_events,
185            );
186            let plan = finalize_physical_plan(plan, &mut decisions, &mut stage_events);
187            let mut report = if directive == OptimizerSearchDirective::DirectFallback {
188                OptimizationSearchReport::forced_direct_fallback(required_groups)
189            } else {
190                OptimizationSearchReport::direct_fallback(required_groups, max_groups)
191            };
192            record_logical_rewrite(&mut report, &rewrite);
193            report.push_stage_event(
194                LOGICAL_GROUPING_STAGE.trace(StageStats::new(1, required_groups)),
195            );
196            for event in stage_events {
197                report.push_stage_event(event);
198            }
199            report.push_stage_event(
200                DIRECT_PHYSICAL_FALLBACK_STAGE.trace(StageStats::new(required_groups, 1)),
201            );
202            report.extend_decisions(decisions);
203            let selected = selected_plan_trace(&plan, catalog, &self.context);
204            report.push_stage_event(SELECTED_PLAN_COSTING_STAGE.trace(StageStats::new(1, 1)));
205            report.record_selected_plan_cost(selected.cost);
206            return Ok(PhysicalPlanRoot::new(plan, report.into_trace(selected)));
207        }
208        let mut memo = GraphMemo::default();
209        let root = insert_logical_group(&mut memo, logical);
210        let mut decisions = Vec::new();
211        let mut stage_events = Vec::new();
212        let plan = best_physical(
213            &memo,
214            root,
215            catalog,
216            &self.context,
217            &mut decisions,
218            &mut stage_events,
219        );
220        let plan = finalize_physical_plan(plan, &mut decisions, &mut stage_events);
221        let mut report = OptimizationSearchReport::memo(memo.group_count());
222        record_logical_rewrite(&mut report, &rewrite);
223        report
224            .push_stage_event(LOGICAL_GROUPING_STAGE.trace(StageStats::new(1, memo.group_count())));
225        let (applied_rules, skipped_rules) = stage_rule_counts(&stage_events);
226        for event in stage_events {
227            report.push_stage_event(event);
228        }
229        report.push_stage_event(PHYSICAL_SEARCH_STAGE.trace(
230            StageStats::new(memo.group_count(), 1).with_rule_counts(applied_rules, skipped_rules),
231        ));
232        report.extend_decisions(decisions);
233        let selected = selected_plan_trace(&plan, catalog, &self.context);
234        report.push_stage_event(SELECTED_PLAN_COSTING_STAGE.trace(StageStats::new(1, 1)));
235        report.record_selected_plan_cost(selected.cost);
236        if directive == OptimizerSearchDirective::Memo {
237            report.push_decision("selected memo search: explicit optimizer search directive");
238        }
239        Ok(PhysicalPlanRoot::new(plan, report.into_trace(selected)))
240    }
241}
242
243fn refresh_selected_plan_trace(trace: &mut OptimizerTrace, selected: SelectedPlanTrace) {
244    let selected_cost = selected.cost.with_cardinality_floor();
245    let selected_cost_breakdown = selected.cost_breakdown.with_cardinality_floor();
246    let cost_detail = format!(
247        "estimated_rows={} cost={}",
248        selected_cost.estimated_rows, selected_cost.cost
249    );
250    let cost_decision = format!("selected physical plan cost: {cost_detail}");
251    if let Some(decision) = trace
252        .decisions
253        .iter_mut()
254        .find(|decision| decision.starts_with("selected physical plan cost: "))
255    {
256        *decision = cost_decision;
257    } else {
258        trace.decisions.push(cost_decision);
259    }
260    let cost_event = RuleEvent::selected("physical plan cost", cost_detail);
261    if let Some(event) = trace.rule_events.iter_mut().find(|event| {
262        event.rule() == "physical plan cost" && event.outcome() == RuleOutcome::Selected
263    }) {
264        *event = cost_event;
265    } else {
266        trace.rule_events.push(cost_event);
267    }
268
269    trace.query_digest = selected.query_digest;
270    trace.selected_plan = selected.explain;
271    trace.selected_plan_fingerprint = selected.fingerprint;
272    trace.selected_plan_cost = selected_cost;
273    trace.selected_plan_cost_breakdown = selected_cost_breakdown;
274    trace.selected_plan_properties = selected.properties;
275    trace.selected_plan_cardinality_estimates = selected.cardinality_estimates;
276    trace.selected_plan_operator_counts = selected.operator_counts;
277    trace.selected_plan_class_counts = selected.class_counts;
278}
279
280pub(super) fn record_logical_rewrite(
281    report: &mut OptimizationSearchReport,
282    rewrite: &LogicalRewriteOutput,
283) {
284    report.push_stage_event(rewrite.trace().clone());
285    if let Some(warning) = rewrite.warning() {
286        report.push_warning(warning);
287    }
288    for event in rewrite.events().iter().cloned() {
289        report.push_rule_event(event);
290    }
291}
292
293fn insert_logical_group(memo: &mut GraphMemo, logical: &LogicalPlan) -> GroupId {
294    let expr = GroupExpr::from_logical(logical, memo);
295    memo.insert_group(expr)
296}
297
298fn best_physical(
299    memo: &GraphMemo,
300    root: GroupId,
301    catalog: &OptimizerCatalog,
302    optimizer_context: &OptimizerContext,
303    decisions: &mut Vec<String>,
304    stage_events: &mut Vec<StageTrace>,
305) -> PhysicalPlan {
306    let group = memo.group(root).expect("memo group id should exist");
307    group
308        .first_expression()
309        .expect("memo group should contain at least one expression")
310        .to_physical(memo, catalog, optimizer_context, decisions, stage_events)
311}
312
313fn select_bounded_sort_plan(
314    items: Vec<SortItem>,
315    offset: usize,
316    limit: usize,
317    input: PhysicalPlan,
318    catalog: &OptimizerCatalog,
319    decisions: &mut Vec<String>,
320) -> PhysicalPlan {
321    let top_n = PhysicalPlan::TopNExec {
322        items: items.clone(),
323        offset,
324        limit,
325        input: Box::new(input.clone()),
326    };
327    let sort_limit = PhysicalPlan::LimitExec {
328        offset,
329        limit: Some(limit),
330        input: Box::new(PhysicalPlan::SortExec {
331            items,
332            input: Box::new(input),
333        }),
334    };
335    let top_n_cost = estimate_physical_plan_cost(&top_n, catalog);
336    let sort_limit_cost = estimate_physical_plan_cost(&sort_limit, catalog);
337    if top_n_cost.cost < sort_limit_cost.cost {
338        decisions.push(format!(
339            "choose TopN for bounded sort: offset={offset} limit={limit} top_n_cost={} sort_limit_cost={}",
340            top_n_cost.cost, sort_limit_cost.cost
341        ));
342        top_n
343    } else {
344        decisions.push(format!(
345            "keep Sort + Limit for bounded sort: offset={offset} limit={limit} top_n_cost={} sort_limit_cost={}",
346            top_n_cost.cost, sort_limit_cost.cost
347        ));
348        sort_limit
349    }
350}
351
352fn stage_rule_counts(stage_events: &[StageTrace]) -> (usize, usize) {
353    stage_events
354        .iter()
355        .fold((0, 0), |(applied, skipped), event| {
356            let stats = event.stats();
357            (
358                applied.saturating_add(stats.applied_rules),
359                skipped.saturating_add(stats.skipped_rules),
360            )
361        })
362}
363
364impl GroupExpr {
365    fn from_logical(logical: &LogicalPlan, memo: &mut GraphMemo) -> Self {
366        match logical {
367            LogicalPlan::GraphMatch { input, .. } => Self {
368                logical: logical.clone(),
369                children: input
370                    .iter()
371                    .map(|input| insert_logical_group(memo, input))
372                    .collect(),
373            },
374            LogicalPlan::CreateNodeLabel { .. }
375            | LogicalPlan::CreateRelationshipType { .. }
376            | LogicalPlan::CreateNodeTable { .. }
377            | LogicalPlan::CreateRelationshipTable { .. }
378            | LogicalPlan::CreateProperty { .. }
379            | LogicalPlan::AlterTableState { .. }
380            | LogicalPlan::AlterPropertyState { .. }
381            | LogicalPlan::CreateIndex { .. }
382            | LogicalPlan::CreateCompositeIndex { .. }
383            | LogicalPlan::CreateRangeIndex { .. }
384            | LogicalPlan::CreateFullTextIndex { .. }
385            | LogicalPlan::CreateUniqueConstraint { .. }
386            | LogicalPlan::CreateNodePropertyExistsConstraint { .. }
387            | LogicalPlan::CreateRelationshipUniqueConstraint { .. }
388            | LogicalPlan::CreateRelationshipPropertyExistsConstraint { .. }
389            | LogicalPlan::ProjectGraph { .. }
390            | LogicalPlan::GraphAlgorithm { .. }
391            | LogicalPlan::VectorSeed { .. }
392            | LogicalPlan::CreateNode { .. }
393            | LogicalPlan::UnwindMutation { .. }
394            | LogicalPlan::MergeNode { .. }
395            | LogicalPlan::MergeRelationship { .. }
396            | LogicalPlan::MergeMatchedRelationship { .. }
397            | LogicalPlan::MergeRelationshipFromMatchedRelationship { .. }
398            | LogicalPlan::MergeRelationshipToMatchedTarget { .. }
399            | LogicalPlan::MergeRelationshipFromMatchedTarget { .. }
400            | LogicalPlan::CreateMatchedRelationship { .. }
401            | LogicalPlan::SetNodeProperty { .. }
402            | LogicalPlan::SetNodeProperties { .. }
403            | LogicalPlan::SetNodePropertiesReturn { .. }
404            | LogicalPlan::SetRelationshipProperty { .. }
405            | LogicalPlan::SetRelationshipProperties { .. }
406            | LogicalPlan::DeleteNode { .. }
407            | LogicalPlan::DeleteRelationship { .. }
408            | LogicalPlan::DeleteRelationshipTargetNodes { .. }
409            | LogicalPlan::CreateRelationship { .. }
410            | LogicalPlan::NodeScan { .. }
411            | LogicalPlan::OptionalRelationshipCountSum { .. }
412            | LogicalPlan::ThreadRepairStats { .. }
413            | LogicalPlan::ShortestPath { .. } => Self {
414                logical: logical.clone(),
415                children: Vec::new(),
416            },
417            LogicalPlan::Limit { limit: Some(0), .. } => Self {
418                logical: logical.clone(),
419                children: Vec::new(),
420            },
421            LogicalPlan::NodeCartesianProduct { left, right } => Self {
422                logical: logical.clone(),
423                children: vec![
424                    insert_logical_group(memo, left),
425                    insert_logical_group(memo, right),
426                ],
427            },
428            LogicalPlan::Limit {
429                limit: Some(_),
430                input,
431                ..
432            } if matches!(input.as_ref(), LogicalPlan::Sort { .. }) => {
433                let LogicalPlan::Sort { input, .. } = input.as_ref() else {
434                    unreachable!("guard requires a sort input");
435                };
436                Self {
437                    logical: logical.clone(),
438                    children: vec![insert_logical_group(memo, input)],
439                }
440            }
441            LogicalPlan::Expand { input, .. }
442            | LogicalPlan::NodeColumnLookup { input, .. }
443            | LogicalPlan::OptionalDegree { input, .. }
444            | LogicalPlan::Filter { input, .. }
445            | LogicalPlan::Project { input, .. }
446            | LogicalPlan::Aggregate { input, .. }
447            | LogicalPlan::Distinct { input }
448            | LogicalPlan::Sort { input, .. }
449            | LogicalPlan::Limit { input, .. } => Self {
450                logical: logical.clone(),
451                children: vec![insert_logical_group(memo, input)],
452            },
453        }
454    }
455
456    fn to_physical(
457        &self,
458        memo: &GraphMemo,
459        catalog: &OptimizerCatalog,
460        optimizer_context: &OptimizerContext,
461        decisions: &mut Vec<String>,
462        stage_events: &mut Vec<StageTrace>,
463    ) -> PhysicalPlan {
464        lower_logical(
465            &self.logical,
466            LoweringChildren::Memo {
467                memo,
468                children: &self.children,
469            },
470            catalog,
471            optimizer_context,
472            decisions,
473            stage_events,
474        )
475    }
476}
477
478fn push_vector_seed_metadata_filter(
479    input: &mut PhysicalPlan,
480    predicate: &hawdb_plan::Predicate,
481    decisions: &mut Vec<String>,
482) {
483    if let hawdb_plan::Predicate::And(predicates) = predicate {
484        for predicate in predicates {
485            push_vector_seed_metadata_filter(input, predicate, decisions);
486        }
487        return;
488    }
489    let hawdb_plan::Predicate::PropertyEq {
490        variable,
491        property,
492        value,
493    } = predicate
494    else {
495        return;
496    };
497    let Some(filter_field) = vector_seed_metadata_field(property) else {
498        return;
499    };
500    let Some(filter_value) = vector_seed_metadata_value(value) else {
501        return;
502    };
503    if attach_vector_seed_metadata_filter(input, variable, filter_field, filter_value) {
504        decisions.push(format!(
505            "push descriptor-safe vector seed filter {variable}.{property} before candidate generation"
506        ));
507    }
508}
509
510fn attach_vector_seed_metadata_filter(
511    plan: &mut PhysicalPlan,
512    variable: &str,
513    field: &str,
514    value: String,
515) -> bool {
516    match plan {
517        PhysicalPlan::NodeColumnLookupExec {
518            variable: lookup_variable,
519            input,
520            ..
521        } if lookup_variable == variable => {
522            attach_metadata_filter_to_vector_seed(input, field, value)
523        }
524        PhysicalPlan::AdjacencyExpandExec {
525            source_variable,
526            input,
527            ..
528        } if source_variable == variable => {
529            attach_vector_seed_metadata_filter(input, variable, field, value)
530        }
531        _ => false,
532    }
533}
534
535fn attach_metadata_filter_to_vector_seed(
536    plan: &mut PhysicalPlan,
537    field: &str,
538    value: String,
539) -> bool {
540    let PhysicalPlan::VectorSeedScan {
541        metadata_filters,
542        vector_plan,
543        ..
544    } = plan
545    else {
546        return false;
547    };
548    if metadata_filters
549        .get(field)
550        .is_some_and(|existing| existing != &value)
551    {
552        return false;
553    }
554    metadata_filters.insert(field.to_string(), value);
555    attach_vector_filter_field(vector_plan, field);
556    true
557}
558
559fn attach_vector_filter_field(plan: &mut hawdb_plan::VectorPhysicalPlan, field: &str) {
560    match plan {
561        hawdb_plan::VectorPhysicalPlan::Filter { fields } => {
562            if !fields.iter().any(|existing| existing == field) {
563                fields.push(field.to_string());
564                fields.sort();
565            }
566        }
567        hawdb_plan::VectorPhysicalPlan::VectorCandidateScan { input, .. }
568        | hawdb_plan::VectorPhysicalPlan::ResidualFilter { input, .. }
569        | hawdb_plan::VectorPhysicalPlan::RawVectorRerank { input, .. }
570        | hawdb_plan::VectorPhysicalPlan::TopK { input, .. } => {
571            attach_vector_filter_field(input, field);
572        }
573    }
574}
575
576fn vector_seed_metadata_field(property: &str) -> Option<&str> {
577    match property {
578        "id" => Some("external_id"),
579        "kind" | "external_id" | "source_id" | "space_id" | "unit_type" | "lifecycle_state"
580        | "importance" | "confidence" | "created_at" | "updated_at" | "event_start"
581        | "event_end" | "is_latest" => Some(property),
582        _ => None,
583    }
584}
585
586fn vector_seed_metadata_value(value: &Value) -> Option<String> {
587    match value {
588        Value::String(value) => Some(value.clone()),
589        Value::Bool(value) => Some(value.to_string()),
590        Value::Int(value) => Some(value.to_string()),
591        Value::Float(value) if value.is_finite() => Some(value.to_string()),
592        Value::Uuid(value) => Some(value.to_string()),
593        Value::Null | Value::Float(_) | Value::Binary(_) | Value::List(_) | Value::Map(_) => None,
594    }
595}
596
597fn vector_seed_top_k(plan: &PhysicalPlan) -> Option<usize> {
598    match plan {
599        PhysicalPlan::VectorSeedScan { vector_plan, .. } => vector_plan_top_k(vector_plan),
600        PhysicalPlan::NodeColumnLookupExec { input, .. }
601        | PhysicalPlan::AdjacencyExpandExec { input, .. }
602        | PhysicalPlan::AdjacencyExistsExec { input, .. }
603        | PhysicalPlan::FilterExec { input, .. }
604        | PhysicalPlan::ProjectExec { input, .. }
605        | PhysicalPlan::LimitExec { input, .. } => vector_seed_top_k(input),
606        _ => None,
607    }
608}
609
610fn vector_plan_top_k(plan: &hawdb_plan::VectorPhysicalPlan) -> Option<usize> {
611    match plan {
612        hawdb_plan::VectorPhysicalPlan::TopK { limit, .. } => Some(*limit),
613        hawdb_plan::VectorPhysicalPlan::VectorCandidateScan { input, .. }
614        | hawdb_plan::VectorPhysicalPlan::ResidualFilter { input, .. }
615        | hawdb_plan::VectorPhysicalPlan::RawVectorRerank { input, .. } => vector_plan_top_k(input),
616        hawdb_plan::VectorPhysicalPlan::Filter { .. } => None,
617    }
618}
619
620fn graph_expansion_budget(top_k: usize, max_hops: usize) -> GraphExpansionBudget {
621    let candidate_limit = top_k
622        .max(1)
623        .saturating_mul(max_hops.max(1))
624        .saturating_mul(GRAPH_EXPANSION_FANOUT_PER_SEED_HOP)
625        .min(GRAPH_EXPANSION_MAX_CANDIDATES)
626        .max(top_k);
627    GraphExpansionBudget {
628        candidate_limit,
629        payload_byte_limit: GRAPH_EXPANSION_PAYLOAD_BYTE_LIMIT,
630    }
631}
632
633fn logical_group_count(logical: &LogicalPlan) -> usize {
634    match logical {
635        LogicalPlan::GraphMatch { input, .. } => {
636            1 + input.as_deref().map(logical_group_count).unwrap_or(0)
637        }
638        LogicalPlan::Limit { limit: Some(0), .. } => 1,
639        LogicalPlan::Limit {
640            limit: Some(_),
641            input,
642            ..
643        } if matches!(input.as_ref(), LogicalPlan::Sort { .. }) => {
644            let LogicalPlan::Sort { input, .. } = input.as_ref() else {
645                unreachable!("guard requires a sort input");
646            };
647            // Bounded-sort selection owns the Sort; it is not a child group.
648            1 + logical_group_count(input)
649        }
650        LogicalPlan::Expand { input, .. }
651        | LogicalPlan::NodeColumnLookup { input, .. }
652        | LogicalPlan::OptionalDegree { input, .. }
653        | LogicalPlan::Filter { input, .. }
654        | LogicalPlan::Project { input, .. }
655        | LogicalPlan::Aggregate { input, .. }
656        | LogicalPlan::Distinct { input }
657        | LogicalPlan::Sort { input, .. }
658        | LogicalPlan::Limit { input, .. } => 1 + logical_group_count(input),
659        LogicalPlan::OptionalRelationshipCountSum { .. }
660        | LogicalPlan::ThreadRepairStats { .. }
661        | LogicalPlan::ShortestPath { .. } => 1,
662        LogicalPlan::CreateNodeLabel { .. }
663        | LogicalPlan::CreateRelationshipType { .. }
664        | LogicalPlan::CreateNodeTable { .. }
665        | LogicalPlan::CreateRelationshipTable { .. }
666        | LogicalPlan::CreateProperty { .. }
667        | LogicalPlan::AlterTableState { .. }
668        | LogicalPlan::AlterPropertyState { .. }
669        | LogicalPlan::CreateIndex { .. }
670        | LogicalPlan::CreateCompositeIndex { .. }
671        | LogicalPlan::CreateRangeIndex { .. }
672        | LogicalPlan::CreateFullTextIndex { .. }
673        | LogicalPlan::CreateUniqueConstraint { .. }
674        | LogicalPlan::CreateNodePropertyExistsConstraint { .. }
675        | LogicalPlan::CreateRelationshipUniqueConstraint { .. }
676        | LogicalPlan::CreateRelationshipPropertyExistsConstraint { .. }
677        | LogicalPlan::ProjectGraph { .. }
678        | LogicalPlan::GraphAlgorithm { .. }
679        | LogicalPlan::VectorSeed { .. }
680        | LogicalPlan::CreateNode { .. }
681        | LogicalPlan::UnwindMutation { .. }
682        | LogicalPlan::MergeNode { .. }
683        | LogicalPlan::MergeRelationship { .. }
684        | LogicalPlan::MergeMatchedRelationship { .. }
685        | LogicalPlan::MergeRelationshipFromMatchedRelationship { .. }
686        | LogicalPlan::MergeRelationshipToMatchedTarget { .. }
687        | LogicalPlan::MergeRelationshipFromMatchedTarget { .. }
688        | LogicalPlan::CreateMatchedRelationship { .. }
689        | LogicalPlan::SetNodeProperty { .. }
690        | LogicalPlan::SetNodeProperties { .. }
691        | LogicalPlan::SetNodePropertiesReturn { .. }
692        | LogicalPlan::SetRelationshipProperty { .. }
693        | LogicalPlan::SetRelationshipProperties { .. }
694        | LogicalPlan::DeleteNode { .. }
695        | LogicalPlan::DeleteRelationship { .. }
696        | LogicalPlan::DeleteRelationshipTargetNodes { .. }
697        | LogicalPlan::CreateRelationship { .. }
698        | LogicalPlan::NodeScan { .. } => 1,
699        LogicalPlan::NodeCartesianProduct { left, right } => {
700            1 + logical_group_count(left) + logical_group_count(right)
701        }
702    }
703}
704
705// The graph memo currently contains one expression per group. Only child
706// lookup differs; physical alternatives and their costing share one lowerer.
707enum LoweringChildren<'a> {
708    Memo {
709        memo: &'a GraphMemo,
710        children: &'a [GroupId],
711    },
712    Direct,
713}
714
715impl LoweringChildren<'_> {
716    fn lower(
717        &self,
718        logical: &LogicalPlan,
719        index: usize,
720        catalog: &OptimizerCatalog,
721        optimizer_context: &OptimizerContext,
722        decisions: &mut Vec<String>,
723        stage_events: &mut Vec<StageTrace>,
724    ) -> PhysicalPlan {
725        match self {
726            Self::Memo { memo, children } => best_physical(
727                memo,
728                children[index],
729                catalog,
730                optimizer_context,
731                decisions,
732                stage_events,
733            ),
734            Self::Direct => lower_logical(
735                logical,
736                Self::Direct,
737                catalog,
738                optimizer_context,
739                decisions,
740                stage_events,
741            ),
742        }
743    }
744}
745
746fn lower_logical(
747    logical: &LogicalPlan,
748    children: LoweringChildren<'_>,
749    catalog: &OptimizerCatalog,
750    optimizer_context: &OptimizerContext,
751    decisions: &mut Vec<String>,
752    stage_events: &mut Vec<StageTrace>,
753) -> PhysicalPlan {
754    if let Some(plan) = select_exact_count_fast_path(logical, decisions, stage_events) {
755        return plan;
756    }
757    if let Some(plan) = simple::lower_simple_logical(logical, optimizer_context) {
758        return plan;
759    }
760    match logical {
761        LogicalPlan::GraphMatch { program, input } => PhysicalPlan::GraphMatchExec {
762            program: program.clone(),
763            input: input.as_ref().map(|input| {
764                Box::new(children.lower(
765                    input,
766                    0,
767                    catalog,
768                    optimizer_context,
769                    decisions,
770                    stage_events,
771                ))
772            }),
773        },
774        LogicalPlan::NodeCartesianProduct { left, right } => {
775            let left = children.lower(left, 0, catalog, optimizer_context, decisions, stage_events);
776            let right = children.lower(
777                right,
778                1,
779                catalog,
780                optimizer_context,
781                decisions,
782                stage_events,
783            );
784            let (left, right) =
785                order_single_row_cartesian_product_children(catalog, decisions, left, right);
786            push_cartesian_product_cost_decision(catalog, decisions, &left, &right);
787            PhysicalPlan::NodeCartesianProductExec {
788                left: Box::new(left),
789                right: Box::new(right),
790            }
791        }
792        LogicalPlan::NodeColumnLookup {
793            variable,
794            label,
795            property,
796            column,
797            optional,
798            input,
799        } => PhysicalPlan::NodeColumnLookupExec {
800            variable: variable.clone(),
801            label: label.clone(),
802            property: property.clone(),
803            column: column.clone(),
804            optional: *optional,
805            input: Box::new(children.lower(
806                input,
807                0,
808                catalog,
809                optimizer_context,
810                decisions,
811                stage_events,
812            )),
813        },
814        LogicalPlan::Expand {
815            source_variable,
816            source_label,
817            rel_variable,
818            rel_type,
819            rel_properties,
820            direction,
821            target_variable,
822            target_label,
823            min_hops,
824            max_hops,
825            optional,
826            input,
827        } => {
828            push_expand_estimate_decision(
829                catalog,
830                decisions,
831                ExpandEstimateRequest {
832                    source_label,
833                    rel_type,
834                    rel_properties,
835                    target_label,
836                    min_hops: *min_hops,
837                    max_hops: *max_hops,
838                },
839            );
840            let input = children.lower(
841                input,
842                0,
843                catalog,
844                optimizer_context,
845                decisions,
846                stage_events,
847            );
848            let graph_budget =
849                vector_seed_top_k(&input).map(|top_k| graph_expansion_budget(top_k, *max_hops));
850            if let Some(graph_budget) = graph_budget {
851                decisions.push(format!(
852                    "bound vector-seeded graph expansion to {} candidates and {} payload bytes",
853                    graph_budget.candidate_limit, graph_budget.payload_byte_limit
854                ));
855            }
856            PhysicalPlan::AdjacencyExpandExec {
857                source_variable: source_variable.clone(),
858                source_label: source_label.clone(),
859                rel_variable: rel_variable.clone(),
860                rel_type: rel_type.clone(),
861                rel_properties: rel_properties.clone(),
862                direction: *direction,
863                target_variable: target_variable.clone(),
864                target_label: target_label.clone(),
865                min_hops: *min_hops,
866                max_hops: *max_hops,
867                optional: *optional,
868                graph_budget,
869                input: Box::new(input),
870            }
871        }
872        LogicalPlan::OptionalDegree {
873            source_variable,
874            rel_type,
875            rel_properties,
876            direction,
877            target_label,
878            target_properties,
879            alias,
880            input,
881        } => PhysicalPlan::OptionalDegreeExec {
882            source_variable: source_variable.clone(),
883            rel_type: rel_type.clone(),
884            rel_properties: rel_properties.clone(),
885            direction: *direction,
886            target_label: target_label.clone(),
887            target_properties: target_properties.clone(),
888            alias: alias.clone(),
889            input: Box::new(children.lower(
890                input,
891                0,
892                catalog,
893                optimizer_context,
894                decisions,
895                stage_events,
896            )),
897        },
898        LogicalPlan::OptionalRelationshipCountSum {
899            variable,
900            label,
901            properties,
902            legs,
903            output,
904        } => {
905            push_optional_relationship_count_sum_cost_decision(
906                catalog, decisions, label, properties, legs,
907            );
908            PhysicalPlan::OptionalRelationshipCountSumExec {
909                variable: variable.clone(),
910                label: label.clone(),
911                properties: properties.clone(),
912                legs: legs.clone(),
913                output: output.clone(),
914            }
915        }
916        LogicalPlan::Filter { predicate, input } => {
917            if let Some(plan) =
918                index_seek_from_filter(predicate, input, catalog, decisions, stage_events)
919            {
920                plan
921            } else if let Some(plan) = access::source_segment_scan_from_filter(predicate, input) {
922                decisions.push(
923                    "choose SourceSegmentScan for storage-prunable Source filter".to_string(),
924                );
925                PhysicalPlan::FilterExec {
926                    predicate: predicate.clone(),
927                    input: Box::new(plan),
928                }
929            } else if let Predicate::BoundRelationshipExists {
930                source_variable,
931                rel_type,
932                direction,
933                target_variable,
934            } = predicate
935            {
936                decisions.push(
937                    "lower bound relationship existence predicate to AdjacencyExistsExec"
938                        .to_string(),
939                );
940                PhysicalPlan::AdjacencyExistsExec {
941                    source_variable: source_variable.clone(),
942                    rel_type: rel_type.clone(),
943                    direction: *direction,
944                    target_variable: target_variable.clone(),
945                    input: Box::new(children.lower(
946                        input,
947                        0,
948                        catalog,
949                        optimizer_context,
950                        decisions,
951                        stage_events,
952                    )),
953                }
954            } else {
955                let mut input = children.lower(
956                    input,
957                    0,
958                    catalog,
959                    optimizer_context,
960                    decisions,
961                    stage_events,
962                );
963                push_vector_seed_metadata_filter(&mut input, predicate, decisions);
964                input = join::lower_property_join(input, predicate, decisions);
965                PhysicalPlan::FilterExec {
966                    predicate: predicate.clone(),
967                    input: Box::new(input),
968                }
969            }
970        }
971        LogicalPlan::Project { items, input } => PhysicalPlan::ProjectExec {
972            items: items.clone(),
973            input: Box::new(children.lower(
974                input,
975                0,
976                catalog,
977                optimizer_context,
978                decisions,
979                stage_events,
980            )),
981        },
982        LogicalPlan::Aggregate {
983            group_keys,
984            items,
985            input,
986        } => PhysicalPlan::AggregateExec {
987            group_keys: group_keys.clone(),
988            items: items.clone(),
989            input: Box::new(children.lower(
990                input,
991                0,
992                catalog,
993                optimizer_context,
994                decisions,
995                stage_events,
996            )),
997        },
998        LogicalPlan::Distinct { input } => PhysicalPlan::DistinctExec {
999            input: Box::new(children.lower(
1000                input,
1001                0,
1002                catalog,
1003                optimizer_context,
1004                decisions,
1005                stage_events,
1006            )),
1007        },
1008        LogicalPlan::Sort { items, input } => PhysicalPlan::SortExec {
1009            items: items.clone(),
1010            input: Box::new(children.lower(
1011                input,
1012                0,
1013                catalog,
1014                optimizer_context,
1015                decisions,
1016                stage_events,
1017            )),
1018        },
1019        LogicalPlan::Limit {
1020            offset,
1021            limit,
1022            input,
1023        } => {
1024            if *limit == Some(0) {
1025                PhysicalPlan::EmptyExec
1026            } else if let (Some(limit), LogicalPlan::Sort { items, input }) =
1027                (limit, input.as_ref())
1028            {
1029                let input = children.lower(
1030                    input,
1031                    0,
1032                    catalog,
1033                    optimizer_context,
1034                    decisions,
1035                    stage_events,
1036                );
1037                select_bounded_sort_plan(items.clone(), *offset, *limit, input, catalog, decisions)
1038            } else {
1039                PhysicalPlan::LimitExec {
1040                    offset: *offset,
1041                    limit: *limit,
1042                    input: Box::new(children.lower(
1043                        input,
1044                        0,
1045                        catalog,
1046                        optimizer_context,
1047                        decisions,
1048                        stage_events,
1049                    )),
1050                }
1051            }
1052        }
1053        _ => unreachable!("leaf logical plans are lowered before child planning"),
1054    }
1055}
1056
1057fn select_exact_count_fast_path(
1058    logical: &LogicalPlan,
1059    decisions: &mut Vec<String>,
1060    stage_events: &mut Vec<StageTrace>,
1061) -> Option<PhysicalPlan> {
1062    if let Some(plan) = select_node_count_fast_path(logical, decisions, stage_events) {
1063        return Some(plan);
1064    }
1065    select_relationship_count_fast_path(logical, decisions, stage_events)
1066}
1067
1068fn select_node_count_fast_path(
1069    logical: &LogicalPlan,
1070    decisions: &mut Vec<String>,
1071    stage_events: &mut Vec<StageTrace>,
1072) -> Option<PhysicalPlan> {
1073    let LogicalPlan::Aggregate {
1074        group_keys,
1075        items,
1076        input,
1077    } = logical
1078    else {
1079        return None;
1080    };
1081    let [item] = items.as_slice() else {
1082        return None;
1083    };
1084    let LogicalPlan::NodeScan { variable, label } = input.as_ref() else {
1085        return None;
1086    };
1087    let exact_node_count = group_keys.is_empty()
1088        && item.function == AggregateFunction::Count
1089        && match &item.target {
1090            AggregateTarget::All => !item.distinct,
1091            AggregateTarget::Variable(target) => target == variable,
1092            AggregateTarget::Property { .. }
1093            | AggregateTarget::Column(_)
1094            | AggregateTarget::ColumnProperty { .. } => false,
1095        };
1096    if !exact_node_count {
1097        return None;
1098    }
1099
1100    decisions.push(format!(
1101        "selected implementation:node_count_fast_path: use exact label count for {label}"
1102    ));
1103    stage_events
1104        .push(ACCESS_PATH_SELECTION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1105    Some(PhysicalPlan::NodeCountExec {
1106        label: label.clone(),
1107        output: item.name.clone(),
1108    })
1109}
1110
1111fn select_relationship_count_fast_path(
1112    logical: &LogicalPlan,
1113    decisions: &mut Vec<String>,
1114    stage_events: &mut Vec<StageTrace>,
1115) -> Option<PhysicalPlan> {
1116    let LogicalPlan::Aggregate {
1117        group_keys,
1118        items,
1119        input,
1120    } = logical
1121    else {
1122        return None;
1123    };
1124    let [item] = items.as_slice() else {
1125        return None;
1126    };
1127    let LogicalPlan::Expand {
1128        source_variable,
1129        source_label,
1130        rel_variable,
1131        rel_type,
1132        rel_properties,
1133        direction,
1134        target_label,
1135        min_hops,
1136        max_hops,
1137        optional,
1138        input,
1139        ..
1140    } = input.as_ref()
1141    else {
1142        return None;
1143    };
1144    let LogicalPlan::NodeScan { variable, label } = input.as_ref() else {
1145        return None;
1146    };
1147    let exact_relationship_count = group_keys.is_empty()
1148        && item.function == AggregateFunction::Count
1149        && !item.distinct
1150        && source_variable == variable
1151        && source_label.is_empty()
1152        && label.is_empty()
1153        && target_label.is_empty()
1154        && rel_properties.is_empty()
1155        && *min_hops == 1
1156        && *max_hops == 1
1157        && !optional
1158        && matches!(
1159            direction,
1160            RelationshipDirection::Outgoing | RelationshipDirection::Incoming
1161        )
1162        && match &item.target {
1163            AggregateTarget::All => true,
1164            AggregateTarget::Variable(target) => rel_variable.as_ref() == Some(target),
1165            AggregateTarget::Property { .. }
1166            | AggregateTarget::Column(_)
1167            | AggregateTarget::ColumnProperty { .. } => false,
1168        };
1169    if !exact_relationship_count {
1170        return None;
1171    }
1172
1173    decisions.push(format!(
1174        "selected implementation:relationship_count_fast_path: use exact relationship count for {rel_type}"
1175    ));
1176    stage_events
1177        .push(ACCESS_PATH_SELECTION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1178    Some(PhysicalPlan::RelationshipCountExec {
1179        rel_type: rel_type.clone(),
1180        output: item.name.clone(),
1181    })
1182}
1183
1184fn finalize_physical_plan(
1185    plan: PhysicalPlan,
1186    decisions: &mut Vec<String>,
1187    stage_events: &mut Vec<StageTrace>,
1188) -> PhysicalPlan {
1189    let event_count_before = stage_events.len();
1190    let finalized = finalize_required_node_properties(plan, decisions, stage_events);
1191    if !stage_events[event_count_before..]
1192        .iter()
1193        .any(|event| event.name() == PLAN_FINALIZATION_STAGE.name())
1194    {
1195        stage_events.push(PLAN_FINALIZATION_STAGE.trace(StageStats::new(1, 1)));
1196    }
1197    finalized
1198}
1199
1200fn finalize_required_node_properties(
1201    plan: PhysicalPlan,
1202    decisions: &mut Vec<String>,
1203    stage_events: &mut Vec<StageTrace>,
1204) -> PhysicalPlan {
1205    match plan {
1206        PhysicalPlan::ProjectExec { items, input } => {
1207            select_node_projection_scan(&items, &input, decisions, stage_events)
1208                .unwrap_or(PhysicalPlan::ProjectExec { items, input })
1209        }
1210        PhysicalPlan::AggregateExec {
1211            group_keys,
1212            items,
1213            input,
1214        } => {
1215            let input = select_node_aggregate_required_scan(
1216                &group_keys,
1217                &items,
1218                &input,
1219                decisions,
1220                stage_events,
1221            )
1222            .map(Box::new)
1223            .unwrap_or(input);
1224            PhysicalPlan::AggregateExec {
1225                group_keys,
1226                items,
1227                input,
1228            }
1229        }
1230        PhysicalPlan::LimitExec {
1231            offset,
1232            limit,
1233            input,
1234        } => PhysicalPlan::LimitExec {
1235            offset,
1236            limit,
1237            input: Box::new(finalize_required_node_properties(
1238                *input,
1239                decisions,
1240                stage_events,
1241            )),
1242        },
1243        PhysicalPlan::DistinctExec { input } => PhysicalPlan::DistinctExec {
1244            input: Box::new(finalize_required_node_properties(
1245                *input,
1246                decisions,
1247                stage_events,
1248            )),
1249        },
1250        PhysicalPlan::SortExec { items, input }
1251            if sort_items_use_only_projected_columns(&items) =>
1252        {
1253            PhysicalPlan::SortExec {
1254                items,
1255                input: Box::new(finalize_required_node_properties(
1256                    *input,
1257                    decisions,
1258                    stage_events,
1259                )),
1260            }
1261        }
1262        PhysicalPlan::TopNExec {
1263            items,
1264            offset,
1265            limit,
1266            input,
1267        } if sort_items_use_only_projected_columns(&items) => PhysicalPlan::TopNExec {
1268            items,
1269            offset,
1270            limit,
1271            input: Box::new(finalize_required_node_properties(
1272                *input,
1273                decisions,
1274                stage_events,
1275            )),
1276        },
1277        plan => plan,
1278    }
1279}
1280
1281fn sort_items_use_only_projected_columns(items: &[SortItem]) -> bool {
1282    items
1283        .iter()
1284        .all(|item| matches!(item.key, SortKey::Column(_)))
1285}
1286
1287fn select_node_projection_scan(
1288    items: &[Projection],
1289    input: &PhysicalPlan,
1290    decisions: &mut Vec<String>,
1291    stage_events: &mut Vec<StageTrace>,
1292) -> Option<PhysicalPlan> {
1293    let (source, predicate) = match input {
1294        PhysicalPlan::FilterExec { predicate, input } => (input.as_ref(), Some(predicate)),
1295        source => (source, None),
1296    };
1297    let (variable, label, access) = node_projection_access(source)?;
1298    let mut required_properties = BTreeSet::new();
1299    if !items.iter().all(|item| {
1300        collect_projection_properties(&item.expression, variable, &mut required_properties)
1301    }) || predicate.is_some_and(|predicate| {
1302        !collect_predicate_properties(predicate, variable, &mut required_properties)
1303    }) {
1304        return None;
1305    }
1306    collect_access_properties(&access, &mut required_properties);
1307
1308    let required_properties = required_properties.into_iter().collect::<Vec<_>>();
1309    decisions.push(format!(
1310        "selected implementation:node_projection_scan: access={} decode {} required properties for {variable}:{label}",
1311        access.kind_name(),
1312        required_properties.len(),
1313    ));
1314    stage_events.push(PLAN_FINALIZATION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1315    Some(PhysicalPlan::NodeProjectionScanExec {
1316        variable: variable.clone(),
1317        label: label.clone(),
1318        access,
1319        required_properties,
1320        predicate: predicate.cloned(),
1321        items: items.to_vec(),
1322    })
1323}
1324
1325fn select_node_aggregate_required_scan(
1326    group_keys: &[Projection],
1327    items: &[hawdb_plan::Aggregation],
1328    input: &PhysicalPlan,
1329    decisions: &mut Vec<String>,
1330    stage_events: &mut Vec<StageTrace>,
1331) -> Option<PhysicalPlan> {
1332    let (source, predicate) = match input {
1333        PhysicalPlan::FilterExec { predicate, input } => (input.as_ref(), Some(predicate)),
1334        source => (source, None),
1335    };
1336    let (variable, label, access) = node_projection_access(source)?;
1337    let mut required_properties = BTreeSet::new();
1338    if !group_keys.iter().all(|item| {
1339        collect_projection_properties(&item.expression, variable, &mut required_properties)
1340    }) || !items
1341        .iter()
1342        .all(|item| collect_aggregate_properties(item, variable, &mut required_properties))
1343        || predicate.is_some_and(|predicate| {
1344            !collect_predicate_properties(predicate, variable, &mut required_properties)
1345        })
1346    {
1347        return None;
1348    }
1349    collect_access_properties(&access, &mut required_properties);
1350
1351    let required_properties = required_properties.into_iter().collect::<Vec<_>>();
1352    decisions.push(format!(
1353        "selected implementation:node_aggregate_required_scan: access={} decode {} required properties for {variable}:{label}",
1354        access.kind_name(),
1355        required_properties.len(),
1356    ));
1357    stage_events.push(PLAN_FINALIZATION_STAGE.trace(StageStats::new(1, 1).with_rule_counts(1, 0)));
1358    Some(PhysicalPlan::NodeProjectionScanExec {
1359        variable: variable.clone(),
1360        label: label.clone(),
1361        access,
1362        required_properties,
1363        predicate: predicate.cloned(),
1364        items: Vec::new(),
1365    })
1366}
1367
1368fn collect_aggregate_properties(
1369    item: &hawdb_plan::Aggregation,
1370    variable: &str,
1371    required: &mut BTreeSet<String>,
1372) -> bool {
1373    match (&item.function, &item.target) {
1374        (AggregateFunction::Count, AggregateTarget::All) => true,
1375        (AggregateFunction::Count, AggregateTarget::Variable(current)) => current == variable,
1376        (
1377            AggregateFunction::Count
1378            | AggregateFunction::Min
1379            | AggregateFunction::Max
1380            | AggregateFunction::Avg
1381            | AggregateFunction::Collect,
1382            AggregateTarget::Property {
1383                variable: current,
1384                property,
1385            },
1386        ) if current == variable => {
1387            required.insert(property.clone());
1388            true
1389        }
1390        _ => false,
1391    }
1392}
1393
1394fn node_projection_access(plan: &PhysicalPlan) -> Option<(&String, &String, NodeProjectionAccess)> {
1395    match plan {
1396        PhysicalPlan::SeqNodeScan { variable, label } => {
1397            Some((variable, label, NodeProjectionAccess::LabelScan))
1398        }
1399        PhysicalPlan::IndexNodeSeek {
1400            variable,
1401            label,
1402            property,
1403            value,
1404        } => Some((
1405            variable,
1406            label,
1407            NodeProjectionAccess::PropertyValues {
1408                property: property.clone(),
1409                values: vec![value.clone()],
1410            },
1411        )),
1412        PhysicalPlan::IndexNodeMultiSeek {
1413            variable,
1414            label,
1415            property,
1416            values,
1417        } => Some((
1418            variable,
1419            label,
1420            NodeProjectionAccess::PropertyValues {
1421                property: property.clone(),
1422                values: values.clone(),
1423            },
1424        )),
1425        PhysicalPlan::IndexNodeUnionSeek {
1426            variable,
1427            label,
1428            branches,
1429        } => Some((
1430            variable,
1431            label,
1432            NodeProjectionAccess::PropertyUnion {
1433                branches: branches.clone(),
1434            },
1435        )),
1436        PhysicalPlan::IndexNodeCompositeSeek {
1437            variable,
1438            label,
1439            predicates,
1440        } => Some((
1441            variable,
1442            label,
1443            NodeProjectionAccess::CompositeEquality {
1444                predicates: predicates.clone(),
1445            },
1446        )),
1447        PhysicalPlan::IndexNodeCompositeRangeSeek {
1448            variable,
1449            label,
1450            seek,
1451        } => Some((
1452            variable,
1453            label,
1454            NodeProjectionAccess::CompositeRange { seek: seek.clone() },
1455        )),
1456        PhysicalPlan::IndexNodeRangeSeek {
1457            variable,
1458            label,
1459            property,
1460            lower,
1461            upper,
1462        } => Some((
1463            variable,
1464            label,
1465            NodeProjectionAccess::PropertyRange {
1466                property: property.clone(),
1467                lower: lower.clone(),
1468                upper: upper.clone(),
1469            },
1470        )),
1471        PhysicalPlan::IndexNodeTextSeek {
1472            variable,
1473            label,
1474            property,
1475            query,
1476        } => Some((
1477            variable,
1478            label,
1479            NodeProjectionAccess::FullText {
1480                property: property.clone(),
1481                query: query.clone(),
1482            },
1483        )),
1484        _ => None,
1485    }
1486}
1487
1488fn collect_access_properties(access: &NodeProjectionAccess, required: &mut BTreeSet<String>) {
1489    match access {
1490        NodeProjectionAccess::LabelScan => {}
1491        NodeProjectionAccess::PropertyValues { property, .. }
1492        | NodeProjectionAccess::PropertyRange { property, .. }
1493        | NodeProjectionAccess::FullText { property, .. } => {
1494            required.insert(property.clone());
1495        }
1496        NodeProjectionAccess::CompositeEquality { predicates } => {
1497            required.extend(predicates.iter().map(|(property, _)| property.clone()));
1498        }
1499        NodeProjectionAccess::CompositeRange { seek } => {
1500            required.extend(
1501                seek.equality_prefix
1502                    .iter()
1503                    .map(|(property, _)| property.clone()),
1504            );
1505            required.insert(seek.range_property.clone());
1506        }
1507        NodeProjectionAccess::PropertyUnion { branches } => {
1508            required.extend(branches.iter().map(|branch| branch.property.clone()));
1509        }
1510    }
1511}
1512
1513fn collect_predicate_properties(
1514    predicate: &Predicate,
1515    variable: &str,
1516    required: &mut BTreeSet<String>,
1517) -> bool {
1518    match predicate {
1519        Predicate::And(predicates) | Predicate::Or(predicates) => predicates
1520            .iter()
1521            .all(|predicate| collect_predicate_properties(predicate, variable, required)),
1522        Predicate::Not(predicate) => collect_predicate_properties(predicate, variable, required),
1523        Predicate::ConstantBool(_) => true,
1524        Predicate::IdEq {
1525            variable: current, ..
1526        }
1527        | Predicate::IdNotEq {
1528            variable: current, ..
1529        }
1530        | Predicate::IdCompare {
1531            variable: current, ..
1532        }
1533        | Predicate::IdIn {
1534            variable: current, ..
1535        } => current == variable,
1536        Predicate::PropertyEq {
1537            variable: current,
1538            property,
1539            ..
1540        }
1541        | Predicate::PropertyNotEq {
1542            variable: current,
1543            property,
1544            ..
1545        }
1546        | Predicate::PropertyCompare {
1547            variable: current,
1548            property,
1549            ..
1550        }
1551        | Predicate::PropertyListContains {
1552            variable: current,
1553            property,
1554            ..
1555        }
1556        | Predicate::PropertyListContainsLower {
1557            variable: current,
1558            property,
1559            ..
1560        }
1561        | Predicate::PropertyContains {
1562            variable: current,
1563            property,
1564            ..
1565        }
1566        | Predicate::PropertyStartsWith {
1567            variable: current,
1568            property,
1569            ..
1570        }
1571        | Predicate::PropertyEndsWith {
1572            variable: current,
1573            property,
1574            ..
1575        }
1576        | Predicate::PropertyRegexMatch {
1577            variable: current,
1578            property,
1579            ..
1580        }
1581        | Predicate::PropertyIsNull {
1582            variable: current,
1583            property,
1584        }
1585        | Predicate::PropertyIsNotNull {
1586            variable: current,
1587            property,
1588        }
1589        | Predicate::PropertyIn {
1590            variable: current,
1591            property,
1592            ..
1593        } => {
1594            if current != variable {
1595                return false;
1596            }
1597            required.insert(property.clone());
1598            true
1599        }
1600        Predicate::ExpressionEq { expression, value }
1601        | Predicate::ExpressionNotEq { expression, value }
1602        | Predicate::ExpressionContains { expression, value } => {
1603            collect_projection_properties(expression, variable, required)
1604                && collect_projection_properties(value, variable, required)
1605        }
1606        Predicate::ExpressionCompare {
1607            expression, value, ..
1608        } => {
1609            collect_projection_properties(expression, variable, required)
1610                && collect_projection_properties(value, variable, required)
1611        }
1612        Predicate::RelationshipExists { .. } | Predicate::BoundRelationshipExists { .. } => false,
1613    }
1614}
1615
1616fn collect_projection_properties(
1617    expression: &ProjectionExpression,
1618    variable: &str,
1619    required: &mut BTreeSet<String>,
1620) -> bool {
1621    match expression {
1622        ProjectionExpression::Case { .. }
1623        | ProjectionExpression::Binary { .. }
1624        | ProjectionExpression::Not(_)
1625        | ProjectionExpression::IsNull { .. } => expression
1626            .all_children(|child| collect_projection_properties(child, variable, required)),
1627        ProjectionExpression::Variable { .. }
1628        | ProjectionExpression::RelationshipType { .. }
1629        | ProjectionExpression::Column(_)
1630        | ProjectionExpression::ColumnProperty { .. }
1631        | ProjectionExpression::ColumnDefaultIfNullOrEq { .. }
1632        | ProjectionExpression::ColumnValueDefaultIfNull { .. }
1633        | ProjectionExpression::ColumnValueCasePropertyNotNullOrEq { .. }
1634        | ProjectionExpression::CaseColumnSearchRank(_) => false,
1635        ProjectionExpression::Id { variable: current } => current == variable,
1636        ProjectionExpression::Literal(_) => true,
1637        ProjectionExpression::Property {
1638            variable: current,
1639            property,
1640        }
1641        | ProjectionExpression::DatePart {
1642            variable: current,
1643            property,
1644            ..
1645        }
1646        | ProjectionExpression::DefaultIfNullOrEq {
1647            variable: current,
1648            property,
1649            ..
1650        }
1651        | ProjectionExpression::DefaultIfNull {
1652            variable: current,
1653            property,
1654            ..
1655        }
1656        | ProjectionExpression::CasePropertyNotNullOrEq {
1657            variable: current,
1658            property,
1659            ..
1660        }
1661        | ProjectionExpression::CasePropertyEqualsRank {
1662            variable: current,
1663            property,
1664            ..
1665        }
1666        | ProjectionExpression::CaseLowerPropertyDefault {
1667            variable: current,
1668            property,
1669            ..
1670        } => {
1671            if current != variable {
1672                return false;
1673            }
1674            required.insert(property.clone());
1675            true
1676        }
1677        ProjectionExpression::Coalesce(expressions) => expressions
1678            .iter()
1679            .all(|expression| collect_projection_properties(expression, variable, required)),
1680        ProjectionExpression::Left { expression, .. } | ProjectionExpression::Lower(expression) => {
1681            collect_projection_properties(expression, variable, required)
1682        }
1683        ProjectionExpression::CaseCoalesceDifferenceFloorZero {
1684            variable: current,
1685            terms,
1686        } => {
1687            if current != variable {
1688                return false;
1689            }
1690            required.extend(terms.iter().map(|term| term.property.clone()));
1691            true
1692        }
1693        ProjectionExpression::CaseEntitySearchRank(rank) => {
1694            if rank.variable != variable {
1695                return false;
1696            }
1697            required.insert(rank.name_property.clone());
1698            required.insert(rank.aliases_property.clone());
1699            true
1700        }
1701    }
1702}
1703
1704struct ExpandEstimateRequest<'a> {
1705    source_label: &'a str,
1706    rel_type: &'a str,
1707    rel_properties: &'a BTreeMap<String, Value>,
1708    target_label: &'a str,
1709    min_hops: usize,
1710    max_hops: usize,
1711}
1712
1713fn push_expand_estimate_decision(
1714    catalog: &OptimizerCatalog,
1715    decisions: &mut Vec<String>,
1716    request: ExpandEstimateRequest<'_>,
1717) {
1718    let estimate = catalog.estimate_expand_rows(
1719        request.source_label,
1720        request.rel_type,
1721        request.rel_properties,
1722        request.target_label,
1723        request.min_hops,
1724        request.max_hops,
1725    );
1726    let path_count = estimate
1727        .path_count
1728        .map(|count| count.to_string())
1729        .unwrap_or_else(|| "unknown".to_string());
1730    let hop_rows = estimate
1731        .hop_estimates
1732        .iter()
1733        .map(|estimate| {
1734            format!(
1735                "{}:{}:{}",
1736                estimate.hop,
1737                if estimate.exact { "exact" } else { "fallback" },
1738                estimate.rows
1739            )
1740        })
1741        .collect::<Vec<_>>()
1742        .join(",");
1743    decisions.push(format!(
1744        "estimate AdjacencyExpand for {}-[:{}*{}..{}]->{}: path_count={path_count} rel_count={} rel_type_sources={} average_fanout={} rel_property_distinct_product={} hop_rows=[{}] estimated_rows={}",
1745        request.source_label,
1746        request.rel_type,
1747        request.min_hops,
1748        request.max_hops,
1749        request.target_label,
1750        estimate.rel_count,
1751        estimate.source_count,
1752        estimate.average_fanout,
1753        estimate.property_distinct_product,
1754        hop_rows,
1755        estimate.estimated_rows
1756    ));
1757}
1758
1759fn order_single_row_cartesian_product_children(
1760    catalog: &OptimizerCatalog,
1761    decisions: &mut Vec<String>,
1762    left: PhysicalPlan,
1763    right: PhysicalPlan,
1764) -> (PhysicalPlan, PhysicalPlan) {
1765    let left_cost = estimate_physical_plan_cost(&left, catalog);
1766    let right_cost = estimate_physical_plan_cost(&right, catalog);
1767    if !cartesian_product_leaves_are_single_row(catalog, &left)
1768        || !cartesian_product_leaves_are_single_row(catalog, &right)
1769    {
1770        decisions.push(format!(
1771            "keep NodeCartesianProduct input order: left_rows={} right_rows={} reason=non_single_row_input",
1772            left_cost.estimated_rows, right_cost.estimated_rows
1773        ));
1774        return (left, right);
1775    }
1776
1777    let mut leaves = Vec::new();
1778    collect_cartesian_product_leaves(left, &mut leaves);
1779    collect_cartesian_product_leaves(right, &mut leaves);
1780
1781    let mut keyed_leaves = leaves
1782        .into_iter()
1783        .map(|plan| {
1784            let cost = estimate_physical_plan_cost(&plan, catalog);
1785            let fingerprint = plan.instance_fingerprint();
1786            (cost, fingerprint, plan)
1787        })
1788        .collect::<Vec<_>>();
1789
1790    let original_order = keyed_leaves
1791        .iter()
1792        .map(|(cost, fingerprint, _)| format!("{}:{}", cost.cost, fingerprint))
1793        .collect::<Vec<_>>()
1794        .join("|");
1795    keyed_leaves.sort_by(
1796        |(left_cost, left_fingerprint, _), (right_cost, right_fingerprint, _)| {
1797            (left_cost.cost, left_fingerprint).cmp(&(right_cost.cost, right_fingerprint))
1798        },
1799    );
1800    let ordered = keyed_leaves
1801        .iter()
1802        .map(|(cost, fingerprint, _)| format!("{}:{}", cost.cost, fingerprint))
1803        .collect::<Vec<_>>()
1804        .join("|");
1805    if original_order == ordered {
1806        decisions.push(format!(
1807            "keep NodeCartesianProduct single-row input order: inputs={} order={ordered}",
1808            keyed_leaves.len()
1809        ));
1810    } else {
1811        decisions.push(format!(
1812            "order NodeCartesianProduct single-row inputs: inputs={} original={original_order} ordered={ordered}",
1813            keyed_leaves.len()
1814        ));
1815    }
1816
1817    let mut ordered_plans = keyed_leaves.into_iter().map(|(_, _, plan)| plan);
1818    let left = ordered_plans
1819        .next()
1820        .expect("cartesian product has left input");
1821    let right = rebuild_cartesian_product(ordered_plans);
1822    (left, right)
1823}
1824
1825fn cartesian_product_leaves_are_single_row(
1826    catalog: &OptimizerCatalog,
1827    plan: &PhysicalPlan,
1828) -> bool {
1829    match plan {
1830        PhysicalPlan::NodeCartesianProductExec { left, right } => {
1831            cartesian_product_leaves_are_single_row(catalog, left)
1832                && cartesian_product_leaves_are_single_row(catalog, right)
1833        }
1834        plan => estimate_physical_plan_cost(plan, catalog).estimated_rows == 1,
1835    }
1836}
1837
1838fn collect_cartesian_product_leaves(plan: PhysicalPlan, leaves: &mut Vec<PhysicalPlan>) {
1839    match plan {
1840        PhysicalPlan::NodeCartesianProductExec { left, right } => {
1841            collect_cartesian_product_leaves(*left, leaves);
1842            collect_cartesian_product_leaves(*right, leaves);
1843        }
1844        plan => leaves.push(plan),
1845    }
1846}
1847
1848fn rebuild_cartesian_product(plans: impl IntoIterator<Item = PhysicalPlan>) -> PhysicalPlan {
1849    let mut plans = plans.into_iter();
1850    let first = plans
1851        .next()
1852        .expect("cartesian product rebuild requires at least one input");
1853    plans.fold(first, |left, right| {
1854        PhysicalPlan::NodeCartesianProductExec {
1855            left: Box::new(left),
1856            right: Box::new(right),
1857        }
1858    })
1859}
1860
1861fn push_cartesian_product_cost_decision(
1862    catalog: &OptimizerCatalog,
1863    decisions: &mut Vec<String>,
1864    left: &PhysicalPlan,
1865    right: &PhysicalPlan,
1866) {
1867    let left_cost = estimate_physical_plan_cost(left, catalog);
1868    let right_cost = estimate_physical_plan_cost(right, catalog);
1869    let cost = estimate_node_cartesian_product_cost(left_cost, right_cost);
1870    decisions.push(format!(
1871        "estimate NodeCartesianProduct: left_rows={} right_rows={} output_rows={} left_cost={} right_cost={} cost={}",
1872        left_cost.estimated_rows,
1873        right_cost.estimated_rows,
1874        cost.estimated_rows,
1875        left_cost.cost,
1876        right_cost.cost,
1877        cost.cost
1878    ));
1879}