Skip to main content

uqa_operators/
tree.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Operator tree IR for the planner.
8//!
9//! Every concrete logical operator is represented in one [`OperatorTree`]
10//! enum so the optimizer can traverse and rewrite the tree with exhaustive
11//! pattern matching.
12//!
13//! The enum is *additive* over the existing trait-object operators:
14//! the engine still composes operators through `Arc<dyn Operator>` at
15//! runtime, but the planner pre-rewrites an [`OperatorTree`] before
16//! handing it to the executor.
17
18use std::collections::BTreeMap;
19use std::sync::Arc;
20
21use uqa_core::{Predicate, Value};
22
23use crate::aggregation::AggregationMonoid;
24use crate::base::Direction;
25
26/// Reference to a scorer used by a `Score` node. The optimizer only
27/// inspects the field/query-terms of the score node; the scorer is
28/// passed through opaquely.
29pub type ScorerRef = Arc<dyn uqa_scoring::Scorer>;
30
31/// Reference to an attention fusion model.
32pub type AttentionRef = Arc<dyn AttentionFuserDyn>;
33
34/// Reference to a learned fusion model.
35pub type LearnedFusionRef = Arc<dyn LearnedFuserDyn>;
36
37/// Function pointer for a vertex predicate (used by graph traverse).
38pub type VertexPredicate = Arc<dyn Fn(&uqa_core::Vertex) -> bool + Send + Sync>;
39
40/// Predicate over the accumulated numeric weight of a matching regular path.
41pub type PathWeightPredicate = Arc<dyn Fn(f64) -> bool + Send + Sync>;
42
43/// Function pointer for a vertex constraint (used by pattern match).
44pub type VertexConstraint = Arc<dyn Fn(&uqa_core::Vertex) -> bool + Send + Sync>;
45
46/// Function pointer for an edge constraint (used by pattern match).
47pub type EdgeConstraint = Arc<dyn Fn(&uqa_core::Edge) -> bool + Send + Sync>;
48
49pub trait AttentionFuserDyn: Send + Sync {
50    fn validate_inputs(
51        &self,
52        signal_count: usize,
53        query_feature_count: usize,
54    ) -> Result<(), &'static str>;
55    fn fuse(&self, probs: &[f64], query_features: &[f64]) -> Result<f64, &'static str>;
56
57    fn fuse_batch(
58        &self,
59        probabilities: &[Vec<f64>],
60        query_features: &[f64],
61    ) -> Result<Vec<f64>, &'static str> {
62        probabilities
63            .iter()
64            .map(|sample| self.fuse(sample, query_features))
65            .collect()
66    }
67
68    /// Number of independently trained attention heads represented by this
69    /// physical fuser. Exposed as immutable IR metadata for explain/testing.
70    fn head_count(&self) -> usize;
71    fn normalize(&self) -> bool;
72    fn alpha(&self) -> f64;
73    fn base_rate(&self) -> Option<f64>;
74}
75
76pub trait LearnedFuserDyn: Send + Sync {
77    fn validate_inputs(&self, signal_count: usize) -> Result<(), &'static str>;
78    fn fuse(&self, probs: &[f64]) -> Result<f64, &'static str>;
79}
80
81impl AttentionFuserDyn for uqa_fusion::AttentionFusion {
82    fn validate_inputs(
83        &self,
84        signal_count: usize,
85        query_feature_count: usize,
86    ) -> Result<(), &'static str> {
87        uqa_fusion::AttentionFusion::validate_inputs(self, signal_count, query_feature_count)
88    }
89
90    fn fuse(&self, probs: &[f64], query_features: &[f64]) -> Result<f64, &'static str> {
91        uqa_fusion::AttentionFusion::fuse(self, probs, query_features)
92    }
93
94    fn fuse_batch(
95        &self,
96        probabilities: &[Vec<f64>],
97        query_features: &[f64],
98    ) -> Result<Vec<f64>, &'static str> {
99        uqa_fusion::AttentionFusion::fuse_batch(self, probabilities, query_features)
100    }
101
102    fn head_count(&self) -> usize {
103        1
104    }
105
106    fn normalize(&self) -> bool {
107        self.normalize
108    }
109
110    fn alpha(&self) -> f64 {
111        self.alpha
112    }
113
114    fn base_rate(&self) -> Option<f64> {
115        self.base_rate
116    }
117}
118
119impl AttentionFuserDyn for uqa_fusion::MultiHeadAttentionFusion {
120    fn validate_inputs(
121        &self,
122        signal_count: usize,
123        query_feature_count: usize,
124    ) -> Result<(), &'static str> {
125        uqa_fusion::MultiHeadAttentionFusion::validate_inputs(
126            self,
127            signal_count,
128            query_feature_count,
129        )
130    }
131
132    fn fuse(&self, probs: &[f64], query_features: &[f64]) -> Result<f64, &'static str> {
133        uqa_fusion::MultiHeadAttentionFusion::fuse(self, probs, query_features)
134    }
135
136    fn fuse_batch(
137        &self,
138        probabilities: &[Vec<f64>],
139        query_features: &[f64],
140    ) -> Result<Vec<f64>, &'static str> {
141        uqa_fusion::MultiHeadAttentionFusion::fuse_batch(self, probabilities, query_features)
142    }
143
144    fn head_count(&self) -> usize {
145        uqa_fusion::MultiHeadAttentionFusion::n_heads(self)
146    }
147
148    fn normalize(&self) -> bool {
149        uqa_fusion::MultiHeadAttentionFusion::normalize(self)
150    }
151
152    fn alpha(&self) -> f64 {
153        uqa_fusion::MultiHeadAttentionFusion::alpha(self).unwrap_or(f64::NAN)
154    }
155
156    fn base_rate(&self) -> Option<f64> {
157        None
158    }
159}
160
161impl LearnedFuserDyn for uqa_fusion::LearnedFusion {
162    fn validate_inputs(&self, signal_count: usize) -> Result<(), &'static str> {
163        uqa_fusion::LearnedFusion::validate_inputs(self, signal_count)
164    }
165
166    fn fuse(&self, probs: &[f64]) -> Result<f64, &'static str> {
167        uqa_fusion::LearnedFusion::fuse(self, probs)
168    }
169}
170
171/// Single vertex pattern (variable name + accumulated constraints).
172#[derive(Clone)]
173pub struct VertexPatternIR {
174    pub variable: String,
175    pub constraints: Vec<VertexConstraint>,
176    /// Optional label filter; when `None` any vertex matches.
177    pub label: Option<String>,
178}
179
180impl std::fmt::Debug for VertexPatternIR {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("VertexPatternIR")
183            .field("variable", &self.variable)
184            .field("constraints_count", &self.constraints.len())
185            .field("label", &self.label)
186            .finish()
187    }
188}
189
190#[derive(Clone)]
191pub struct EdgePatternIR {
192    pub source_var: String,
193    pub target_var: String,
194    pub label: Option<String>,
195    pub constraints: Vec<EdgeConstraint>,
196}
197
198impl std::fmt::Debug for EdgePatternIR {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("EdgePatternIR")
201            .field("source_var", &self.source_var)
202            .field("target_var", &self.target_var)
203            .field("label", &self.label)
204            .field("constraints_count", &self.constraints.len())
205            .finish()
206    }
207}
208
209#[derive(Clone, Debug)]
210pub struct GraphPatternIR {
211    pub vertex_patterns: Vec<VertexPatternIR>,
212    pub edge_patterns: Vec<EdgePatternIR>,
213}
214
215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub enum ProbBoolMode {
217    And,
218    Or,
219}
220
221#[derive(Clone, Debug)]
222pub enum GatingSpec {
223    /// Lucene-compatible softplus gating.
224    Softplus,
225    /// Raw signal score scales the fused logit.
226    Pass,
227    /// Sigmoid gating with the named feature.
228    Sigmoid { feature: String },
229    /// `ReLU` gate.
230    ReLU,
231    /// Swish gate.
232    Swish,
233    /// GELU gate.
234    Gelu,
235}
236
237/// Neighborhood reduction used by a graph-aware deep-fusion propagation
238/// layer. This lives in the algebra crate so the IR does not depend on the ML
239/// runtime crate.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum DeepFusionAggregation {
242    Mean,
243    Sum,
244    Max,
245}
246
247/// Element-wise reduction used by a graph-aware deep-fusion pooling layer.
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
249pub enum DeepFusionPoolMethod {
250    Average,
251    Max,
252}
253
254/// Text scoring algorithm used by [`OperatorTree::Term`].
255#[derive(Clone, Copy, Debug, PartialEq)]
256pub enum TextScoringMode {
257    BM25,
258    BayesianBM25,
259    /// Explicit parameters supplied through the public engine API.
260    CustomBM25(uqa_scoring::BM25Params),
261    /// Explicit Bayesian calibration supplied through the public engine API.
262    CustomBayesianBM25(uqa_scoring::BayesianBM25Params),
263}
264
265/// Exact physical top-k algorithm selected for a text-retrieval leaf.
266///
267/// The logical [`OperatorTree::Term`] remains usable without a limit.  Once a
268/// planner proves that a score-ordered limit can be pushed into that leaf it
269/// attaches one of these strategies through [`TextTopKPlan`].
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub enum TextTopKStrategy {
272    /// Document-at-a-time WAND with scorer-provided term upper bounds.
273    Wand,
274    /// Block-Max WAND with persisted, scorer-versioned block bounds.
275    BlockMaxWand,
276}
277
278/// Physical score-limit pushed into a text leaf.
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub struct TextTopKPlan {
281    pub k: usize,
282    pub strategy: TextTopKStrategy,
283}
284
285/// External document prior used by [`OperatorTree::BayesianMatchWithPrior`].
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287pub enum ExternalPriorMode {
288    Authority,
289    Recency,
290}
291
292/// Concrete logical operator tree used by planning and rewrite passes.
293#[derive(Clone)]
294pub enum OperatorTree {
295    /// Empty leaf (no input). The optimizer treats `Intersect([])` and
296    /// `Union([])` as empty when checking absorption rules.
297    Empty,
298
299    /// `TermOperator(query_string, field)` -- text retrieval primitive.
300    Term {
301        query: String,
302        field: Option<String>,
303        /// Bound by SQL function lowering when a caller explicitly
304        /// chooses `text_match` or `bayesian_match`. Query-string
305        /// parsers leave this unset so they stay syntax-only.
306        scoring: Option<TextScoringMode>,
307        /// Physical score-limit selected after logical lowering. `None`
308        /// preserves the exhaustive posting-list carrier required by Boolean
309        /// and fusion parents.
310        top_k: Option<TextTopKPlan>,
311    },
312    /// `FilterOperator(field, predicate, source)`.
313    Filter {
314        field: String,
315        predicate: Predicate,
316        source: Option<Box<OperatorTree>>,
317    },
318    /// `FacetOperator(field, source)`.
319    Facet {
320        field: String,
321        source: Option<Box<OperatorTree>>,
322    },
323    /// `ScoreOperator(scorer, source, query_terms, field)`.
324    Score {
325        scorer: ScorerRef,
326        source: Box<OperatorTree>,
327        query_terms: Vec<String>,
328        field: String,
329    },
330    /// Lucene-style `BayesianScoreQuery(source)`. The source produces one
331    /// complete raw BM25 query score per matching document, and the wrapper
332    /// applies the persisted field calibration exactly once.
333    BayesianScore {
334        source: Box<OperatorTree>,
335        field: Option<String>,
336    },
337    /// Bayesian text retrieval combined with a document authority or recency
338    /// prior stored in another field.
339    BayesianMatchWithPrior {
340        field: String,
341        query: String,
342        prior_field: String,
343        mode: ExternalPriorMode,
344    },
345
346    /// `IntersectOperator([...])`.
347    Intersect(Vec<OperatorTree>),
348    /// `UnionOperator([...])`.
349    Union(Vec<OperatorTree>),
350    /// `ComplementOperator(operand)`.
351    Complement(Box<OperatorTree>),
352    /// `ComposedOperator([...])`.
353    Composed(Vec<OperatorTree>),
354    /// Explicitly encode a graph posting carrier into an ordinary posting
355    /// carrier with the versioned Phi codec. SQL document predicates insert
356    /// this boundary before combining graph results with relational results;
357    /// graph-to-graph set algebra remains on `GraphPostingList`.
358    EncodeGraphPosting { source: Box<OperatorTree> },
359
360    /// `VectorSimilarityOperator(query_vector, threshold, field)`.
361    VectorSimilarity {
362        query_vector: Vec<f32>,
363        threshold: f32,
364        field: String,
365    },
366    /// `KNNOperator(query_vector, k, field)`.
367    KNN {
368        query_vector: Vec<f32>,
369        k: usize,
370        field: String,
371    },
372    /// Query-pool vector score transform exposed by the compatibility SQL
373    /// name `calibrated_vector_match`. This variant does not claim held-out
374    /// probability calibration.
375    CalibratedVectorMatch {
376        query_vector: Vec<f32>,
377        k: usize,
378        field: String,
379        threshold: Option<f64>,
380    },
381    /// `CosineProbabilityOperator(source)` -- wraps a KNN child with a
382    /// unit-interval score projection. This is monotone, not empirically
383    /// calibrated.
384    CosineProbability(Box<OperatorTree>),
385
386    /// Exact signed-evidence Bayesian fusion. `base_rate = None` derives one
387    /// prior from signal metadata and otherwise falls back to the neutral 0.5.
388    BayesianEvidenceFusion {
389        signals: Vec<OperatorTree>,
390        base_rate: Option<f64>,
391    },
392    /// Robust positive-evidence retrieval pool with optional weights and logit
393    /// normalization. This variant makes no calibration theorem claim.
394    RobustPositiveEvidencePool {
395        signals: Vec<OperatorTree>,
396        alpha: f64,
397        gating: GatingSpec,
398        weights: Option<Vec<f64>>,
399        logit_min: Option<Vec<f64>>,
400        logit_max: Option<Vec<f64>>,
401        /// Derive weights from the score spread of this invocation.
402        adaptive_weights: bool,
403    },
404    /// `ProbBoolFusionOperator(signals, mode)`.
405    ProbBoolFusion {
406        signals: Vec<OperatorTree>,
407        mode: ProbBoolMode,
408    },
409    /// `ProbNotOperator(signal, default_prob)`.
410    ProbNot {
411        signal: Box<OperatorTree>,
412        default_prob: f64,
413    },
414    /// `AttentionFusionOperator(signals, attention, query_features)`.
415    AttentionFusion {
416        signals: Vec<OperatorTree>,
417        attention: AttentionRef,
418        query_features: Vec<f64>,
419    },
420    /// `LearnedFusionOperator(signals, learned)`.
421    LearnedFusion {
422        signals: Vec<OperatorTree>,
423        learned: LearnedFusionRef,
424    },
425    /// `SparseThresholdOperator(source, threshold)`.
426    SparseThreshold {
427        source: Box<OperatorTree>,
428        threshold: f64,
429    },
430
431    /// `TraverseOperator(start, graph, label, max_hops, vertex_predicate)`.
432    Traverse {
433        start_vertex: u64,
434        graph: String,
435        label: Option<String>,
436        max_hops: usize,
437        vertex_predicate: Option<VertexPredicate>,
438    },
439    /// One-hop graph neighborhood without including the start vertex.
440    /// This is separate from `Traverse(max_hops=1)` because SQL
441    /// `graph_neighbors` also carries an explicit edge direction and has
442    /// different start-vertex semantics.
443    GraphNeighbors {
444        vertex: u64,
445        graph: String,
446        label: Option<String>,
447        direction: Direction,
448    },
449    /// Emit graph edges as posting entries keyed by edge id. The payload
450    /// score carries the optional numeric edge weight.
451    GraphEdges {
452        graph: String,
453        label: Option<String>,
454    },
455    /// `PatternMatchOperator(pattern, graph)`.
456    PatternMatch {
457        pattern: GraphPatternIR,
458        graph: String,
459    },
460    /// `RegularPathQueryOperator(expr, start, graph)`.
461    RegularPathQuery {
462        rpq_source: String,
463        start_vertex: u64,
464        graph: String,
465    },
466    /// `GraphJoinOperator(left, right, label, graph)`.
467    GraphJoin {
468        left: Box<OperatorTree>,
469        right: Box<OperatorTree>,
470        label: Option<String>,
471        graph: String,
472    },
473
474    /// `IndexScanOperator(index, field, predicate)` -- selected by the
475    /// optimizer when a covering index is cheaper than a full scan.
476    IndexScan {
477        index_name: String,
478        field: String,
479        predicate: Predicate,
480    },
481
482    /// `AggregateOperator(source, field, monoid)`.
483    Aggregate {
484        source: Option<Box<OperatorTree>>,
485        field: String,
486        monoid: Arc<dyn AggregationMonoid>,
487    },
488    /// `GroupByOperator(source, group_field, agg_field, monoid)`.
489    GroupBy {
490        source: Box<OperatorTree>,
491        group_field: String,
492        agg_field: String,
493        monoid: Arc<dyn AggregationMonoid>,
494    },
495
496    // -----------------------------------------------------------------
497    // Cross-paradigm operators.
498    // -----------------------------------------------------------------
499    /// `MultiStageOperator(stages=[(child, cutoff), ...])`. The cutoff
500    /// determines the cardinality at the final stage.
501    MultiStage { stages: Vec<MultiStageEntry> },
502    /// `MultiFieldSearchOperator(fields, queries, weights)`.
503    MultiFieldSearch {
504        fields: Vec<String>,
505        queries: Vec<String>,
506        weights: Option<Vec<f64>>,
507    },
508    /// `HybridTextVectorOperator(term_op, vector_op, alpha)`.
509    HybridTextVector {
510        term_op: Box<OperatorTree>,
511        vector_op: Box<OperatorTree>,
512        alpha: f64,
513    },
514    /// `SemanticFilterOperator(source, vector_op)`.
515    SemanticFilter {
516        source: Box<OperatorTree>,
517        vector_op: Box<OperatorTree>,
518    },
519    /// `VectorExclusionOperator(positive, negative_op)`.
520    VectorExclusion {
521        positive: Box<OperatorTree>,
522        negative: Box<OperatorTree>,
523    },
524    /// `FacetVectorOperator(vector_op, facet_field)`.
525    FacetVector {
526        vector_op: Box<OperatorTree>,
527        facet_field: String,
528    },
529    /// `VertexAggregationOperator(source, monoid)` -- single-row result.
530    VertexAggregation {
531        source: Box<OperatorTree>,
532        monoid: Arc<dyn AggregationMonoid>,
533    },
534    /// A bounded regular-path walk filtered by its accumulated edge weight.
535    /// `predicate_selectivity` is a planner estimate only; the physical
536    /// predicate itself is always preserved in `predicate`.
537    WeightedPathQuery {
538        rpq_source: String,
539        start_vertex: u64,
540        graph: String,
541        weight_property: String,
542        default_edge_weight: f64,
543        max_hops: usize,
544        predicate: PathWeightPredicate,
545        predicate_selectivity: f64,
546        score: f64,
547    },
548    /// `MessagePassingOperator(source, ...)` -- pass-through cardinality.
549    MessagePassing { source: Box<OperatorTree> },
550    /// `GraphEmbeddingOperator(source, ...)` -- pass-through cardinality.
551    GraphEmbedding { source: Box<OperatorTree> },
552    /// `PageRankOperator(graph)` -- one score per vertex.
553    PageRank { graph: String },
554    /// `HITSOperator(graph)` -- one score per vertex.
555    HITS { graph: String },
556    /// `BetweennessCentralityOperator(graph)` -- one score per vertex.
557    BetweennessCentrality { graph: String },
558    /// `TextSimilarityJoinOperator(left, right, threshold)`.
559    TextSimilarityJoin {
560        left: Box<OperatorTree>,
561        right: Box<OperatorTree>,
562        threshold: f64,
563    },
564    /// `VectorSimilarityJoinOperator(left, right, threshold)`.
565    VectorSimilarityJoin {
566        left: Box<OperatorTree>,
567        right: Box<OperatorTree>,
568        threshold: f64,
569    },
570    /// `HybridJoinOperator(left, right)`.
571    HybridJoin {
572        left: Box<OperatorTree>,
573        right: Box<OperatorTree>,
574    },
575    /// `CrossParadigmJoinOperator(left, right)`. Distinct from
576    /// [`OperatorTree::GraphJoin`]: it joins arbitrary operands via a
577    /// graph traversal step but does not carry an edge label.
578    CrossParadigmJoin {
579        left: Box<OperatorTree>,
580        right: Box<OperatorTree>,
581    },
582    /// `TemporalTraverseOperator(start, graph, label, hops, filter)`.
583    TemporalTraverse {
584        start_vertex: u64,
585        graph: String,
586        label: Option<String>,
587        max_hops: usize,
588        temporal_filter: Option<TemporalFilterIR>,
589    },
590    /// `TemporalPatternMatchOperator(pattern, graph, filter)`.
591    TemporalPatternMatch {
592        pattern: GraphPatternIR,
593        graph: String,
594        temporal_filter: Option<TemporalFilterIR>,
595    },
596    /// `ProgressiveFusionOperator(stages=[(signal, k), ...], alpha, gating)`.
597    /// The final stage `k` determines the result cardinality.
598    ProgressiveFusion {
599        stages: Vec<ProgressiveFusionEntry>,
600        alpha: f64,
601        gating: GatingSpec,
602    },
603    /// `DeepFusionOperator(layers, alpha, gating)`.
604    DeepFusion {
605        layers: Vec<DeepFusionLayer>,
606        alpha: f64,
607        gating: GatingSpec,
608    },
609
610    /// Execute a registered deep model and emit its document scores.
611    DeepPredict { model: String },
612
613    /// Catch-all for opaque operators the optimizer should not rewrite.
614    Opaque {
615        kind: String,
616        children: Vec<OperatorTree>,
617        meta: BTreeMap<String, Value>,
618    },
619}
620
621/// A single entry in an [`OperatorTree::MultiStage`] cascade, pairing a child
622/// with either a fixed top-k or fractional cutoff.
623#[derive(Clone)]
624pub struct MultiStageEntry {
625    pub child: OperatorTree,
626    pub cutoff: MultiStageCutoff,
627}
628
629/// Candidate cutoff for one stage of a multi-stage cascade.
630#[derive(Clone, Copy, Debug, PartialEq)]
631pub enum MultiStageCutoff {
632    /// Top-K results -- final cardinality is `k`.
633    TopK(usize),
634    /// Fractional cutoff -- final cardinality is `n * ratio`.
635    Ratio(f64),
636}
637
638/// One stage of a [`OperatorTree::ProgressiveFusion`].
639#[derive(Clone)]
640pub struct ProgressiveFusionEntry {
641    pub signal: OperatorTree,
642    pub k: usize,
643}
644
645/// Layer in a [`OperatorTree::DeepFusion`] pipeline.
646#[derive(Clone)]
647pub enum DeepFusionLayer {
648    Signal {
649        signals: Vec<OperatorTree>,
650    },
651    Propagate {
652        edge_label: Option<String>,
653        aggregation: DeepFusionAggregation,
654        direction: Direction,
655    },
656    Conv {
657        edge_label: Option<String>,
658        /// Self weight followed by one weight per neighbor hop.
659        hop_weights: Vec<f64>,
660        direction: Direction,
661    },
662    Pool {
663        edge_label: Option<String>,
664        pool_size: usize,
665        method: DeepFusionPoolMethod,
666        direction: Direction,
667    },
668    Flatten,
669    Dense {
670        /// `output_channels x input_channels`, row-major.
671        weights: Vec<f64>,
672        bias: Vec<f64>,
673        output_channels: usize,
674        input_channels: usize,
675    },
676    Softmax,
677    BatchNorm {
678        epsilon: f64,
679    },
680    Dropout {
681        probability: f64,
682    },
683}
684
685/// Tree-local view of a temporal filter. The filter accepts
686/// either an exact timestamp or a `[low, high]` time range; both can
687/// be present simultaneously.
688#[derive(Clone, Debug, Default)]
689pub struct TemporalFilterIR {
690    pub timestamp: Option<f64>,
691    pub time_range: Option<(f64, f64)>,
692}
693
694impl OperatorTree {
695    /// Visit this node and every descendant in pre-order.
696    ///
697    /// The match is intentionally exhaustive so adding a child-bearing IR
698    /// variant cannot silently create a traversal boundary in planners or
699    /// engine catalog analysis.
700    #[expect(
701        clippy::too_many_lines,
702        reason = "preserves exhaustive IR variant order"
703    )]
704    pub fn visit(&self, visitor: &mut impl FnMut(&OperatorTree)) {
705        visitor(self);
706        match self {
707            OperatorTree::Filter {
708                source: Some(source),
709                ..
710            }
711            | OperatorTree::Facet {
712                source: Some(source),
713                ..
714            }
715            | OperatorTree::Score { source, .. }
716            | OperatorTree::BayesianScore { source, .. }
717            | OperatorTree::Complement(source)
718            | OperatorTree::EncodeGraphPosting { source }
719            | OperatorTree::CosineProbability(source)
720            | OperatorTree::ProbNot { signal: source, .. }
721            | OperatorTree::SparseThreshold { source, .. }
722            | OperatorTree::VertexAggregation { source, .. }
723            | OperatorTree::MessagePassing { source }
724            | OperatorTree::GraphEmbedding { source }
725            | OperatorTree::GroupBy { source, .. }
726            | OperatorTree::Aggregate {
727                source: Some(source),
728                ..
729            } => source.visit(visitor),
730            OperatorTree::Intersect(children)
731            | OperatorTree::Union(children)
732            | OperatorTree::Composed(children)
733            | OperatorTree::Opaque { children, .. }
734            | OperatorTree::BayesianEvidenceFusion {
735                signals: children, ..
736            }
737            | OperatorTree::RobustPositiveEvidencePool {
738                signals: children, ..
739            }
740            | OperatorTree::ProbBoolFusion {
741                signals: children, ..
742            }
743            | OperatorTree::AttentionFusion {
744                signals: children, ..
745            }
746            | OperatorTree::LearnedFusion {
747                signals: children, ..
748            } => visit_operator_slice(children, visitor),
749            OperatorTree::GraphJoin { left, right, .. }
750            | OperatorTree::TextSimilarityJoin { left, right, .. }
751            | OperatorTree::VectorSimilarityJoin { left, right, .. }
752            | OperatorTree::HybridJoin { left, right }
753            | OperatorTree::CrossParadigmJoin { left, right }
754            | OperatorTree::HybridTextVector {
755                term_op: left,
756                vector_op: right,
757                ..
758            }
759            | OperatorTree::SemanticFilter {
760                source: left,
761                vector_op: right,
762            }
763            | OperatorTree::VectorExclusion {
764                positive: left,
765                negative: right,
766            } => {
767                left.visit(visitor);
768                right.visit(visitor);
769            }
770            OperatorTree::FacetVector { vector_op, .. } => vector_op.visit(visitor),
771            OperatorTree::MultiStage { stages } => {
772                for stage in stages {
773                    stage.child.visit(visitor);
774                }
775            }
776            OperatorTree::ProgressiveFusion { stages, .. } => {
777                for stage in stages {
778                    stage.signal.visit(visitor);
779                }
780            }
781            OperatorTree::DeepFusion { layers, .. } => {
782                for layer in layers {
783                    if let DeepFusionLayer::Signal { signals } = layer {
784                        for signal in signals {
785                            signal.visit(visitor);
786                        }
787                    }
788                }
789            }
790            OperatorTree::Empty
791            | OperatorTree::Term { .. }
792            | OperatorTree::BayesianMatchWithPrior { .. }
793            | OperatorTree::Filter { source: None, .. }
794            | OperatorTree::Facet { source: None, .. }
795            | OperatorTree::VectorSimilarity { .. }
796            | OperatorTree::KNN { .. }
797            | OperatorTree::CalibratedVectorMatch { .. }
798            | OperatorTree::Traverse { .. }
799            | OperatorTree::GraphNeighbors { .. }
800            | OperatorTree::GraphEdges { .. }
801            | OperatorTree::PatternMatch { .. }
802            | OperatorTree::RegularPathQuery { .. }
803            | OperatorTree::IndexScan { .. }
804            | OperatorTree::Aggregate { source: None, .. }
805            | OperatorTree::MultiFieldSearch { .. }
806            | OperatorTree::WeightedPathQuery { .. }
807            | OperatorTree::PageRank { .. }
808            | OperatorTree::HITS { .. }
809            | OperatorTree::BetweennessCentrality { .. }
810            | OperatorTree::TemporalTraverse { .. }
811            | OperatorTree::TemporalPatternMatch { .. }
812            | OperatorTree::DeepPredict { .. } => {}
813        }
814    }
815
816    /// `True` when the operator is structurally empty. The explicit empty
817    /// node and zero-operand boolean/composition nodes all execute to an
818    /// empty posting list, so the optimizer must give them the same meaning.
819    pub fn is_empty(&self) -> bool {
820        match self {
821            OperatorTree::Empty => true,
822            OperatorTree::Intersect(v) | OperatorTree::Union(v) | OperatorTree::Composed(v) => {
823                v.is_empty()
824            }
825            _ => false,
826        }
827    }
828
829    /// Whether this subtree observes and produces document membership only.
830    ///
831    /// The classification is intentionally exhaustive: a newly added
832    /// operator remains payload-bearing until its score and field effects are
833    /// reviewed. Optimizer laws and physical support-only set operations share
834    /// this contract so they cannot silently disagree.
835    pub fn is_membership_only(&self) -> bool {
836        match self {
837            OperatorTree::Empty | OperatorTree::IndexScan { .. } => true,
838            OperatorTree::Filter { source, .. } => source
839                .as_deref()
840                .is_none_or(OperatorTree::is_membership_only),
841            OperatorTree::Intersect(children)
842            | OperatorTree::Union(children)
843            | OperatorTree::Composed(children) => {
844                children.iter().all(OperatorTree::is_membership_only)
845            }
846            OperatorTree::Complement(child) => child.is_membership_only(),
847            OperatorTree::VectorExclusion { positive, negative } => {
848                positive.is_membership_only() && negative.is_membership_only()
849            }
850            OperatorTree::Term { .. }
851            | OperatorTree::Facet { .. }
852            | OperatorTree::Score { .. }
853            | OperatorTree::BayesianScore { .. }
854            | OperatorTree::EncodeGraphPosting { .. }
855            | OperatorTree::BayesianMatchWithPrior { .. }
856            | OperatorTree::VectorSimilarity { .. }
857            | OperatorTree::KNN { .. }
858            | OperatorTree::CalibratedVectorMatch { .. }
859            | OperatorTree::CosineProbability(_)
860            | OperatorTree::BayesianEvidenceFusion { .. }
861            | OperatorTree::RobustPositiveEvidencePool { .. }
862            | OperatorTree::ProbBoolFusion { .. }
863            | OperatorTree::ProbNot { .. }
864            | OperatorTree::AttentionFusion { .. }
865            | OperatorTree::LearnedFusion { .. }
866            | OperatorTree::SparseThreshold { .. }
867            | OperatorTree::Traverse { .. }
868            | OperatorTree::GraphNeighbors { .. }
869            | OperatorTree::GraphEdges { .. }
870            | OperatorTree::PatternMatch { .. }
871            | OperatorTree::RegularPathQuery { .. }
872            | OperatorTree::GraphJoin { .. }
873            | OperatorTree::Aggregate { .. }
874            | OperatorTree::GroupBy { .. }
875            | OperatorTree::MultiStage { .. }
876            | OperatorTree::MultiFieldSearch { .. }
877            | OperatorTree::HybridTextVector { .. }
878            | OperatorTree::SemanticFilter { .. }
879            | OperatorTree::FacetVector { .. }
880            | OperatorTree::VertexAggregation { .. }
881            | OperatorTree::WeightedPathQuery { .. }
882            | OperatorTree::MessagePassing { .. }
883            | OperatorTree::GraphEmbedding { .. }
884            | OperatorTree::PageRank { .. }
885            | OperatorTree::HITS { .. }
886            | OperatorTree::BetweennessCentrality { .. }
887            | OperatorTree::TextSimilarityJoin { .. }
888            | OperatorTree::VectorSimilarityJoin { .. }
889            | OperatorTree::HybridJoin { .. }
890            | OperatorTree::CrossParadigmJoin { .. }
891            | OperatorTree::TemporalTraverse { .. }
892            | OperatorTree::TemporalPatternMatch { .. }
893            | OperatorTree::ProgressiveFusion { .. }
894            | OperatorTree::DeepFusion { .. }
895            | OperatorTree::DeepPredict { .. }
896            | OperatorTree::Opaque { .. } => false,
897        }
898    }
899}
900
901fn visit_operator_slice(children: &[OperatorTree], visitor: &mut impl FnMut(&OperatorTree)) {
902    for child in children {
903        child.visit(visitor);
904    }
905}