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
262mod vector;
263pub use vector::{estimate_diskann, DiskANNWorkEstimate};
264
265impl CostModel {
266 pub fn new() -> Self {
267 Self::default()
268 }
269
270 pub fn with_graph_stats(mut self, stats: GraphStats) -> Self {
271 self.graph_stats = Some(stats);
272 self
273 }
274
275 pub fn with_column_stats(mut self, stats: BTreeMap<String, ColumnStats>) -> Self {
276 self.column_stats = stats;
277 self
278 }
279
280 pub fn with_cost_estimator(mut self, estimator: CostEstimator) -> Self {
281 self.physical_cost = estimator;
282 self
283 }
284
285 #[expect(
287 clippy::too_many_lines,
288 reason = "cost boundary keeps paradigm terms and clamping in one formula"
289 )]
290 pub fn estimate(&self, op: &OperatorTree, stats: &IndexStats) -> f64 {
291 let n = stats.total_docs as f64;
292 match op {
293 OperatorTree::Empty => 0.0,
294 OperatorTree::Term { query, field, .. } | OperatorTree::Phrase { query, field, .. } => {
295 if stats.total_docs == 0 {
296 1.0
297 } else {
298 let f = field.as_deref().unwrap_or("_default");
299 stats.doc_freq(f, query) as f64
300 }
301 }
302 OperatorTree::VectorSimilarity {
303 field,
304 query_vector,
305 ..
306 } => {
307 if let Some(physical) = stats.diskann_query(field, query_vector) {
308 return estimate_diskann(physical, None, stats.total_docs).total();
309 }
310 let dims = f64::from(stats.dimensions.max(1));
311 dims * ((stats.total_docs as f64) + 1.0).log2()
312 }
313 OperatorTree::KNN {
314 field,
315 query_vector,
316 k,
317 } => {
318 if let Some(physical) = stats.diskann_query(field, query_vector) {
319 return estimate_diskann(physical, Some(*k), stats.total_docs).total();
320 }
321 let dims = f64::from(stats.dimensions.max(1));
322 dims * ((stats.total_docs as f64) + 1.0).log2()
323 }
324 OperatorTree::CalibratedVectorMatch {
325 field,
326 query_vector,
327 k,
328 ..
329 } => {
330 if let Some(physical) = stats.diskann_query(field, query_vector) {
331 return estimate_diskann(physical, Some(*k), stats.total_docs).total()
332 * SCORE_OVERHEAD_FACTOR;
333 }
334 let dims = f64::from(stats.dimensions.max(1));
335 dims * ((stats.total_docs as f64) + 1.0).log2() * SCORE_OVERHEAD_FACTOR
336 }
337 OperatorTree::IndexScan { .. } => self
338 .physical_cost
339 .estimate_unary(
340 OperatorKind::IndexScan,
341 self.estimated_cardinality(op, stats),
342 )
343 .total(),
344 OperatorTree::Score { source, .. } => {
345 self.estimate(source, stats) * SCORE_OVERHEAD_FACTOR
346 }
347 OperatorTree::BayesianScore { source, .. } => {
348 self.estimate(source, stats) * SCORE_OVERHEAD_FACTOR
349 }
350 OperatorTree::BayesianMatchWithPrior { query, field, .. } => {
351 let postings = if stats.total_docs == 0 {
352 1.0
353 } else {
354 stats.doc_freq(field, query) as f64
355 };
356 postings * SCORE_OVERHEAD_FACTOR
357 }
358 OperatorTree::Filter { source, .. } => {
359 let input_rows = source
360 .as_deref()
361 .map_or(n, |source| self.estimated_cardinality(source, stats));
362 let input_cost = source.as_deref().map_or_else(
363 || {
364 self.physical_cost
365 .estimate_unary(OperatorKind::TableScan, n)
366 .total()
367 },
368 |source| self.estimate(source, stats),
369 );
370 input_cost
371 + self
372 .physical_cost
373 .estimate_unary(OperatorKind::Filter, input_rows)
374 .total()
375 }
376 OperatorTree::Intersect(ops) => {
377 let total: f64 = ops.iter().map(|o| self.estimate(o, stats)).sum();
378 total
379 }
380 OperatorTree::Union(ops) => ops.iter().map(|o| self.estimate(o, stats)).sum(),
381 OperatorTree::Aggregate { .. } => n,
382 OperatorTree::GroupBy { .. } => n * GROUP_BY_OVERHEAD_FACTOR,
383 OperatorTree::BayesianEvidenceFusion { signals, .. }
384 | OperatorTree::RobustPositiveEvidencePool { signals, .. }
385 | OperatorTree::ProbBoolFusion { signals, .. }
386 | OperatorTree::AttentionFusion { signals, .. }
387 | OperatorTree::LearnedFusion { signals, .. } => {
388 signals.iter().map(|s| self.estimate(s, stats)).sum()
389 }
390 OperatorTree::ProbNot { signal, .. } => self.estimate(signal, stats) + n,
391 OperatorTree::HybridTextVector {
392 term_op, vector_op, ..
393 } => self.estimate(term_op, stats) + self.estimate(vector_op, stats),
394 OperatorTree::SemanticFilter { source, vector_op } => {
395 self.estimate(source, stats) + self.estimate(vector_op, stats)
396 }
397 OperatorTree::VectorExclusion { positive, negative } => {
398 self.estimate(positive, stats) + self.estimate(negative, stats)
399 }
400 OperatorTree::FacetVector { vector_op, .. } => self.estimate(vector_op, stats),
401 OperatorTree::VertexAggregation { .. } => n * VERTEX_AGG_FRACTION,
402 OperatorTree::Traverse {
403 label, max_hops, ..
404 }
405 | OperatorTree::TemporalTraverse {
406 label, max_hops, ..
407 } => {
408 if let Some(gs) = self.graph_stats.as_ref() {
409 let sel = gs.label_selectivity(label.as_deref());
410 let d = gs.avg_out_degree * sel;
411 let hops = (*max_hops).max(1) as f64;
412 let cost = if d == 1.0 {
413 hops
414 } else if d <= 0.0 {
415 0.0
416 } else {
417 d * (d.powf(hops) - 1.0) / (d - 1.0)
418 };
419 cost.max(1.0)
420 } else {
421 n * TRAVERSE_FRACTION
422 }
423 }
424 OperatorTree::GraphNeighbors { label, .. } => self
425 .graph_stats
426 .as_ref()
427 .map(|stats| {
428 (stats.avg_out_degree * stats.label_selectivity(label.as_deref())).max(1.0)
429 })
430 .unwrap_or(n * TRAVERSE_FRACTION),
431 OperatorTree::GraphEdges { label, .. } => self
432 .graph_stats
433 .as_ref()
434 .map(|stats| stats.num_edges as f64 * stats.label_selectivity(label.as_deref()))
435 .unwrap_or(n),
436 OperatorTree::PatternMatch { pattern, .. } => {
437 let k = pattern.vertex_patterns.len() as f64;
438 if let Some(gs) = self.graph_stats.as_ref() {
443 let nv = if gs.num_vertices > 0 {
444 gs.num_vertices as f64
445 } else {
446 n
447 };
448 (nv.powf(k) * 0.01).max(1.0)
449 } else {
450 n * n
451 }
452 }
453 OperatorTree::TemporalPatternMatch { .. } => n * n,
454 OperatorTree::RegularPathQuery { rpq_source, .. }
455 | OperatorTree::WeightedPathQuery { rpq_source, .. } => {
456 if is_label_chain(rpq_source) {
459 return n * 0.1;
460 }
461 if let Some(gs) = self.graph_stats.as_ref() {
462 let nv = gs.num_vertices as f64;
463 let r_size = rpq_source_label_count(rpq_source).max(1) as f64;
464 return (nv.powi(2) * r_size * 0.001).max(1.0);
465 }
466 n * n
467 }
468 OperatorTree::SparseThreshold { source, .. } => self.estimate(source, stats) * 0.5,
469 OperatorTree::MultiFieldSearch { fields, .. } => n * fields.len() as f64,
470 OperatorTree::MessagePassing { source } | OperatorTree::GraphEmbedding { source } => {
471 self.estimate(source, stats)
472 }
473 OperatorTree::MultiStage { stages } => stages
474 .iter()
475 .map(|s| self.estimate(&s.child, stats))
476 .sum::<f64>()
477 .max(n * 0.1),
478 OperatorTree::PageRank { .. } => n * 20.0 * 0.1,
482 OperatorTree::HITS { .. } => n * 20.0 * 0.2,
483 OperatorTree::BetweennessCentrality { .. } => n * n * 0.5,
484 OperatorTree::TextSimilarityJoin { left, right, .. } => {
485 let left_rows = self.estimated_cardinality(left, stats);
486 let right_rows = self.estimated_cardinality(right, stats);
487 self.estimate(left, stats)
488 + self.estimate(right, stats)
489 + self
490 .physical_cost
491 .estimate_join(OperatorKind::NestedLoopJoin, left_rows, right_rows)
492 .total()
493 }
494 OperatorTree::VectorSimilarityJoin { left, right, .. } => {
495 let left_rows = self.estimated_cardinality(left, stats);
496 let right_rows = self.estimated_cardinality(right, stats);
497 self.estimate(left, stats)
498 + self.estimate(right, stats)
499 + self
500 .physical_cost
501 .estimate_join(OperatorKind::NestedLoopJoin, left_rows, right_rows)
502 .total()
503 * f64::from(stats.dimensions.max(1))
504 }
505 OperatorTree::GraphJoin {
506 left, right, label, ..
507 } => {
508 let left_rows = self.estimated_cardinality(left, stats);
509 let right_rows = self.estimated_cardinality(right, stats);
510 let candidate_edges = self.graph_stats.as_ref().map_or(left_rows, |graph| {
511 left_rows * graph.avg_out_degree * graph.label_selectivity(label.as_deref())
512 });
513 self.estimate(left, stats)
514 + self.estimate(right, stats)
515 + candidate_edges
516 + self
517 .physical_cost
518 .estimate_join(OperatorKind::HashJoinInner, candidate_edges, right_rows)
519 .total()
520 }
521 OperatorTree::CrossParadigmJoin { left, right } => {
522 let left_rows = self.estimated_cardinality(left, stats);
523 let right_rows = self.estimated_cardinality(right, stats);
524 self.estimate(left, stats)
525 + self.estimate(right, stats)
526 + self
527 .physical_cost
528 .estimate_join(OperatorKind::HashJoinInner, left_rows, right_rows)
529 .total()
530 }
531 OperatorTree::HybridJoin { left, right } => {
532 let left_rows = self.estimated_cardinality(left, stats);
533 let right_rows = self.estimated_cardinality(right, stats);
534 let equality_candidates = (left_rows * right_rows) / n.max(1.0);
535 self.estimate(left, stats)
536 + self.estimate(right, stats)
537 + self
538 .physical_cost
539 .estimate_join(OperatorKind::HashJoinInner, left_rows, right_rows)
540 .total()
541 + self
542 .physical_cost
543 .estimate_join(OperatorKind::NestedLoopJoin, equality_candidates, 1.0)
544 .total()
545 * f64::from(stats.dimensions.max(1))
546 }
547 OperatorTree::ProgressiveFusion { stages, .. } => {
548 stages.last().map(|s| s.k as f64).unwrap_or(n)
549 }
550 OperatorTree::DeepFusion { layers, .. } => self.estimate_deep_fusion(layers, stats, n),
551 OperatorTree::DeepPredict { .. } => n,
552 OperatorTree::Composed(ops) | OperatorTree::Opaque { children: ops, .. } => {
553 ops.iter().map(|o| self.estimate(o, stats)).sum()
554 }
555 OperatorTree::Complement(inner) => self.estimate(inner, stats) + n,
556 OperatorTree::EncodeGraphPosting { source } => self.estimate(source, stats),
557 OperatorTree::CosineProbability(inner) => self.estimate(inner, stats),
558 OperatorTree::Facet { source, .. } => match source.as_deref() {
559 Some(s) => self.estimate(s, stats),
560 None => n,
561 },
562 }
563 }
564
565 fn estimate_deep_fusion(&self, layers: &[DeepFusionLayer], stats: &IndexStats, n: f64) -> f64 {
566 let mut cost = 0.0_f64;
567 for layer in layers {
568 match layer {
569 DeepFusionLayer::Signal { signals } => {
570 cost += signals.iter().map(|s| self.estimate(s, stats)).sum::<f64>();
571 }
572 DeepFusionLayer::Propagate { .. } | DeepFusionLayer::Conv { .. } => {
573 cost += n;
574 }
575 DeepFusionLayer::Pool { .. }
576 | DeepFusionLayer::Flatten
577 | DeepFusionLayer::Dense { .. }
578 | DeepFusionLayer::Softmax
579 | DeepFusionLayer::BatchNorm { .. }
580 | DeepFusionLayer::Dropout { .. } => {}
581 }
582 }
583 cost.max(n * 0.1)
584 }
585
586 fn estimated_cardinality(&self, op: &OperatorTree, stats: &IndexStats) -> f64 {
587 let mut estimator =
588 crate::CardinalityEstimator::new().with_column_stats(self.column_stats.clone());
589 if let Some(graph_stats) = self.graph_stats.clone() {
590 estimator = estimator.with_graph_stats(graph_stats);
591 }
592 estimator.estimate(op, stats)
593 }
594}
595
596fn is_label_chain(source: &str) -> bool {
600 !source.contains('*')
601 && !source.contains('+')
602 && !source.contains('?')
603 && !source.contains('|')
604 && !source.contains('{')
605}
606
607fn rpq_source_label_count(source: &str) -> usize {
611 let mut labels = 0_usize;
612 let mut in_ident = false;
613 for ch in source.chars() {
614 if ch.is_alphanumeric() || ch == '_' {
615 if !in_ident {
616 labels += 1;
617 in_ident = true;
618 }
619 } else {
620 in_ident = false;
621 if ch == '*' || ch == '+' || ch == '?' {
622 labels = labels.saturating_add(labels);
623 }
624 }
625 }
626 labels.max(1)
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn hash_join_prefers_smaller_build_side() {
635 let est = CostEstimator::default();
636 let a = est.estimate_join(OperatorKind::HashJoinInner, 100.0, 1_000_000.0);
637 let b = est.estimate_join(OperatorKind::HashJoinInner, 1_000_000.0, 100.0);
638 assert!((a.total() - b.total()).abs() < 1e-6);
640 }
641
642 #[test]
643 fn nested_loop_grows_quadratically() {
644 let est = CostEstimator::default();
645 let a = est.estimate_join(OperatorKind::NestedLoopJoin, 100.0, 100.0);
646 let b = est.estimate_join(OperatorKind::NestedLoopJoin, 200.0, 200.0);
647 assert!(b.total() > a.total() * 3.5);
648 }
649
650 #[test]
651 fn sort_cpu_dominates_for_large_inputs() {
652 let est = CostEstimator::default();
653 let cost = est.estimate_unary(OperatorKind::Sort, 10_000.0);
654 assert!(cost.cpu > 0.0);
655 assert!(cost.memory > 0.0);
656 }
657
658 #[test]
659 fn operator_similarity_join_uses_the_physical_cost_estimator() {
660 let left = OperatorTree::KNN {
661 query_vector: vec![1.0, 0.0],
662 k: 10,
663 field: "embedding".into(),
664 };
665 let right = OperatorTree::KNN {
666 query_vector: vec![1.0, 0.0],
667 k: 20,
668 field: "embedding".into(),
669 };
670 let join = OperatorTree::TextSimilarityJoin {
671 left: Box::new(left.clone()),
672 right: Box::new(right.clone()),
673 threshold: 0.5,
674 };
675 let mut stats = IndexStats::new(100);
676 stats.dimensions = 2;
677 let coefficients = CostCoefficients {
678 nestedloop_per_pair: 2.0,
679 ..CostCoefficients::default()
680 };
681 let model = CostModel::new().with_cost_estimator(CostEstimator::new(coefficients));
682 let child_cost = model.estimate(&left, &stats) + model.estimate(&right, &stats);
683
684 assert_eq!(
685 model.estimate(&join, &stats),
686 child_cost + 10.0 * 20.0 * 2.0
687 );
688 }
689}