Skip to main content

hawdb_optimizer_graph/
search.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 crate::cost::{PlanCost, PlanCostBreakdown};
16use crate::properties::PhysicalProperties;
17use crate::stage::StageTrace;
18use crate::trace::{OperatorCardinalityEstimate, OptimizerTrace};
19use std::collections::BTreeMap;
20
21pub use hawdb_cascades::{
22    OptimizerSearchDirective, OptimizerSearchDirectiveError, RuleEvent, RuleOutcome, SearchMode,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct OptimizationSearchReport {
27    groups: usize,
28    mode: SearchMode,
29    warnings: Vec<String>,
30    decisions: Vec<String>,
31    rule_events: Vec<RuleEvent>,
32    stage_events: Vec<StageTrace>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SelectedPlanTrace {
37    pub query_digest: Option<String>,
38    pub explain: String,
39    pub fingerprint: String,
40    pub cost: PlanCost,
41    pub cost_breakdown: PlanCostBreakdown,
42    pub properties: PhysicalProperties,
43    pub cardinality_estimates: Vec<OperatorCardinalityEstimate>,
44    pub operator_counts: BTreeMap<String, usize>,
45    pub class_counts: BTreeMap<String, usize>,
46}
47
48impl OptimizationSearchReport {
49    pub fn memo(groups: usize) -> Self {
50        Self {
51            groups,
52            mode: SearchMode::Memo,
53            warnings: Vec::new(),
54            decisions: Vec::new(),
55            rule_events: Vec::new(),
56            stage_events: Vec::new(),
57        }
58    }
59
60    pub fn direct_fallback(required_groups: usize, max_groups: usize) -> Self {
61        let mut report = Self {
62            groups: required_groups,
63            mode: SearchMode::DirectFallback,
64            warnings: Vec::new(),
65            decisions: Vec::new(),
66            rule_events: Vec::new(),
67            stage_events: Vec::new(),
68        };
69        report.push_decision(format!(
70            "selected direct child resolution: required_groups={required_groups} max_groups={max_groups}; same physical alternatives as memo lowering"
71        ));
72        report
73    }
74
75    pub fn forced_direct_fallback(required_groups: usize) -> Self {
76        let mut report = Self {
77            groups: required_groups,
78            mode: SearchMode::DirectFallback,
79            warnings: Vec::new(),
80            decisions: Vec::new(),
81            rule_events: Vec::new(),
82            stage_events: Vec::new(),
83        };
84        report.push_decision(
85            "selected direct physical fallback: explicit optimizer search directive",
86        );
87        report
88    }
89
90    pub fn groups(&self) -> usize {
91        self.groups
92    }
93
94    pub fn mode(&self) -> SearchMode {
95        self.mode
96    }
97
98    pub fn warnings(&self) -> &[String] {
99        &self.warnings
100    }
101
102    pub fn decisions(&self) -> &[String] {
103        &self.decisions
104    }
105
106    pub fn rule_events(&self) -> &[RuleEvent] {
107        &self.rule_events
108    }
109
110    pub fn stage_events(&self) -> &[StageTrace] {
111        &self.stage_events
112    }
113
114    pub fn push_stage_event(&mut self, event: StageTrace) {
115        self.stage_events.push(event);
116    }
117
118    pub(crate) fn push_warning(&mut self, warning: impl Into<String>) {
119        self.warnings.push(warning.into());
120    }
121
122    pub fn push_decision(&mut self, decision: impl Into<String>) {
123        let decision = decision.into();
124        if let Some(event) = RuleEvent::from_decision(&decision) {
125            self.rule_events.push(event);
126        }
127        self.decisions.push(decision);
128    }
129
130    pub fn extend_decisions(&mut self, decisions: impl IntoIterator<Item = String>) {
131        for decision in decisions {
132            self.push_decision(decision);
133        }
134    }
135
136    pub fn push_rule_event(&mut self, event: RuleEvent) {
137        self.decisions.push(event.clone().into_decision());
138        self.rule_events.push(event);
139    }
140
141    pub fn record_selected_plan_cost(&mut self, cost: PlanCost) {
142        let cost = cost.with_cardinality_floor();
143        self.push_decision(format!(
144            "selected physical plan cost: estimated_rows={} cost={}",
145            cost.estimated_rows, cost.cost
146        ));
147    }
148
149    pub fn into_trace(self, selected: SelectedPlanTrace) -> OptimizerTrace {
150        OptimizerTrace {
151            groups: self.groups,
152            search_mode: self.mode,
153            query_digest: selected.query_digest,
154            selected_plan: selected.explain,
155            selected_plan_fingerprint: selected.fingerprint,
156            selected_plan_cost: selected.cost.with_cardinality_floor(),
157            selected_plan_cost_breakdown: selected.cost_breakdown.with_cardinality_floor(),
158            selected_plan_properties: selected.properties,
159            selected_plan_cardinality_estimates: selected.cardinality_estimates,
160            selected_plan_operator_counts: selected.operator_counts,
161            selected_plan_class_counts: selected.class_counts,
162            warnings: self.warnings,
163            decisions: self.decisions,
164            rule_events: self.rule_events,
165            stage_events: self.stage_events,
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::{OptimizationSearchReport, RuleOutcome, SearchMode, SelectedPlanTrace};
173    use crate::{ApplyOrder, OptimizationStage, PlanCost, PlanCostBreakdown, StageStats};
174    use std::collections::BTreeMap;
175
176    #[test]
177    fn direct_fallback_report_records_budget_decision_without_degradation() {
178        let report = OptimizationSearchReport::direct_fallback(9, 4);
179
180        assert_eq!(report.groups(), 9);
181        assert_eq!(report.mode(), SearchMode::DirectFallback);
182        assert!(report.warnings().is_empty());
183        assert_eq!(report.decisions().len(), 1);
184        assert!(report.decisions()[0].contains("required_groups=9 max_groups=4"));
185        assert!(report.decisions()[0].contains("same physical alternatives"));
186        assert_eq!(report.rule_events().len(), 1);
187        assert!(report.stage_events().is_empty());
188    }
189
190    #[test]
191    fn forced_direct_fallback_reports_directive_without_budget_warning() {
192        let report = OptimizationSearchReport::forced_direct_fallback(9);
193
194        assert_eq!(report.groups(), 9);
195        assert_eq!(report.mode(), SearchMode::DirectFallback);
196        assert!(report.warnings().is_empty());
197        assert!(report
198            .decisions()
199            .iter()
200            .any(|decision| decision.contains("explicit optimizer search directive")));
201    }
202
203    #[test]
204    fn search_report_preserves_structured_rule_events_from_legacy_decisions() {
205        let mut report = OptimizationSearchReport::memo(1);
206        report.push_decision(
207            "apply implementation:node_equality_index_seek: priority=100 property=id",
208        );
209        report.push_decision("choose IndexNodeSeek");
210
211        assert_eq!(report.decisions().len(), 2);
212        assert_eq!(report.rule_events().len(), 1);
213        assert_eq!(
214            report.rule_events()[0].rule(),
215            "implementation:node_equality_index_seek"
216        );
217        assert_eq!(report.rule_events()[0].outcome(), RuleOutcome::Applied);
218        assert_eq!(report.rule_events()[0].detail(), "priority=100 property=id");
219    }
220
221    #[test]
222    fn search_report_builds_legacy_trace_surface() {
223        let mut report = OptimizationSearchReport::memo(2);
224        report.push_stage_event(
225            OptimizationStage::new("physical_search", ApplyOrder::BottomUp)
226                .trace(StageStats::new(2, 1).with_rule_counts(1, 0)),
227        );
228        report.push_decision("choose IndexNodeSeek");
229        report.record_selected_plan_cost(PlanCost {
230            estimated_rows: 1,
231            cost: 4,
232        });
233
234        let trace = report.into_trace(SelectedPlanTrace {
235            query_digest: None,
236            explain: "IndexNodeSeek".to_string(),
237            fingerprint: "IndexNodeSeek(1:m:6:Memory.id=i:1)".to_string(),
238            cost: PlanCost {
239                estimated_rows: 1,
240                cost: 4,
241            },
242            cost_breakdown: PlanCostBreakdown::new(1, 1, 3, 0, 0),
243            properties: Default::default(),
244            cardinality_estimates: Vec::new(),
245            operator_counts: BTreeMap::from([("IndexNodeSeek".to_string(), 1)]),
246            class_counts: BTreeMap::from([("access".to_string(), 1)]),
247        });
248
249        assert_eq!(trace.groups, 2);
250        assert_eq!(trace.search_mode, SearchMode::Memo);
251        assert!(trace.warnings.is_empty());
252        assert_eq!(trace.stage_events[0].name(), "physical_search");
253        assert_eq!(trace.stage_events[0].apply_order(), ApplyOrder::BottomUp);
254        assert_eq!(trace.stage_events[0].stats().applied_rules, 1);
255        assert_eq!(trace.decisions[0], "choose IndexNodeSeek");
256        assert_eq!(
257            trace.decisions[1],
258            "selected physical plan cost: estimated_rows=1 cost=4"
259        );
260        assert_eq!(trace.selected_plan_operator_counts["IndexNodeSeek"], 1);
261        assert_eq!(trace.selected_plan_class_counts["access"], 1);
262    }
263}