1use crate::query::plan::{
6 AggregateOp, DistinctOp, ExpandOp, FilterOp, JoinOp, JoinType, LimitOp, LogicalOperator,
7 NodeScanOp, ProjectOp, ReturnOp, SkipOp, SortOp, VectorJoinOp, VectorScanOp,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct Cost {
15 pub cpu: f64,
17 pub io: f64,
19 pub memory: f64,
21 pub network: f64,
23}
24
25impl Cost {
26 #[must_use]
28 pub fn zero() -> Self {
29 Self {
30 cpu: 0.0,
31 io: 0.0,
32 memory: 0.0,
33 network: 0.0,
34 }
35 }
36
37 #[must_use]
39 pub fn cpu(cpu: f64) -> Self {
40 Self {
41 cpu,
42 io: 0.0,
43 memory: 0.0,
44 network: 0.0,
45 }
46 }
47
48 #[must_use]
50 pub fn with_io(mut self, io: f64) -> Self {
51 self.io = io;
52 self
53 }
54
55 #[must_use]
57 pub fn with_memory(mut self, memory: f64) -> Self {
58 self.memory = memory;
59 self
60 }
61
62 #[must_use]
66 pub fn total(&self) -> f64 {
67 self.cpu + self.io * 10.0 + self.memory * 0.1 + self.network * 100.0
68 }
69
70 #[must_use]
72 pub fn total_weighted(&self, cpu_weight: f64, io_weight: f64, mem_weight: f64) -> f64 {
73 self.cpu * cpu_weight + self.io * io_weight + self.memory * mem_weight
74 }
75}
76
77impl std::ops::Add for Cost {
78 type Output = Self;
79
80 fn add(self, other: Self) -> Self {
81 Self {
82 cpu: self.cpu + other.cpu,
83 io: self.io + other.io,
84 memory: self.memory + other.memory,
85 network: self.network + other.network,
86 }
87 }
88}
89
90impl std::ops::AddAssign for Cost {
91 fn add_assign(&mut self, other: Self) {
92 self.cpu += other.cpu;
93 self.io += other.io;
94 self.memory += other.memory;
95 self.network += other.network;
96 }
97}
98
99pub struct CostModel {
101 cpu_tuple_cost: f64,
103 hash_lookup_cost: f64,
105 sort_comparison_cost: f64,
107 avg_tuple_size: f64,
109 page_size: f64,
111}
112
113impl CostModel {
114 #[must_use]
116 pub fn new() -> Self {
117 Self {
118 cpu_tuple_cost: 0.01,
119 hash_lookup_cost: 0.02,
120 sort_comparison_cost: 0.02,
121 avg_tuple_size: 100.0,
122 page_size: 8192.0,
123 }
124 }
125
126 #[must_use]
128 pub fn estimate(&self, op: &LogicalOperator, cardinality: f64) -> Cost {
129 match op {
130 LogicalOperator::NodeScan(scan) => self.node_scan_cost(scan, cardinality),
131 LogicalOperator::Filter(filter) => self.filter_cost(filter, cardinality),
132 LogicalOperator::Project(project) => self.project_cost(project, cardinality),
133 LogicalOperator::Expand(expand) => self.expand_cost(expand, cardinality),
134 LogicalOperator::Join(join) => self.join_cost(join, cardinality),
135 LogicalOperator::Aggregate(agg) => self.aggregate_cost(agg, cardinality),
136 LogicalOperator::Sort(sort) => self.sort_cost(sort, cardinality),
137 LogicalOperator::Distinct(distinct) => self.distinct_cost(distinct, cardinality),
138 LogicalOperator::Limit(limit) => self.limit_cost(limit, cardinality),
139 LogicalOperator::Skip(skip) => self.skip_cost(skip, cardinality),
140 LogicalOperator::Return(ret) => self.return_cost(ret, cardinality),
141 LogicalOperator::Empty => Cost::zero(),
142 LogicalOperator::VectorScan(scan) => self.vector_scan_cost(scan, cardinality),
143 LogicalOperator::VectorJoin(join) => self.vector_join_cost(join, cardinality),
144 _ => Cost::cpu(cardinality * self.cpu_tuple_cost),
145 }
146 }
147
148 fn node_scan_cost(&self, _scan: &NodeScanOp, cardinality: f64) -> Cost {
150 let pages = (cardinality * self.avg_tuple_size) / self.page_size;
151 Cost::cpu(cardinality * self.cpu_tuple_cost).with_io(pages)
152 }
153
154 fn filter_cost(&self, _filter: &FilterOp, cardinality: f64) -> Cost {
156 Cost::cpu(cardinality * self.cpu_tuple_cost * 1.5)
158 }
159
160 fn project_cost(&self, project: &ProjectOp, cardinality: f64) -> Cost {
162 let expr_count = project.projections.len() as f64;
164 Cost::cpu(cardinality * self.cpu_tuple_cost * expr_count)
165 }
166
167 fn expand_cost(&self, _expand: &ExpandOp, cardinality: f64) -> Cost {
169 let lookup_cost = cardinality * self.hash_lookup_cost;
171 let avg_fanout = 10.0;
173 let output_cost = cardinality * avg_fanout * self.cpu_tuple_cost;
174 Cost::cpu(lookup_cost + output_cost)
175 }
176
177 fn join_cost(&self, join: &JoinOp, cardinality: f64) -> Cost {
179 match join.join_type {
181 JoinType::Cross => {
182 Cost::cpu(cardinality * self.cpu_tuple_cost)
184 }
185 JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full => {
186 let build_cardinality = cardinality.sqrt(); let probe_cardinality = cardinality.sqrt();
190
191 let build_cost = build_cardinality * self.hash_lookup_cost;
193 let memory_cost = build_cardinality * self.avg_tuple_size;
194
195 let probe_cost = probe_cardinality * self.hash_lookup_cost;
197
198 let output_cost = cardinality * self.cpu_tuple_cost;
200
201 Cost::cpu(build_cost + probe_cost + output_cost).with_memory(memory_cost)
202 }
203 JoinType::Semi | JoinType::Anti => {
204 let build_cardinality = cardinality.sqrt();
206 let probe_cardinality = cardinality.sqrt();
207
208 let build_cost = build_cardinality * self.hash_lookup_cost;
209 let probe_cost = probe_cardinality * self.hash_lookup_cost;
210
211 Cost::cpu(build_cost + probe_cost)
212 .with_memory(build_cardinality * self.avg_tuple_size)
213 }
214 }
215 }
216
217 fn aggregate_cost(&self, agg: &AggregateOp, cardinality: f64) -> Cost {
219 let hash_cost = cardinality * self.hash_lookup_cost;
221
222 let agg_count = agg.aggregates.len() as f64;
224 let agg_cost = cardinality * self.cpu_tuple_cost * agg_count;
225
226 let distinct_groups = (cardinality / 10.0).max(1.0); let memory_cost = distinct_groups * self.avg_tuple_size;
229
230 Cost::cpu(hash_cost + agg_cost).with_memory(memory_cost)
231 }
232
233 fn sort_cost(&self, sort: &SortOp, cardinality: f64) -> Cost {
235 if cardinality <= 1.0 {
236 return Cost::zero();
237 }
238
239 let comparisons = cardinality * cardinality.log2();
241 let key_count = sort.keys.len() as f64;
242
243 let memory_cost = cardinality * self.avg_tuple_size;
245
246 Cost::cpu(comparisons * self.sort_comparison_cost * key_count).with_memory(memory_cost)
247 }
248
249 fn distinct_cost(&self, _distinct: &DistinctOp, cardinality: f64) -> Cost {
251 let hash_cost = cardinality * self.hash_lookup_cost;
253 let memory_cost = cardinality * self.avg_tuple_size * 0.5; Cost::cpu(hash_cost).with_memory(memory_cost)
256 }
257
258 fn limit_cost(&self, limit: &LimitOp, _cardinality: f64) -> Cost {
260 Cost::cpu(limit.count as f64 * self.cpu_tuple_cost * 0.1)
262 }
263
264 fn skip_cost(&self, skip: &SkipOp, _cardinality: f64) -> Cost {
266 Cost::cpu(skip.count as f64 * self.cpu_tuple_cost)
268 }
269
270 fn return_cost(&self, ret: &ReturnOp, cardinality: f64) -> Cost {
272 let expr_count = ret.items.len() as f64;
274 Cost::cpu(cardinality * self.cpu_tuple_cost * expr_count)
275 }
276
277 fn vector_scan_cost(&self, scan: &VectorScanOp, cardinality: f64) -> Cost {
282 let k = scan.k as f64;
284
285 let ef = 64.0;
288 let n = cardinality.max(1.0);
289 let search_cost = if scan.index_name.is_some() {
290 ef * n.ln() * self.cpu_tuple_cost * 10.0 } else {
293 n * self.cpu_tuple_cost * 10.0
295 };
296
297 let memory = k * self.avg_tuple_size * 2.0;
299
300 Cost::cpu(search_cost).with_memory(memory)
301 }
302
303 fn vector_join_cost(&self, join: &VectorJoinOp, cardinality: f64) -> Cost {
307 let k = join.k as f64;
308
309 let per_row_search_cost = if join.index_name.is_some() {
312 let ef = 64.0;
314 let n = cardinality.max(1.0);
315 ef * n.ln() * self.cpu_tuple_cost * 10.0
316 } else {
317 cardinality * self.cpu_tuple_cost * 10.0
319 };
320
321 let input_cardinality = (cardinality / k).max(1.0);
324 let total_search_cost = input_cardinality * per_row_search_cost;
325
326 let memory = cardinality * self.avg_tuple_size;
328
329 Cost::cpu(total_search_cost).with_memory(memory)
330 }
331
332 #[must_use]
334 pub fn cheaper<'a>(&self, a: &'a Cost, b: &'a Cost) -> &'a Cost {
335 if a.total() <= b.total() { a } else { b }
336 }
337
338 #[must_use]
354 pub fn leapfrog_join_cost(
355 &self,
356 num_relations: usize,
357 cardinalities: &[f64],
358 output_cardinality: f64,
359 ) -> Cost {
360 if cardinalities.is_empty() {
361 return Cost::zero();
362 }
363
364 let total_input: f64 = cardinalities.iter().sum();
365 let min_card = cardinalities.iter().copied().fold(f64::INFINITY, f64::min);
366
367 let materialize_cost = total_input * self.cpu_tuple_cost * 2.0; let seek_cost = if min_card > 1.0 {
372 output_cardinality * (num_relations as f64) * min_card.log2() * self.hash_lookup_cost
373 } else {
374 output_cardinality * self.cpu_tuple_cost
375 };
376
377 let output_cost = output_cardinality * self.cpu_tuple_cost;
379
380 let memory = total_input * self.avg_tuple_size * 2.0;
382
383 Cost::cpu(materialize_cost + seek_cost + output_cost).with_memory(memory)
384 }
385
386 #[must_use]
390 pub fn prefer_leapfrog_join(
391 &self,
392 num_relations: usize,
393 cardinalities: &[f64],
394 output_cardinality: f64,
395 ) -> bool {
396 if num_relations < 3 || cardinalities.len() < 3 {
397 return false;
399 }
400
401 let leapfrog_cost =
402 self.leapfrog_join_cost(num_relations, cardinalities, output_cardinality);
403
404 let mut hash_cascade_cost = Cost::zero();
408 let mut intermediate_cardinality = cardinalities[0];
409
410 for card in &cardinalities[1..] {
411 let join_output = (intermediate_cardinality * card).sqrt(); let join = JoinOp {
414 left: Box::new(LogicalOperator::Empty),
415 right: Box::new(LogicalOperator::Empty),
416 join_type: JoinType::Inner,
417 conditions: vec![],
418 };
419 hash_cascade_cost += self.join_cost(&join, join_output);
420 intermediate_cardinality = join_output;
421 }
422
423 leapfrog_cost.total() < hash_cascade_cost.total()
424 }
425
426 #[must_use]
434 pub fn factorized_benefit(&self, avg_fanout: f64, num_hops: usize) -> f64 {
435 if num_hops <= 1 || avg_fanout <= 1.0 {
436 return 1.0; }
438
439 let full_size = avg_fanout.powi(num_hops as i32);
445 let factorized_size = if avg_fanout > 1.0 {
446 (avg_fanout.powi(num_hops as i32 + 1) - 1.0) / (avg_fanout - 1.0)
447 } else {
448 num_hops as f64
449 };
450
451 (factorized_size / full_size).min(1.0)
452 }
453}
454
455impl Default for CostModel {
456 fn default() -> Self {
457 Self::new()
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::query::plan::{
465 AggregateExpr, AggregateFunction, ExpandDirection, JoinCondition, LogicalExpression,
466 Projection, ReturnItem, SortOrder,
467 };
468
469 #[test]
470 fn test_cost_addition() {
471 let a = Cost::cpu(10.0).with_io(5.0);
472 let b = Cost::cpu(20.0).with_memory(100.0);
473 let c = a + b;
474
475 assert!((c.cpu - 30.0).abs() < 0.001);
476 assert!((c.io - 5.0).abs() < 0.001);
477 assert!((c.memory - 100.0).abs() < 0.001);
478 }
479
480 #[test]
481 fn test_cost_total() {
482 let cost = Cost::cpu(10.0).with_io(1.0).with_memory(100.0);
483 assert!((cost.total() - 30.0).abs() < 0.001);
485 }
486
487 #[test]
488 fn test_cost_model_node_scan() {
489 let model = CostModel::new();
490 let scan = NodeScanOp {
491 variable: "n".to_string(),
492 label: Some("Person".to_string()),
493 input: None,
494 };
495 let cost = model.node_scan_cost(&scan, 1000.0);
496
497 assert!(cost.cpu > 0.0);
498 assert!(cost.io > 0.0);
499 }
500
501 #[test]
502 fn test_cost_model_sort() {
503 let model = CostModel::new();
504 let sort = SortOp {
505 keys: vec![],
506 input: Box::new(LogicalOperator::Empty),
507 };
508
509 let cost_100 = model.sort_cost(&sort, 100.0);
510 let cost_1000 = model.sort_cost(&sort, 1000.0);
511
512 assert!(cost_1000.total() > cost_100.total());
514 }
515
516 #[test]
517 fn test_cost_zero() {
518 let cost = Cost::zero();
519 assert!((cost.cpu).abs() < 0.001);
520 assert!((cost.io).abs() < 0.001);
521 assert!((cost.memory).abs() < 0.001);
522 assert!((cost.network).abs() < 0.001);
523 assert!((cost.total()).abs() < 0.001);
524 }
525
526 #[test]
527 fn test_cost_add_assign() {
528 let mut cost = Cost::cpu(10.0);
529 cost += Cost::cpu(5.0).with_io(2.0);
530 assert!((cost.cpu - 15.0).abs() < 0.001);
531 assert!((cost.io - 2.0).abs() < 0.001);
532 }
533
534 #[test]
535 fn test_cost_total_weighted() {
536 let cost = Cost::cpu(10.0).with_io(2.0).with_memory(100.0);
537 let total = cost.total_weighted(2.0, 5.0, 0.5);
539 assert!((total - 80.0).abs() < 0.001);
540 }
541
542 #[test]
543 fn test_cost_model_filter() {
544 let model = CostModel::new();
545 let filter = FilterOp {
546 predicate: LogicalExpression::Literal(grafeo_common::types::Value::Bool(true)),
547 input: Box::new(LogicalOperator::Empty),
548 };
549 let cost = model.filter_cost(&filter, 1000.0);
550
551 assert!(cost.cpu > 0.0);
553 assert!((cost.io).abs() < 0.001);
554 }
555
556 #[test]
557 fn test_cost_model_project() {
558 let model = CostModel::new();
559 let project = ProjectOp {
560 projections: vec![
561 Projection {
562 expression: LogicalExpression::Variable("a".to_string()),
563 alias: None,
564 },
565 Projection {
566 expression: LogicalExpression::Variable("b".to_string()),
567 alias: None,
568 },
569 ],
570 input: Box::new(LogicalOperator::Empty),
571 };
572 let cost = model.project_cost(&project, 1000.0);
573
574 assert!(cost.cpu > 0.0);
576 }
577
578 #[test]
579 fn test_cost_model_expand() {
580 let model = CostModel::new();
581 let expand = ExpandOp {
582 from_variable: "a".to_string(),
583 to_variable: "b".to_string(),
584 edge_variable: None,
585 direction: ExpandDirection::Outgoing,
586 edge_type: None,
587 min_hops: 1,
588 max_hops: Some(1),
589 input: Box::new(LogicalOperator::Empty),
590 path_alias: None,
591 };
592 let cost = model.expand_cost(&expand, 1000.0);
593
594 assert!(cost.cpu > 0.0);
596 }
597
598 #[test]
599 fn test_cost_model_hash_join() {
600 let model = CostModel::new();
601 let join = JoinOp {
602 left: Box::new(LogicalOperator::Empty),
603 right: Box::new(LogicalOperator::Empty),
604 join_type: JoinType::Inner,
605 conditions: vec![JoinCondition {
606 left: LogicalExpression::Variable("a".to_string()),
607 right: LogicalExpression::Variable("b".to_string()),
608 }],
609 };
610 let cost = model.join_cost(&join, 10000.0);
611
612 assert!(cost.cpu > 0.0);
614 assert!(cost.memory > 0.0);
615 }
616
617 #[test]
618 fn test_cost_model_cross_join() {
619 let model = CostModel::new();
620 let join = JoinOp {
621 left: Box::new(LogicalOperator::Empty),
622 right: Box::new(LogicalOperator::Empty),
623 join_type: JoinType::Cross,
624 conditions: vec![],
625 };
626 let cost = model.join_cost(&join, 1000000.0);
627
628 assert!(cost.cpu > 0.0);
630 }
631
632 #[test]
633 fn test_cost_model_semi_join() {
634 let model = CostModel::new();
635 let join = JoinOp {
636 left: Box::new(LogicalOperator::Empty),
637 right: Box::new(LogicalOperator::Empty),
638 join_type: JoinType::Semi,
639 conditions: vec![],
640 };
641 let cost_semi = model.join_cost(&join, 1000.0);
642
643 let inner_join = JoinOp {
644 left: Box::new(LogicalOperator::Empty),
645 right: Box::new(LogicalOperator::Empty),
646 join_type: JoinType::Inner,
647 conditions: vec![],
648 };
649 let cost_inner = model.join_cost(&inner_join, 1000.0);
650
651 assert!(cost_semi.cpu > 0.0);
653 assert!(cost_inner.cpu > 0.0);
654 }
655
656 #[test]
657 fn test_cost_model_aggregate() {
658 let model = CostModel::new();
659 let agg = AggregateOp {
660 group_by: vec![],
661 aggregates: vec![
662 AggregateExpr {
663 function: AggregateFunction::Count,
664 expression: None,
665 distinct: false,
666 alias: Some("cnt".to_string()),
667 percentile: None,
668 },
669 AggregateExpr {
670 function: AggregateFunction::Sum,
671 expression: Some(LogicalExpression::Variable("x".to_string())),
672 distinct: false,
673 alias: Some("total".to_string()),
674 percentile: None,
675 },
676 ],
677 input: Box::new(LogicalOperator::Empty),
678 having: None,
679 };
680 let cost = model.aggregate_cost(&agg, 1000.0);
681
682 assert!(cost.cpu > 0.0);
684 assert!(cost.memory > 0.0);
685 }
686
687 #[test]
688 fn test_cost_model_distinct() {
689 let model = CostModel::new();
690 let distinct = DistinctOp {
691 input: Box::new(LogicalOperator::Empty),
692 columns: None,
693 };
694 let cost = model.distinct_cost(&distinct, 1000.0);
695
696 assert!(cost.cpu > 0.0);
698 assert!(cost.memory > 0.0);
699 }
700
701 #[test]
702 fn test_cost_model_limit() {
703 let model = CostModel::new();
704 let limit = LimitOp {
705 count: 10,
706 input: Box::new(LogicalOperator::Empty),
707 };
708 let cost = model.limit_cost(&limit, 1000.0);
709
710 assert!(cost.cpu > 0.0);
712 assert!(cost.cpu < 1.0); }
714
715 #[test]
716 fn test_cost_model_skip() {
717 let model = CostModel::new();
718 let skip = SkipOp {
719 count: 100,
720 input: Box::new(LogicalOperator::Empty),
721 };
722 let cost = model.skip_cost(&skip, 1000.0);
723
724 assert!(cost.cpu > 0.0);
726 }
727
728 #[test]
729 fn test_cost_model_return() {
730 let model = CostModel::new();
731 let ret = ReturnOp {
732 items: vec![
733 ReturnItem {
734 expression: LogicalExpression::Variable("a".to_string()),
735 alias: None,
736 },
737 ReturnItem {
738 expression: LogicalExpression::Variable("b".to_string()),
739 alias: None,
740 },
741 ],
742 distinct: false,
743 input: Box::new(LogicalOperator::Empty),
744 };
745 let cost = model.return_cost(&ret, 1000.0);
746
747 assert!(cost.cpu > 0.0);
749 }
750
751 #[test]
752 fn test_cost_cheaper() {
753 let model = CostModel::new();
754 let cheap = Cost::cpu(10.0);
755 let expensive = Cost::cpu(100.0);
756
757 assert_eq!(model.cheaper(&cheap, &expensive).total(), cheap.total());
758 assert_eq!(model.cheaper(&expensive, &cheap).total(), cheap.total());
759 }
760
761 #[test]
762 fn test_cost_comparison_prefers_lower_total() {
763 let model = CostModel::new();
764 let cpu_heavy = Cost::cpu(100.0).with_io(1.0);
766 let io_heavy = Cost::cpu(10.0).with_io(20.0);
768
769 assert!(cpu_heavy.total() < io_heavy.total());
771 assert_eq!(
772 model.cheaper(&cpu_heavy, &io_heavy).total(),
773 cpu_heavy.total()
774 );
775 }
776
777 #[test]
778 fn test_cost_model_sort_with_keys() {
779 let model = CostModel::new();
780 let sort_single = SortOp {
781 keys: vec![crate::query::plan::SortKey {
782 expression: LogicalExpression::Variable("a".to_string()),
783 order: SortOrder::Ascending,
784 }],
785 input: Box::new(LogicalOperator::Empty),
786 };
787 let sort_multi = SortOp {
788 keys: vec![
789 crate::query::plan::SortKey {
790 expression: LogicalExpression::Variable("a".to_string()),
791 order: SortOrder::Ascending,
792 },
793 crate::query::plan::SortKey {
794 expression: LogicalExpression::Variable("b".to_string()),
795 order: SortOrder::Descending,
796 },
797 ],
798 input: Box::new(LogicalOperator::Empty),
799 };
800
801 let cost_single = model.sort_cost(&sort_single, 1000.0);
802 let cost_multi = model.sort_cost(&sort_multi, 1000.0);
803
804 assert!(cost_multi.cpu > cost_single.cpu);
806 }
807
808 #[test]
809 fn test_cost_model_empty_operator() {
810 let model = CostModel::new();
811 let cost = model.estimate(&LogicalOperator::Empty, 0.0);
812 assert!((cost.total()).abs() < 0.001);
813 }
814
815 #[test]
816 fn test_cost_model_default() {
817 let model = CostModel::default();
818 let scan = NodeScanOp {
819 variable: "n".to_string(),
820 label: None,
821 input: None,
822 };
823 let cost = model.estimate(&LogicalOperator::NodeScan(scan), 100.0);
824 assert!(cost.total() > 0.0);
825 }
826
827 #[test]
828 fn test_leapfrog_join_cost() {
829 let model = CostModel::new();
830
831 let cardinalities = vec![1000.0, 1000.0, 1000.0];
833 let cost = model.leapfrog_join_cost(3, &cardinalities, 100.0);
834
835 assert!(cost.cpu > 0.0);
837 assert!(cost.memory > 0.0);
839 }
840
841 #[test]
842 fn test_leapfrog_join_cost_empty() {
843 let model = CostModel::new();
844 let cost = model.leapfrog_join_cost(0, &[], 0.0);
845 assert!((cost.total()).abs() < 0.001);
846 }
847
848 #[test]
849 fn test_prefer_leapfrog_join_for_triangles() {
850 let model = CostModel::new();
851
852 let cardinalities = vec![10000.0, 10000.0, 10000.0];
854 let output = 1000.0;
855
856 let leapfrog_cost = model.leapfrog_join_cost(3, &cardinalities, output);
857
858 assert!(leapfrog_cost.cpu > 0.0);
860 assert!(leapfrog_cost.memory > 0.0);
861
862 let _prefer = model.prefer_leapfrog_join(3, &cardinalities, output);
865 }
867
868 #[test]
869 fn test_prefer_leapfrog_join_binary_case() {
870 let model = CostModel::new();
871
872 let cardinalities = vec![1000.0, 1000.0];
874 let prefer = model.prefer_leapfrog_join(2, &cardinalities, 500.0);
875 assert!(!prefer, "Binary joins should use hash join, not leapfrog");
876 }
877
878 #[test]
879 fn test_factorized_benefit_single_hop() {
880 let model = CostModel::new();
881
882 let benefit = model.factorized_benefit(10.0, 1);
884 assert!(
885 (benefit - 1.0).abs() < 0.001,
886 "Single hop should have no benefit"
887 );
888 }
889
890 #[test]
891 fn test_factorized_benefit_multi_hop() {
892 let model = CostModel::new();
893
894 let benefit = model.factorized_benefit(10.0, 3);
896
897 assert!(benefit <= 1.0, "Benefit should be <= 1.0");
901 assert!(benefit > 0.0, "Benefit should be positive");
902 }
903
904 #[test]
905 fn test_factorized_benefit_low_fanout() {
906 let model = CostModel::new();
907
908 let benefit = model.factorized_benefit(1.5, 2);
910 assert!(
911 benefit <= 1.0,
912 "Low fanout still benefits from factorization"
913 );
914 }
915}