Skip to main content

linkrs_query/executor/streaming/plan/
validator.rs

1//! PhysicalPlanValidator: structural and binding-dependent validation.
2//!
3//! Two-tier validation:
4//! 1. **Structural validation** (no bindings required): runs once before
5//!    cache-write.  Checks ID uniqueness, fragment DAG integrity, operator
6//!    input/output consistency, property derivation, capability requirements,
7//!    memory policy, and root output contract.
8//! 2. **Binding-dependent validation** (requires parameter values, auth):
9//!    runs at instantiation time.  Re-checks compatibility, parameter frame,
10//!    transaction mode, and runtime limits.
11
12use std::collections::HashSet;
13
14use super::properties::{MemoryPolicy, Ordering, PipelineKind};
15use super::types::{
16    FragmentId, FragmentKind, InputContract, OperatorKindSpec, PhysicalOperatorId, PhysicalPlan,
17    StateOwnership,
18};
19use linkrs_core::error::QueryError;
20
21/// The two validation tiers.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ValidationTier {
24    /// Structural validation (no bindings required).
25    Structural,
26    /// Full validation including binding-dependent checks.
27    Full,
28}
29
30/// Result of a validation pass, collecting all errors instead of failing fast.
31#[derive(Debug, Default)]
32pub struct ValidationResult {
33    pub errors: Vec<String>,
34    pub warnings: Vec<String>,
35}
36
37impl ValidationResult {
38    pub fn is_ok(&self) -> bool {
39        self.errors.is_empty()
40    }
41
42    pub fn into_result(self) -> Result<(), QueryError> {
43        if self.errors.is_empty() {
44            Ok(())
45        } else {
46            Err(QueryError::execution(format!(
47                "PhysicalPlan validation failed ({} errors, {} warnings): [{}]",
48                self.errors.len(),
49                self.warnings.len(),
50                self.errors.join("; ")
51            )))
52        }
53    }
54
55    pub fn merge(&mut self, other: ValidationResult) {
56        self.errors.extend(other.errors);
57        self.warnings.extend(other.warnings);
58    }
59}
60
61/// Validates a [`PhysicalPlan`] for correctness and consistency.
62pub struct PhysicalPlanValidator;
63
64impl PhysicalPlanValidator {
65    /// Run the full structural validation on a plan.
66    pub fn validate(plan: &PhysicalPlan) -> Result<(), QueryError> {
67        Self::validate_tier(plan, ValidationTier::Structural)?.into_result()
68    }
69
70    /// Run validation at the specified tier.
71    pub fn validate_tier(
72        plan: &PhysicalPlan,
73        tier: ValidationTier,
74    ) -> Result<ValidationResult, QueryError> {
75        let mut result = ValidationResult::default();
76
77        // ── Always run (structural) ──
78        Self::check_operator_id_uniqueness(plan, &mut result);
79        Self::check_operator_references(plan, &mut result);
80        Self::check_fragment_connectivity(plan, &mut result);
81        Self::check_fragment_cycles(plan, &mut result);
82        Self::check_fragment_operator_belongs(plan, &mut result);
83        Self::check_operator_input_counts(plan, &mut result);
84        Self::check_output_layouts(plan, &mut result);
85        Self::check_property_consistency(plan, &mut result);
86        Self::check_memory_policy(plan, &mut result);
87        Self::check_root_output_contract(plan, &mut result);
88        Self::check_capability_set(plan, &mut result);
89        Self::check_output_layout_width(plan, &mut result);
90        Self::check_fragment_exchange_layout(plan, &mut result);
91        Self::check_input_contract_consistency(plan, &mut result);
92        Self::check_linear_fragment_contracts(plan, &mut result);
93        Self::check_state_ownership(plan, &mut result);
94        Self::check_fragment_parallelism(plan, &mut result);
95        Self::check_partition_parallelism(plan, &mut result);
96
97        // Additional structural integrity checks.
98        Self::check_fragment_kind_matches(plan, &mut result);
99        Self::check_root_fragment_kind(plan, &mut result);
100        Self::check_unreferenced_operators(plan, &mut result);
101        Self::check_operator_spec_consistency(plan, &mut result);
102
103        if tier == ValidationTier::Full {
104            // Binding-dependent checks.
105            Self::check_parameter_schema(plan, &mut result);
106        }
107
108        Ok(result)
109    }
110
111    /// Quick compatibility check after cache load.
112    ///
113    /// Does not re-run expensive structural validation.  Only checks that
114    /// the cached plan's compatibility metadata still matches the current
115    /// execution context.
116    pub fn check_compatibility(
117        plan: &PhysicalPlan,
118        current_layout_version: Option<u64>,
119    ) -> Result<(), QueryError> {
120        if let Some(cached_version) = plan.compatibility.layout_version {
121            if let Some(current_version) = current_layout_version {
122                if cached_version != current_version {
123                    return Err(QueryError::execution(format!(
124                        "Plan layout version mismatch: cached={}, current={}",
125                        cached_version, current_version
126                    )));
127                }
128            }
129        }
130        Ok(())
131    }
132
133    // ── Individual checks ──
134
135    /// Every operator in the arena has a unique ID.
136    fn check_operator_id_uniqueness(plan: &PhysicalPlan, result: &mut ValidationResult) {
137        let mut seen = HashSet::new();
138        for op in &plan.operators {
139            if !seen.insert(op.operator_id) {
140                result
141                    .errors
142                    .push(format!("Duplicate PhysicalOperatorId: {}", op.operator_id));
143            }
144        }
145    }
146
147    /// All operator IDs referenced in fragments exist in the arena.
148    fn check_operator_references(plan: &PhysicalPlan, result: &mut ValidationResult) {
149        let arena_ids: HashSet<PhysicalOperatorId> =
150            plan.operators.iter().map(|op| op.operator_id).collect();
151
152        for fragment in plan.fragments.fragments() {
153            for &op_id in &fragment.operators {
154                if !arena_ids.contains(&op_id) {
155                    result.errors.push(format!(
156                        "Fragment {:?} references operator {:?} which is not in the arena",
157                        fragment.id, op_id
158                    ));
159                }
160            }
161            if !arena_ids.contains(&fragment.root_operator) {
162                result.errors.push(format!(
163                    "Fragment {:?} root operator {:?} not in arena",
164                    fragment.id, fragment.root_operator
165                ));
166            }
167        }
168    }
169
170    /// Fragment graph is connected and root exists.
171    fn check_fragment_connectivity(plan: &PhysicalPlan, result: &mut ValidationResult) {
172        let fragment_ids: HashSet<FragmentId> =
173            plan.fragments.fragments().iter().map(|f| f.id).collect();
174
175        if !fragment_ids.contains(&plan.root_fragment) {
176            result.errors.push(format!(
177                "Root fragment {:?} not found in fragment graph",
178                plan.root_fragment
179            ));
180        }
181
182        for fragment in plan.fragments.fragments() {
183            if fragment.operators.is_empty() {
184                result
185                    .warnings
186                    .push(format!("Fragment {:?} has no operators", fragment.id));
187            }
188            for input_id in &fragment.inputs {
189                if !fragment_ids.contains(input_id) {
190                    result.errors.push(format!(
191                        "Fragment {:?} references missing input {:?}",
192                        fragment.id, input_id
193                    ));
194                }
195            }
196        }
197    }
198
199    /// No illegal cycles in fragment DAG (a fragment cannot depend on itself
200    /// transitively — the graph must be a DAG).
201    fn check_fragment_cycles(plan: &PhysicalPlan, result: &mut ValidationResult) {
202        let fragment_ids: Vec<FragmentId> =
203            plan.fragments.fragments().iter().map(|f| f.id).collect();
204
205        // Compute in-degree and topological order.
206        let mut in_degree: std::collections::HashMap<FragmentId, usize> =
207            fragment_ids.iter().map(|&fid| (fid, 0)).collect();
208
209        for fragment in plan.fragments.fragments() {
210            for &input in &fragment.inputs {
211                *in_degree.get_mut(&input).unwrap_or(&mut 0) += 1;
212            }
213        }
214
215        // For a DAG starting from root, walk backward through inputs.
216        // If any fragment is not reachable from root (and not an input of root),
217        // it's either a cycle or disconnected.
218        let mut reachable = HashSet::new();
219        let mut stack = vec![plan.root_fragment];
220        while let Some(fid) = stack.pop() {
221            if !reachable.insert(fid) {
222                continue;
223            }
224            if let Some(frag) = plan.fragments.get(fid) {
225                for &input in &frag.inputs {
226                    stack.push(input);
227                }
228            }
229        }
230
231        for &fid in &fragment_ids {
232            if !reachable.contains(&fid) {
233                result
234                    .warnings
235                    .push(format!("Fragment {:?} is not reachable from root", fid));
236            }
237        }
238    }
239
240    /// Every operator referenced in a fragment exists in that fragment's
241    /// operator list.
242    fn check_fragment_operator_belongs(plan: &PhysicalPlan, result: &mut ValidationResult) {
243        for fragment in plan.fragments.fragments() {
244            let op_set: HashSet<PhysicalOperatorId> = fragment.operators.iter().copied().collect();
245            if !op_set.contains(&fragment.root_operator) {
246                result.errors.push(format!(
247                    "Fragment {:?} root operator {:?} not in its operator list",
248                    fragment.id, fragment.root_operator
249                ));
250            }
251        }
252    }
253
254    /// Each fragment's input count matches the expected children of its
255    /// leaf (first) operator — the one that receives data from other fragments.
256    /// Operators further up the same fragment pipeline (e.g., Filter above
257    /// Scan) consume data from their sibling within the fragment, not from
258    /// external fragment inputs.
259    fn check_operator_input_counts(plan: &PhysicalPlan, result: &mut ValidationResult) {
260        for fragment in plan.fragments.fragments() {
261            if fragment.operators.is_empty() {
262                continue;
263            }
264            // The first operator in the fragment pipeline is the leaf that
265            // receives external inputs. Check its expected children against
266            // the number of fragment inputs.
267            let leaf_op_id = fragment.operators[0];
268            let leaf_op = &plan.operators[leaf_op_id.0];
269
270            let expected_children = match &leaf_op.spec {
271                OperatorKindSpec::Source(_) | OperatorKindSpec::Txn(_) => 0,
272                OperatorKindSpec::Unary(_)
273                | OperatorKindSpec::Blocking(_)
274                | OperatorKindSpec::Graph(_)
275                | OperatorKindSpec::RecursiveFragment(_)
276                | OperatorKindSpec::Sink(_)
277                | OperatorKindSpec::Ddl(_)
278                | OperatorKindSpec::Fulltext(_)
279                | OperatorKindSpec::Vector(_)
280                | OperatorKindSpec::Apply(
281                    crate::executor::streaming::operators::spec::ApplySpec::CorrelatedApply {
282                        ..
283                    },
284                ) => 1,
285                OperatorKindSpec::Join(_)
286                | OperatorKindSpec::Set(_)
287                | OperatorKindSpec::Apply(
288                    crate::executor::streaming::operators::spec::ApplySpec::Apply { .. },
289                )
290                | OperatorKindSpec::Apply(
291                    crate::executor::streaming::operators::spec::ApplySpec::PatternApply { .. },
292                )
293                | OperatorKindSpec::Apply(
294                    crate::executor::streaming::operators::spec::ApplySpec::RollUpApply { .. },
295                ) => 2,
296                OperatorKindSpec::Wco(wco) => 1 + wco.bound_names.len(),
297                OperatorKindSpec::Exchange(_) => {
298                    continue;
299                }
300            };
301
302            if fragment.inputs.len() != expected_children && expected_children > 0 {
303                result.errors.push(format!(
304                    "Fragment {:?} has {} input(s) but its leaf operator {:?} ({}) expects {} child(ren)",
305                    fragment.id,
306                    fragment.inputs.len(),
307                    leaf_op.operator_id,
308                    leaf_op.explain_name,
309                    expected_children
310                ));
311            }
312        }
313    }
314
315    /// Output layout is non-empty for non-command operators.
316    fn check_output_layouts(plan: &PhysicalPlan, result: &mut ValidationResult) {
317        for op in &plan.operators {
318            if op.output_layout.is_empty() {
319                let is_command = matches!(
320                    &op.spec,
321                    OperatorKindSpec::Ddl(_) | OperatorKindSpec::Txn(_)
322                );
323                let is_source = matches!(&op.spec, OperatorKindSpec::Source(s)
324                    if matches!(s, super::super::operators::spec::SourceSpec::Start)
325                );
326                if !is_command && !is_source {
327                    result.warnings.push(format!(
328                        "Operator {:?} ({}) has empty output layout",
329                        op.operator_id, op.explain_name
330                    ));
331                }
332            }
333        }
334    }
335
336    /// Physical property consistency checks.
337    fn check_property_consistency(plan: &PhysicalPlan, result: &mut ValidationResult) {
338        for op in &plan.operators {
339            match &op.spec {
340                OperatorKindSpec::Blocking(spec) => {
341                    if matches!(
342                        spec,
343                        super::super::operators::spec::BlockingSpec::Sort { .. }
344                    ) && matches!(op.properties.ordering, Ordering::None)
345                    {
346                        result.warnings.push(format!(
347                            "Sort operator {:?} declares no output ordering",
348                            op.operator_id
349                        ));
350                    }
351                    // PartialAggregate and FinalAggregate should be blocking.
352                    if matches!(op.properties.pipeline_kind, PipelineKind::Streaming) {
353                        result.warnings.push(format!(
354                            "Blocking spec operator {:?} is marked as streaming",
355                            op.operator_id
356                        ));
357                    }
358                }
359                OperatorKindSpec::Exchange(_) => {
360                    // Exchange must be non-streaming (blocking or exchange pipeline).
361                    if op.properties.pipeline_kind == PipelineKind::Streaming {
362                        result.warnings.push(format!(
363                            "Exchange operator {:?} is marked as streaming",
364                            op.operator_id
365                        ));
366                    }
367                }
368                OperatorKindSpec::Source(super::super::operators::spec::SourceSpec::Start)
369                    if op.properties.pipeline_kind == PipelineKind::Blocking =>
370                {
371                    result.warnings.push(format!(
372                        "Start source {:?} should be streaming",
373                        op.operator_id
374                    ));
375                }
376                _ => {}
377            }
378        }
379    }
380
381    /// Blocking operators must have a memory policy (RequiresBudget or Spillable).
382    fn check_memory_policy(plan: &PhysicalPlan, result: &mut ValidationResult) {
383        for op in &plan.operators {
384            if op.properties.pipeline_kind == PipelineKind::Blocking {
385                match &op.properties.memory_policy {
386                    MemoryPolicy::None => {
387                        result.warnings.push(format!(
388                            "Blocking operator {:?} ({}) has no memory policy set \
389                             (should be RequiresBudget or Spillable)",
390                            op.operator_id, op.explain_name
391                        ));
392                    }
393                    MemoryPolicy::RequiresBudget | MemoryPolicy::Spillable { .. } => {}
394                }
395            }
396        }
397    }
398
399    /// Root output contract must be present.
400    fn check_root_output_contract(plan: &PhysicalPlan, result: &mut ValidationResult) {
401        if plan.output.output_layout.is_empty() {
402            result
403                .warnings
404                .push("Plan root output contract has empty layout".to_string());
405        }
406
407        // Verify root fragment exists and has operators.
408        let root_frag = plan.fragments.get(plan.root_fragment);
409        if let Some(frag) = root_frag {
410            if frag.operators.is_empty() {
411                result.errors.push(format!(
412                    "Root fragment {:?} has no operators",
413                    plan.root_fragment
414                ));
415            }
416        }
417    }
418
419    /// Required capabilities must be non-empty and valid.
420    fn check_capability_set(plan: &PhysicalPlan, result: &mut ValidationResult) {
421        if plan.required_capabilities.is_empty() {
422            result
423                .warnings
424                .push("Plan declares no required capabilities".to_string());
425        }
426    }
427
428    /// Each operator's output layout slots must have non-empty type info
429    /// (unless the operator is a command or Start source).
430    fn check_output_layout_width(plan: &PhysicalPlan, result: &mut ValidationResult) {
431        for op in &plan.operators {
432            if op.output_layout.is_empty() {
433                continue;
434            }
435            for slot in &op.output_layout.slots {
436                if slot.data_type.is_none() {
437                    let is_command = matches!(
438                        &op.spec,
439                        OperatorKindSpec::Ddl(_) | OperatorKindSpec::Txn(_)
440                    );
441                    if !is_command {
442                        result.warnings.push(format!(
443                            "Operator {:?} ({}) output slot '{}' has no data type",
444                            op.operator_id, op.explain_name, slot.name
445                        ));
446                    }
447                }
448            }
449        }
450    }
451
452    /// Exchange fragments must have an exchange_layout set.
453    fn check_fragment_exchange_layout(plan: &PhysicalPlan, result: &mut ValidationResult) {
454        for fragment in plan.fragments.fragments() {
455            if fragment.kind == FragmentKind::Exchange && fragment.exchange_layout.is_none() {
456                result.warnings.push(format!(
457                    "Exchange fragment {:?} has no exchange_layout",
458                    fragment.id
459                ));
460            }
461        }
462    }
463
464    // ── Additional structural integrity checks ──
465
466    /// Fragment kind should be consistent with the root operator type.
467    ///
468    /// A Source fragment should have a Source operator as its root.
469    /// A Terminal fragment should have a terminal operator (Ddl, Sink, Txn).
470    fn check_fragment_kind_matches(plan: &PhysicalPlan, result: &mut ValidationResult) {
471        for fragment in plan.fragments.fragments() {
472            let root_op = match plan.operator(fragment.root_operator) {
473                Some(op) => op,
474                None => continue,
475            };
476            let is_terminal = matches!(
477                &root_op.spec,
478                OperatorKindSpec::Sink(_) | OperatorKindSpec::Ddl(_) | OperatorKindSpec::Txn(_)
479            );
480            match fragment.kind {
481                super::types::FragmentKind::Source => {
482                    if !matches!(&root_op.spec, OperatorKindSpec::Source(_)) {
483                        result.warnings.push(format!(
484                            "Fragment {:?} has kind Source but root operator is {:?}",
485                            fragment.id, root_op.explain_name
486                        ));
487                    }
488                }
489                super::types::FragmentKind::Terminal => {
490                    if !is_terminal {
491                        result.warnings.push(format!(
492                            "Fragment {:?} has kind Terminal but root operator is {:?}",
493                            fragment.id, root_op.explain_name
494                        ));
495                    }
496                }
497                super::types::FragmentKind::Blocking => {
498                    if !matches!(&root_op.spec, OperatorKindSpec::Blocking(_)) {
499                        result.warnings.push(format!(
500                            "Fragment {:?} has kind Blocking but root operator is {:?}",
501                            fragment.id, root_op.explain_name
502                        ));
503                    }
504                }
505                super::types::FragmentKind::Exchange
506                    if !matches!(&root_op.spec, OperatorKindSpec::Exchange(_)) =>
507                {
508                    result.warnings.push(format!(
509                        "Fragment {:?} has kind Exchange but root operator is {:?}",
510                        fragment.id, root_op.explain_name
511                    ));
512                }
513                _ => {}
514            }
515        }
516    }
517
518    /// Check that the root fragment kind is appropriate.
519    fn check_root_fragment_kind(plan: &PhysicalPlan, result: &mut ValidationResult) {
520        if let Some(root_frag) = plan.fragments.get(plan.root_fragment) {
521            match root_frag.kind {
522                super::types::FragmentKind::Source | super::types::FragmentKind::Streaming => {
523                    result.warnings.push(format!(
524                        "Root fragment {:?} has kind {:?}, expected Terminal or Result",
525                        root_frag.id, root_frag.kind
526                    ));
527                }
528                _ => {}
529            }
530        }
531    }
532
533    /// Every operator in the arena must belong to at least one fragment.
534    fn check_unreferenced_operators(plan: &PhysicalPlan, result: &mut ValidationResult) {
535        let mut referenced: std::collections::HashSet<PhysicalOperatorId> =
536            std::collections::HashSet::new();
537        for fragment in plan.fragments.fragments() {
538            for &op_id in &fragment.operators {
539                referenced.insert(op_id);
540            }
541        }
542        for op in &plan.operators {
543            if !referenced.contains(&op.operator_id) {
544                result.warnings.push(format!(
545                    "Operator {:?} ({}) exists in the arena but is not referenced by any fragment",
546                    op.operator_id, op.explain_name
547                ));
548            }
549        }
550    }
551
552    /// Input contract matches spec kind and fragment inputs.
553    fn check_input_contract_consistency(plan: &PhysicalPlan, result: &mut ValidationResult) {
554        for op in &plan.operators {
555            // NoInput should not have fragment inputs.
556            if let InputContract::NoInput = &op.input_contract {
557                for fragment in plan.fragments.fragments() {
558                    if fragment.root_operator == op.operator_id && !fragment.inputs.is_empty() {
559                        result.warnings.push(format!(
560                            "Operator {:?} ({}) has NoInput contract but its fragment {:?} has {} inputs",
561                            op.operator_id, op.explain_name, fragment.id, fragment.inputs.len()
562                        ));
563                    }
564                }
565            }
566        }
567    }
568
569    /// The arena contract is the authoritative edge description. External
570    /// ports on the first operator must match the fragment's producer list;
571    /// subsequent operators may only consume the immediately preceding
572    /// operator in the same fragment. This deliberately rejects ambiguous
573    /// stack-shaped plans instead of trying to repair them at materialization.
574    fn check_linear_fragment_contracts(plan: &PhysicalPlan, result: &mut ValidationResult) {
575        for fragment in plan.fragments.fragments() {
576            if fragment.operators.is_empty() {
577                result.errors.push(format!(
578                    "Fragment {:?} has no operator and therefore no root",
579                    fragment.id
580                ));
581                continue;
582            }
583            if fragment.root_operator
584                != *fragment.operators.last().unwrap_or(&fragment.root_operator)
585            {
586                result.errors.push(format!(
587                    "Fragment {:?} root {:?} is not the final pipeline operator",
588                    fragment.id, fragment.root_operator
589                ));
590            }
591
592            let external = fragment.inputs.clone();
593            for (index, operator_id) in fragment.operators.iter().enumerate() {
594                let Some(operator) = plan.operator(*operator_id) else {
595                    continue;
596                };
597                let references: Vec<FragmentId> = match &operator.input_contract {
598                    InputContract::NoInput => Vec::new(),
599                    InputContract::UnaryInput(input) => vec![input.fragment],
600                    InputContract::BinaryInputs { left, right } => {
601                        vec![left.fragment, right.fragment]
602                    }
603                    InputContract::PartitionedInputs { members, .. } => {
604                        members.iter().map(|member| member.fragment).collect()
605                    }
606                    InputContract::WcoInputs { probe, builds } => {
607                        let mut references = Vec::with_capacity(builds.len() + 1);
608                        references.push(probe.fragment);
609                        references.extend(builds.iter().map(|build| build.fragment));
610                        references
611                    }
612                };
613
614                let expected: Vec<FragmentId> = if index == 0 {
615                    external.clone()
616                } else {
617                    vec![fragment.id]
618                };
619                if references != expected {
620                    result.errors.push(format!(
621                        "Operator {:?} in fragment {:?} has input ports {:?}, expected {:?}",
622                        operator_id, fragment.id, references, expected
623                    ));
624                }
625
626                let arity = match &operator.input_contract {
627                    InputContract::NoInput => 0,
628                    InputContract::UnaryInput(_) => 1,
629                    InputContract::BinaryInputs { .. } => 2,
630                    InputContract::PartitionedInputs { members, .. } => members.len(),
631                    InputContract::WcoInputs { builds, .. } => 1 + builds.len(),
632                };
633                let expected_arity = match &operator.spec {
634                    OperatorKindSpec::Source(_) => 0,
635                    OperatorKindSpec::Join(_)
636                    | OperatorKindSpec::Set(_)
637                    | OperatorKindSpec::Apply(
638                        crate::executor::streaming::operators::spec::ApplySpec::Apply { .. },
639                    )
640                    | OperatorKindSpec::Apply(
641                        crate::executor::streaming::operators::spec::ApplySpec::PatternApply {
642                            ..
643                        },
644                    )
645                    | OperatorKindSpec::Apply(
646                        crate::executor::streaming::operators::spec::ApplySpec::RollUpApply {
647                            ..
648                        },
649                    ) => 2,
650                    OperatorKindSpec::Wco(wco) => 1 + wco.bound_names.len(),
651                    OperatorKindSpec::Exchange(_) => arity,
652                    _ => 1,
653                };
654                if !matches!(operator.spec, OperatorKindSpec::Source(_)) && arity != expected_arity
655                {
656                    result.errors.push(format!(
657                        "Operator {:?} ({}) declares arity {}, expected {}",
658                        operator_id, operator.explain_name, arity, expected_arity
659                    ));
660                }
661
662                for input_fragment in &references {
663                    if *input_fragment == fragment.id {
664                        if index == 0 {
665                            result.errors.push(format!(
666                                "First operator {:?} in fragment {:?} self-references its input",
667                                operator_id, fragment.id
668                            ));
669                        }
670                    } else if plan.fragments.get(*input_fragment).is_none() {
671                        result.errors.push(format!(
672                            "Operator {:?} references missing producer fragment {:?}",
673                            operator_id, input_fragment
674                        ));
675                    }
676                }
677            }
678        }
679    }
680
681    /// State ownership must match operator kind.
682    ///
683    /// Blocking operators (Sort, Aggregate, Distinct, etc.) must be
684    /// `GlobalRuntime` or `TaskLocal` — they own spill/state arenas
685    /// that outlive individual `next()` calls.
686    fn check_state_ownership(plan: &PhysicalPlan, result: &mut ValidationResult) {
687        for op in &plan.operators {
688            let is_blocking = matches!(&op.spec, OperatorKindSpec::Blocking(_));
689            let is_exchange = matches!(&op.spec, OperatorKindSpec::Exchange(_));
690
691            match op.state_ownership {
692                StateOwnership::TreeLocal => {
693                    // TreeLocal is fine for source, unary, join, set, apply,
694                    // sink, graph, txn, fulltext, vector, ddl.
695                    if is_blocking || is_exchange {
696                        result.warnings.push(format!(
697                            "Operator {:?} ({}) is blocking/exchange but has TreeLocal state ownership",
698                            op.operator_id, op.explain_name
699                        ));
700                    }
701                }
702                StateOwnership::GlobalRuntime | StateOwnership::TaskLocal => {
703                    // These are expected for blocking/exchange operators.
704                }
705            }
706        }
707    }
708
709    /// Check operator spec internal consistency.
710    ///
711    /// validations that catch spec-level errors before execution.
712    fn check_operator_spec_consistency(plan: &PhysicalPlan, result: &mut ValidationResult) {
713        for op in &plan.operators {
714            // Check that Source operators are not in fragments with inputs.
715            if let OperatorKindSpec::Source(_) = &op.spec {
716                for fragment in plan.fragments.fragments() {
717                    if fragment.root_operator == op.operator_id && !fragment.inputs.is_empty() {
718                        // Source operators should be leaf fragments.
719                        result.warnings.push(format!(
720                            "Source operator {:?} ({}) is root of fragment {:?} which has inputs",
721                            op.operator_id, op.explain_name, fragment.id
722                        ));
723                    }
724                }
725            }
726        }
727    }
728
729    /// Fragment parallelism consistency: each operator's min_workers must be
730    /// <= max_workers, and within the same fragment all operators should have
731    /// compatible parallelism settings.
732    fn check_fragment_parallelism(plan: &PhysicalPlan, result: &mut ValidationResult) {
733        for op in &plan.operators {
734            let p = &op.properties.parallelism;
735            if p.min_workers > p.max_workers {
736                result.errors.push(format!(
737                    "Operator {:?} ({}) has min_workers {} > max_workers {}",
738                    op.operator_id, op.explain_name, p.min_workers, p.max_workers
739                ));
740            }
741            if p.min_workers < 1 {
742                result.errors.push(format!(
743                    "Operator {:?} ({}) has min_workers {} < 1",
744                    op.operator_id, op.explain_name, p.min_workers
745                ));
746            }
747        }
748    }
749
750    /// Validate that PartitionedInputs have consistent parallelism across all
751    /// partition members.
752    fn check_partition_parallelism(plan: &PhysicalPlan, result: &mut ValidationResult) {
753        for op in &plan.operators {
754            if let InputContract::PartitionedInputs { members, .. } = &op.input_contract {
755                if members.is_empty() {
756                    result.warnings.push(format!(
757                        "Operator {:?} ({}) has empty PartitionedInputs",
758                        op.operator_id, op.explain_name
759                    ));
760                    continue;
761                }
762                let base_workers = members[0].properties.parallelism.max_workers;
763                for member in members {
764                    if member.properties.parallelism.max_workers != base_workers {
765                        result.warnings.push(format!(
766                            "Operator {:?} ({}) has partition member {:?} with {} workers, expected {}",
767                            op.operator_id,
768                            op.explain_name,
769                            member.fragment,
770                            member.properties.parallelism.max_workers,
771                            base_workers
772                        ));
773                    }
774                }
775            }
776        }
777    }
778
779    // ── Binding-dependent checks ──
780
781    fn check_parameter_schema(plan: &PhysicalPlan, result: &mut ValidationResult) {
782        for param in &plan.parameter_schema.params {
783            if param.name.is_empty() {
784                result
785                    .errors
786                    .push("Parameter schema contains unnamed parameter".to_string());
787            }
788            if param.slot.0 >= plan.parameter_schema.params.len() {
789                result.warnings.push(format!(
790                    "Parameter '{}' has out-of-range slot {:?}",
791                    param.name, param.slot
792                ));
793            }
794        }
795    }
796}