1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum OperatorKind {
15 TableScan,
16 IndexScan,
17 Filter,
18 Project,
19 Sort,
20 HashAggregate,
21 Window,
22 Limit,
23 HashJoinInner,
24 HashJoinOuter,
25 SortMergeJoin,
26 NestedLoopJoin,
27 IndexJoin,
28 SemiJoin,
29 AntiJoin,
30 CrossJoin,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub struct OperatorCost {
35 pub cpu: f64,
36 pub io: f64,
37 pub memory: f64,
38}
39
40impl OperatorCost {
41 pub fn zero() -> Self {
42 Self {
43 cpu: 0.0,
44 io: 0.0,
45 memory: 0.0,
46 }
47 }
48
49 pub fn total(&self) -> f64 {
50 self.cpu + self.io + self.memory
51 }
52
53 pub fn add(&self, other: &OperatorCost) -> OperatorCost {
54 OperatorCost {
55 cpu: self.cpu + other.cpu,
56 io: self.io + other.io,
57 memory: self.memory + other.memory,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy)]
65pub struct CostCoefficients {
66 pub scan_per_row: f64,
67 pub index_per_row: f64,
68 pub filter_per_row: f64,
69 pub project_per_row: f64,
70 pub sort_per_row_log: f64,
71 pub hashagg_build_per_row: f64,
72 pub window_per_row: f64,
73 pub limit_per_row: f64,
74 pub hashjoin_build_per_row: f64,
75 pub hashjoin_probe_per_row: f64,
76 pub sortmerge_per_row: f64,
77 pub nestedloop_per_pair: f64,
78 pub crossjoin_per_pair: f64,
79 pub io_per_disk_row: f64,
80}
81
82impl Default for CostCoefficients {
83 fn default() -> Self {
84 Self {
85 scan_per_row: 1.0,
86 index_per_row: 0.1,
87 filter_per_row: 0.2,
88 project_per_row: 0.1,
89 sort_per_row_log: 1.5,
90 hashagg_build_per_row: 1.2,
91 window_per_row: 1.5,
92 limit_per_row: 0.05,
93 hashjoin_build_per_row: 0.8,
94 hashjoin_probe_per_row: 0.3,
95 sortmerge_per_row: 1.0,
96 nestedloop_per_pair: 0.05,
97 crossjoin_per_pair: 0.04,
98 io_per_disk_row: 5.0,
99 }
100 }
101}
102
103#[derive(Debug, Clone)]
104pub struct CostEstimator {
105 pub coefficients: CostCoefficients,
106}
107
108impl Default for CostEstimator {
109 fn default() -> Self {
110 Self {
111 coefficients: CostCoefficients::default(),
112 }
113 }
114}
115
116impl CostEstimator {
117 pub fn new(coefficients: CostCoefficients) -> Self {
118 Self { coefficients }
119 }
120
121 pub fn estimate_unary(&self, kind: OperatorKind, rows: f64) -> OperatorCost {
125 let c = &self.coefficients;
126 let rows = rows.max(0.0);
127 let log_rows = (rows.max(2.0)).log2();
128 match kind {
129 OperatorKind::TableScan => OperatorCost {
130 cpu: rows * c.scan_per_row,
131 io: rows * c.io_per_disk_row,
132 memory: 0.0,
133 },
134 OperatorKind::IndexScan => OperatorCost {
135 cpu: rows * c.index_per_row,
136 io: rows * c.io_per_disk_row * 0.5,
137 memory: 0.0,
138 },
139 OperatorKind::Filter => OperatorCost {
140 cpu: rows * c.filter_per_row,
141 io: 0.0,
142 memory: 0.0,
143 },
144 OperatorKind::Project => OperatorCost {
145 cpu: rows * c.project_per_row,
146 io: 0.0,
147 memory: 0.0,
148 },
149 OperatorKind::Sort => OperatorCost {
150 cpu: rows * log_rows * c.sort_per_row_log,
151 io: 0.0,
152 memory: rows,
153 },
154 OperatorKind::HashAggregate => OperatorCost {
155 cpu: rows * c.hashagg_build_per_row,
156 io: 0.0,
157 memory: rows,
158 },
159 OperatorKind::Window => OperatorCost {
160 cpu: rows * c.window_per_row,
161 io: 0.0,
162 memory: rows,
163 },
164 OperatorKind::Limit => OperatorCost {
165 cpu: rows * c.limit_per_row,
166 io: 0.0,
167 memory: 0.0,
168 },
169 _ => OperatorCost::zero(),
170 }
171 }
172
173 pub fn estimate_join(
178 &self,
179 kind: OperatorKind,
180 left_rows: f64,
181 right_rows: f64,
182 ) -> OperatorCost {
183 let c = &self.coefficients;
184 let l = left_rows.max(0.0);
185 let r = right_rows.max(0.0);
186 let (build, probe) = if l <= r { (l, r) } else { (r, l) };
187 match kind {
188 OperatorKind::HashJoinInner => OperatorCost {
189 cpu: build * c.hashjoin_build_per_row + probe * c.hashjoin_probe_per_row,
190 io: 0.0,
191 memory: build,
192 },
193 OperatorKind::HashJoinOuter => OperatorCost {
194 cpu: build * c.hashjoin_build_per_row * 1.2
195 + probe * c.hashjoin_probe_per_row * 1.2,
196 io: 0.0,
197 memory: build,
198 },
199 OperatorKind::SortMergeJoin => {
200 let total = l + r;
201 OperatorCost {
202 cpu: total * c.sortmerge_per_row + total * (total.max(2.0)).log2() * 0.5,
203 io: 0.0,
204 memory: total,
205 }
206 }
207 OperatorKind::NestedLoopJoin => OperatorCost {
208 cpu: l * r * c.nestedloop_per_pair,
209 io: 0.0,
210 memory: 0.0,
211 },
212 OperatorKind::IndexJoin => OperatorCost {
213 cpu: l * c.hashjoin_probe_per_row + l * c.index_per_row,
214 io: l * c.io_per_disk_row * 0.5,
215 memory: 0.0,
216 },
217 OperatorKind::SemiJoin | OperatorKind::AntiJoin => OperatorCost {
218 cpu: probe * c.hashjoin_probe_per_row + build * c.hashjoin_build_per_row,
219 io: 0.0,
220 memory: build,
221 },
222 OperatorKind::CrossJoin => OperatorCost {
223 cpu: l * r * c.crossjoin_per_pair,
224 io: 0.0,
225 memory: 0.0,
226 },
227 _ => OperatorCost::zero(),
228 }
229 }
230}
231
232use std::collections::BTreeMap;
237
238use uqa_core::IndexStats;
239use uqa_operators::{DeepFusionLayer, OperatorTree};
240
241use crate::cardinality::{ColumnStats, GraphStats};
242
243pub const SCORE_OVERHEAD_FACTOR: f64 = 1.1;
245pub const GROUP_BY_OVERHEAD_FACTOR: f64 = 1.5;
247pub const VERTEX_AGG_FRACTION: f64 = 0.2;
249pub const TRAVERSE_FRACTION: f64 = 0.1;
251
252#[derive(Debug, Clone, Default)]
256pub struct CostModel {
257 pub graph_stats: Option<GraphStats>,
258 pub column_stats: BTreeMap<String, ColumnStats>,
259 pub physical_cost: CostEstimator,
260}
261
262impl CostModel {
263 pub fn new() -> Self {
264 Self::default()
265 }
266
267 pub fn with_graph_stats(mut self, stats: GraphStats) -> Self {
268 self.graph_stats = Some(stats);
269 self
270 }
271
272 pub fn with_column_stats(mut self, stats: BTreeMap<String, ColumnStats>) -> Self {
273 self.column_stats = stats;
274 self
275 }
276
277 pub fn with_cost_estimator(mut self, estimator: CostEstimator) -> Self {
278 self.physical_cost = estimator;
279 self
280 }
281
282 #[expect(
284 clippy::too_many_lines,
285 reason = "cost boundary keeps paradigm terms and clamping in one formula"
286 )]
287 pub fn estimate(&self, op: &OperatorTree, stats: &IndexStats) -> f64 {
288 let n = stats.total_docs as f64;
289 match op {
290 OperatorTree::Empty => 0.0,
291 OperatorTree::Term { query, field, .. } | OperatorTree::Phrase { query, field, .. } => {
292 if stats.total_docs == 0 {
293 1.0
294 } else {
295 let f = field.as_deref().unwrap_or("_default");
296 stats.doc_freq(f, query) as f64
297 }
298 }
299 OperatorTree::VectorSimilarity { .. } | OperatorTree::KNN { .. } => {
300 let dims = f64::from(stats.dimensions.max(1));
301 dims * ((stats.total_docs as f64) + 1.0).log2()
302 }
303 OperatorTree::CalibratedVectorMatch { .. } => {
304 let dims = f64::from(stats.dimensions.max(1));
305 dims * ((stats.total_docs as f64) + 1.0).log2() * SCORE_OVERHEAD_FACTOR
306 }
307 OperatorTree::IndexScan { .. } => self
308 .physical_cost
309 .estimate_unary(
310 OperatorKind::IndexScan,
311 self.estimated_cardinality(op, stats),
312 )
313 .total(),
314 OperatorTree::Score { source, .. } => {
315 self.estimate(source, stats) * SCORE_OVERHEAD_FACTOR
316 }
317 OperatorTree::BayesianScore { source, .. } => {
318 self.estimate(source, stats) * SCORE_OVERHEAD_FACTOR
319 }
320 OperatorTree::BayesianMatchWithPrior { query, field, .. } => {
321 let postings = if stats.total_docs == 0 {
322 1.0
323 } else {
324 stats.doc_freq(field, query) as f64
325 };
326 postings * SCORE_OVERHEAD_FACTOR
327 }
328 OperatorTree::Filter { source, .. } => {
329 let input_rows = source
330 .as_deref()
331 .map_or(n, |source| self.estimated_cardinality(source, stats));
332 let input_cost = source.as_deref().map_or_else(
333 || {
334 self.physical_cost
335 .estimate_unary(OperatorKind::TableScan, n)
336 .total()
337 },
338 |source| self.estimate(source, stats),
339 );
340 input_cost
341 + self
342 .physical_cost
343 .estimate_unary(OperatorKind::Filter, input_rows)
344 .total()
345 }
346 OperatorTree::Intersect(ops) => {
347 let total: f64 = ops.iter().map(|o| self.estimate(o, stats)).sum();
348 total
349 }
350 OperatorTree::Union(ops) => ops.iter().map(|o| self.estimate(o, stats)).sum(),
351 OperatorTree::Aggregate { .. } => n,
352 OperatorTree::GroupBy { .. } => n * GROUP_BY_OVERHEAD_FACTOR,
353 OperatorTree::BayesianEvidenceFusion { signals, .. }
354 | OperatorTree::RobustPositiveEvidencePool { signals, .. }
355 | OperatorTree::ProbBoolFusion { signals, .. }
356 | OperatorTree::AttentionFusion { signals, .. }
357 | OperatorTree::LearnedFusion { signals, .. } => {
358 signals.iter().map(|s| self.estimate(s, stats)).sum()
359 }
360 OperatorTree::ProbNot { signal, .. } => self.estimate(signal, stats) + n,
361 OperatorTree::HybridTextVector {
362 term_op, vector_op, ..
363 } => self.estimate(term_op, stats) + self.estimate(vector_op, stats),
364 OperatorTree::SemanticFilter { source, vector_op } => {
365 self.estimate(source, stats) + self.estimate(vector_op, stats)
366 }
367 OperatorTree::VectorExclusion { positive, negative } => {
368 self.estimate(positive, stats) + self.estimate(negative, stats)
369 }
370 OperatorTree::FacetVector { vector_op, .. } => self.estimate(vector_op, stats),
371 OperatorTree::VertexAggregation { .. } => n * VERTEX_AGG_FRACTION,
372 OperatorTree::Traverse {
373 label, max_hops, ..
374 }
375 | OperatorTree::TemporalTraverse {
376 label, max_hops, ..
377 } => {
378 if let Some(gs) = self.graph_stats.as_ref() {
379 let sel = gs.label_selectivity(label.as_deref());
380 let d = gs.avg_out_degree * sel;
381 let hops = (*max_hops).max(1) as f64;
382 let cost = if d == 1.0 {
383 hops
384 } else if d <= 0.0 {
385 0.0
386 } else {
387 d * (d.powf(hops) - 1.0) / (d - 1.0)
388 };
389 cost.max(1.0)
390 } else {
391 n * TRAVERSE_FRACTION
392 }
393 }
394 OperatorTree::GraphNeighbors { label, .. } => self
395 .graph_stats
396 .as_ref()
397 .map(|stats| {
398 (stats.avg_out_degree * stats.label_selectivity(label.as_deref())).max(1.0)
399 })
400 .unwrap_or(n * TRAVERSE_FRACTION),
401 OperatorTree::GraphEdges { label, .. } => self
402 .graph_stats
403 .as_ref()
404 .map(|stats| stats.num_edges as f64 * stats.label_selectivity(label.as_deref()))
405 .unwrap_or(n),
406 OperatorTree::PatternMatch { pattern, .. } => {
407 let k = pattern.vertex_patterns.len() as f64;
408 if let Some(gs) = self.graph_stats.as_ref() {
413 let nv = if gs.num_vertices > 0 {
414 gs.num_vertices as f64
415 } else {
416 n
417 };
418 (nv.powf(k) * 0.01).max(1.0)
419 } else {
420 n * n
421 }
422 }
423 OperatorTree::TemporalPatternMatch { .. } => n * n,
424 OperatorTree::RegularPathQuery { rpq_source, .. }
425 | OperatorTree::WeightedPathQuery { rpq_source, .. } => {
426 if is_label_chain(rpq_source) {
429 return n * 0.1;
430 }
431 if let Some(gs) = self.graph_stats.as_ref() {
432 let nv = gs.num_vertices as f64;
433 let r_size = rpq_source_label_count(rpq_source).max(1) as f64;
434 return (nv.powi(2) * r_size * 0.001).max(1.0);
435 }
436 n * n
437 }
438 OperatorTree::SparseThreshold { source, .. } => self.estimate(source, stats) * 0.5,
439 OperatorTree::MultiFieldSearch { fields, .. } => n * fields.len() as f64,
440 OperatorTree::MessagePassing { source } | OperatorTree::GraphEmbedding { source } => {
441 self.estimate(source, stats)
442 }
443 OperatorTree::MultiStage { stages } => stages
444 .iter()
445 .map(|s| self.estimate(&s.child, stats))
446 .sum::<f64>()
447 .max(n * 0.1),
448 OperatorTree::PageRank { .. } => n * 20.0 * 0.1,
452 OperatorTree::HITS { .. } => n * 20.0 * 0.2,
453 OperatorTree::BetweennessCentrality { .. } => n * n * 0.5,
454 OperatorTree::TextSimilarityJoin { left, right, .. } => {
455 let left_rows = self.estimated_cardinality(left, stats);
456 let right_rows = self.estimated_cardinality(right, stats);
457 self.estimate(left, stats)
458 + self.estimate(right, stats)
459 + self
460 .physical_cost
461 .estimate_join(OperatorKind::NestedLoopJoin, left_rows, right_rows)
462 .total()
463 }
464 OperatorTree::VectorSimilarityJoin { left, right, .. } => {
465 let left_rows = self.estimated_cardinality(left, stats);
466 let right_rows = self.estimated_cardinality(right, stats);
467 self.estimate(left, stats)
468 + self.estimate(right, stats)
469 + self
470 .physical_cost
471 .estimate_join(OperatorKind::NestedLoopJoin, left_rows, right_rows)
472 .total()
473 * f64::from(stats.dimensions.max(1))
474 }
475 OperatorTree::GraphJoin {
476 left, right, label, ..
477 } => {
478 let left_rows = self.estimated_cardinality(left, stats);
479 let right_rows = self.estimated_cardinality(right, stats);
480 let candidate_edges = self.graph_stats.as_ref().map_or(left_rows, |graph| {
481 left_rows * graph.avg_out_degree * graph.label_selectivity(label.as_deref())
482 });
483 self.estimate(left, stats)
484 + self.estimate(right, stats)
485 + candidate_edges
486 + self
487 .physical_cost
488 .estimate_join(OperatorKind::HashJoinInner, candidate_edges, right_rows)
489 .total()
490 }
491 OperatorTree::CrossParadigmJoin { left, right } => {
492 let left_rows = self.estimated_cardinality(left, stats);
493 let right_rows = self.estimated_cardinality(right, stats);
494 self.estimate(left, stats)
495 + self.estimate(right, stats)
496 + self
497 .physical_cost
498 .estimate_join(OperatorKind::HashJoinInner, left_rows, right_rows)
499 .total()
500 }
501 OperatorTree::HybridJoin { left, right } => {
502 let left_rows = self.estimated_cardinality(left, stats);
503 let right_rows = self.estimated_cardinality(right, stats);
504 let equality_candidates = (left_rows * right_rows) / n.max(1.0);
505 self.estimate(left, stats)
506 + self.estimate(right, stats)
507 + self
508 .physical_cost
509 .estimate_join(OperatorKind::HashJoinInner, left_rows, right_rows)
510 .total()
511 + self
512 .physical_cost
513 .estimate_join(OperatorKind::NestedLoopJoin, equality_candidates, 1.0)
514 .total()
515 * f64::from(stats.dimensions.max(1))
516 }
517 OperatorTree::ProgressiveFusion { stages, .. } => {
518 stages.last().map(|s| s.k as f64).unwrap_or(n)
519 }
520 OperatorTree::DeepFusion { layers, .. } => self.estimate_deep_fusion(layers, stats, n),
521 OperatorTree::DeepPredict { .. } => n,
522 OperatorTree::Composed(ops) | OperatorTree::Opaque { children: ops, .. } => {
523 ops.iter().map(|o| self.estimate(o, stats)).sum()
524 }
525 OperatorTree::Complement(inner) => self.estimate(inner, stats) + n,
526 OperatorTree::EncodeGraphPosting { source } => self.estimate(source, stats),
527 OperatorTree::CosineProbability(inner) => self.estimate(inner, stats),
528 OperatorTree::Facet { source, .. } => match source.as_deref() {
529 Some(s) => self.estimate(s, stats),
530 None => n,
531 },
532 }
533 }
534
535 fn estimate_deep_fusion(&self, layers: &[DeepFusionLayer], stats: &IndexStats, n: f64) -> f64 {
536 let mut cost = 0.0_f64;
537 for layer in layers {
538 match layer {
539 DeepFusionLayer::Signal { signals } => {
540 cost += signals.iter().map(|s| self.estimate(s, stats)).sum::<f64>();
541 }
542 DeepFusionLayer::Propagate { .. } | DeepFusionLayer::Conv { .. } => {
543 cost += n;
544 }
545 DeepFusionLayer::Pool { .. }
546 | DeepFusionLayer::Flatten
547 | DeepFusionLayer::Dense { .. }
548 | DeepFusionLayer::Softmax
549 | DeepFusionLayer::BatchNorm { .. }
550 | DeepFusionLayer::Dropout { .. } => {}
551 }
552 }
553 cost.max(n * 0.1)
554 }
555
556 fn estimated_cardinality(&self, op: &OperatorTree, stats: &IndexStats) -> f64 {
557 let mut estimator =
558 crate::CardinalityEstimator::new().with_column_stats(self.column_stats.clone());
559 if let Some(graph_stats) = self.graph_stats.clone() {
560 estimator = estimator.with_graph_stats(graph_stats);
561 }
562 estimator.estimate(op, stats)
563 }
564}
565
566fn is_label_chain(source: &str) -> bool {
570 !source.contains('*')
571 && !source.contains('+')
572 && !source.contains('?')
573 && !source.contains('|')
574 && !source.contains('{')
575}
576
577fn rpq_source_label_count(source: &str) -> usize {
581 let mut labels = 0_usize;
582 let mut in_ident = false;
583 for ch in source.chars() {
584 if ch.is_alphanumeric() || ch == '_' {
585 if !in_ident {
586 labels += 1;
587 in_ident = true;
588 }
589 } else {
590 in_ident = false;
591 if ch == '*' || ch == '+' || ch == '?' {
592 labels = labels.saturating_add(labels);
593 }
594 }
595 }
596 labels.max(1)
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[test]
604 fn hash_join_prefers_smaller_build_side() {
605 let est = CostEstimator::default();
606 let a = est.estimate_join(OperatorKind::HashJoinInner, 100.0, 1_000_000.0);
607 let b = est.estimate_join(OperatorKind::HashJoinInner, 1_000_000.0, 100.0);
608 assert!((a.total() - b.total()).abs() < 1e-6);
610 }
611
612 #[test]
613 fn nested_loop_grows_quadratically() {
614 let est = CostEstimator::default();
615 let a = est.estimate_join(OperatorKind::NestedLoopJoin, 100.0, 100.0);
616 let b = est.estimate_join(OperatorKind::NestedLoopJoin, 200.0, 200.0);
617 assert!(b.total() > a.total() * 3.5);
618 }
619
620 #[test]
621 fn sort_cpu_dominates_for_large_inputs() {
622 let est = CostEstimator::default();
623 let cost = est.estimate_unary(OperatorKind::Sort, 10_000.0);
624 assert!(cost.cpu > 0.0);
625 assert!(cost.memory > 0.0);
626 }
627
628 #[test]
629 fn operator_similarity_join_uses_the_physical_cost_estimator() {
630 let left = OperatorTree::KNN {
631 query_vector: vec![1.0, 0.0],
632 k: 10,
633 field: "embedding".into(),
634 };
635 let right = OperatorTree::KNN {
636 query_vector: vec![1.0, 0.0],
637 k: 20,
638 field: "embedding".into(),
639 };
640 let join = OperatorTree::TextSimilarityJoin {
641 left: Box::new(left.clone()),
642 right: Box::new(right.clone()),
643 threshold: 0.5,
644 };
645 let mut stats = IndexStats::new(100);
646 stats.dimensions = 2;
647 let coefficients = CostCoefficients {
648 nestedloop_per_pair: 2.0,
649 ..CostCoefficients::default()
650 };
651 let model = CostModel::new().with_cost_estimator(CostEstimator::new(coefficients));
652 let child_cost = model.estimate(&left, &stats) + model.estimate(&right, &stats);
653
654 assert_eq!(
655 model.estimate(&join, &stats),
656 child_cost + 10.0 * 20.0 * 2.0
657 );
658 }
659}