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