1use 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}