Skip to main content

uqa_planner/
cost_model.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Per-operator cost model.
8//!
9//! The cost is unitless: relative numbers across plans are what
10//! matters. We use the System-R style breakdown of `cpu_cost +
11//! io_cost + memory_cost`, scaled by per-operator constants.
12
13#[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/// Coefficients tuned against the workspace benchmark corpus. They are stable
63/// enough for deterministic join-shape choices in optimizer regression tests.
64#[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    /// Cost of materializing `rows` rows from a unary physical operator.
122    /// Join operators use [`Self::estimate_join`] so their two inputs remain
123    /// explicit.
124    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    /// Cost of joining `left_rows` to `right_rows` via `kind`. For
174    /// hash joins, the smaller side is assumed to be the build side
175    /// (the enumerator decides which one when it constructs the
176    /// node).
177    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
232// ---------------------------------------------------------------
233// Algebraic-tree cost model constants and estimator.
234// ---------------------------------------------------------------
235
236use std::collections::BTreeMap;
237
238use uqa_core::IndexStats;
239use uqa_operators::{DeepFusionLayer, OperatorTree};
240
241use crate::cardinality::{ColumnStats, GraphStats};
242
243/// Multiplier for score-producing operators.
244pub const SCORE_OVERHEAD_FACTOR: f64 = 1.1;
245/// Multiplier for grouping overhead.
246pub const GROUP_BY_OVERHEAD_FACTOR: f64 = 1.5;
247/// Fractional cost assigned to vertex aggregation.
248pub const VERTEX_AGG_FRACTION: f64 = 0.2;
249/// Fractional cost assigned to graph traversal.
250pub const TRAVERSE_FRACTION: f64 = 0.1;
251
252/// Algebraic operator-tree cost model. Produces a unitless cost for
253/// each [`OperatorTree`] node so the query optimiser's
254/// `reorder_intersect` pass can pick the lowest estimated join order.
255#[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    /// Estimate the cost of a subplan against `stats`.
283    #[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                // Negated edge patterns aren't represented in the Rust
409                // IR yet (`EdgePatternIR` carries no `negated` flag).
410                // Use the positive-pattern base cost until the IR can carry a
411                // negated-edge flag and its additional per-edge cost.
412                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                // Path-indexable expressions (Concat-of-Labels) are
427                // cheap. Falling back to the full RPQ cost otherwise.
428                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            // The plan IR does not yet carry maximum iterations, so PageRank
449            // and HITS use a default of 20. Accurate per-query costing requires
450            // extending these `OperatorTree` variants.
451            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
566/// Approximate the `Concat(Label, Concat(Label, ...))` shape used by
567/// path-indexable RPQs. Because the plan stores the source string, reject
568/// quantifiers (`*`, `+`, `?`) and alternation (`|`).
569fn is_label_chain(source: &str) -> bool {
570    !source.contains('*')
571        && !source.contains('+')
572        && !source.contains('?')
573        && !source.contains('|')
574        && !source.contains('{')
575}
576
577/// Count label-bearing terms in a raw RPQ source string. Falls back to
578/// alphanumeric token counting plus quantifier weighting, so empty or
579/// unparseable inputs degrade to 1.
580fn 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        // Symmetric: the model swaps build/probe internally.
609        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}