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