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
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    /// Estimate the cost of a subplan against `stats`.
286    #[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                // Negated edge patterns aren't represented in the Rust
439                // IR yet (`EdgePatternIR` carries no `negated` flag).
440                // Use the positive-pattern base cost until the IR can carry a
441                // negated-edge flag and its additional per-edge cost.
442                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                // Path-indexable expressions (Concat-of-Labels) are
457                // cheap. Falling back to the full RPQ cost otherwise.
458                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            // The plan IR does not yet carry maximum iterations, so PageRank
479            // and HITS use a default of 20. Accurate per-query costing requires
480            // extending these `OperatorTree` variants.
481            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
596/// Approximate the `Concat(Label, Concat(Label, ...))` shape used by
597/// path-indexable RPQs. Because the plan stores the source string, reject
598/// quantifiers (`*`, `+`, `?`) and alternation (`|`).
599fn is_label_chain(source: &str) -> bool {
600    !source.contains('*')
601        && !source.contains('+')
602        && !source.contains('?')
603        && !source.contains('|')
604        && !source.contains('{')
605}
606
607/// Count label-bearing terms in a raw RPQ source string. Falls back to
608/// alphanumeric token counting plus quantifier weighting, so empty or
609/// unparseable inputs degrade to 1.
610fn 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        // Symmetric: the model swaps build/probe internally.
639        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}