Skip to main content

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