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