Skip to main content

polydat_core/iteration/comprehension/
validate.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Validation — spec §5 (V1-V9) + §5.8 (modes).
5//!
6//! [`validate`] is the single entry point. Walking the AST
7//! bottom-up, every variant's V-axiom checks fire; any failure
8//! produces a typed [`ValidationError`]. Degenerate-but-defined
9//! compositions emit a [`ValidationWarning`] in Permissive mode
10//! and become hard errors in Strict mode.
11//!
12//! V4 (per-strategy input-shape contract) fires in two
13//! tiers per spec §10.7.8:
14//!
15//! 1. **Compile-time** — this module, run by the compile stage
16//!    (`CompiledComprehension::from_ast` and the `for` lowering)
17//!    on the AST, against the AST's static
18//!    metadata-derived [`IndexFn`]; catches shape violations
19//!    the static estimate can prove. For
20//!    [`crate::iteration::comprehension::eval_source::EvalClass::Static`]
21//!    sources the static IndexFn equals the runtime IndexFn,
22//!    so this fire is exact; for `ContextRequired` sources
23//!    (Generator without registry recognition,
24//!    WorkloadParamList) the static estimate may be
25//!    conservative (uses `cardinality_hint`, or `None` if
26//!    absent) and the strategy-invocation-time fire below
27//!    is load-bearing.
28//! 2. **Strategy-invocation-time (load-bearing)** —
29//!    [`crate::iteration::comprehension::runtime::evaluate_for_iteration`]'s
30//!    `apply_order` fires
31//!    [`crate::iteration::comprehension::strategies::Strategy::accepts_input`]
32//!    against the [`crate::iteration::comprehension::eval_source::EvaluatedSource`]'s
33//!    actual `index_fn` after source evaluation. This is the
34//!    definitive V4 check per spec §10.7.8.
35
36use serde::{Deserialize, Serialize};
37
38use super::ast::Comprehension;
39use super::cardinality::CardinalityClass;
40use super::metadata::{IndexFn, Metadata};
41use super::source::Source;
42use super::strategy::{StrategyName, ZipMode};
43
44/// Validation mode per spec §5.8.
45///
46/// `Permissive` (default) enforces V1-V9 as errors and surfaces
47/// degenerate-composition warnings non-blockingly. `Strict`
48/// promotes those warnings to errors.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50pub enum Mode {
51    #[default]
52    /// V1 through V9 are errors; degenerate compositions are warnings.
53    Permissive,
54    /// Degenerate compositions are errors too.
55    Strict,
56}
57
58/// Result of a validation pass.
59#[derive(Debug, Clone)]
60pub struct ValidationReport {
61    /// The warnings the pass raised.
62    pub warnings: Vec<ValidationWarning>,
63}
64
65/// V-axiom violation. Each variant carries enough context to
66/// produce a useful diagnostic at the call site.
67#[derive(Debug, Clone, PartialEq)]
68pub enum ValidationError {
69    /// V1 — cartesian or zip children share a name.
70    V1DuplicateName {
71        /// The combinator whose children share the name.
72        combinator: &'static str,
73        /// The duplicated name.
74        name: String,
75    },
76
77    /// V2 — union children disagree on tuple shape.
78    V2ShapeMismatch {
79        /// The tuple shape of the first child.
80        expected: Vec<String>,
81        /// The shape that differs.
82        actual: Vec<String>,
83    },
84
85    /// V3 — filter predicate references a name neither in the
86    /// child's coordinates nor in the parent scope. Parser-time
87    /// validation only — link-time (parent scope) check lives
88    /// in the consumer.
89    ///
90    /// `coords` is the wrapped comprehension's coordinate set;
91    /// the predicate may also reference names from the parent
92    /// scope which this layer doesn't see.
93    V3UnresolvedNames {
94        /// The predicate text.
95        predicate: String,
96        /// The coordinates the comprehension binds.
97        coords: Vec<String>,
98        /// The names the predicate references that are neither coordinates nor known here.
99        unresolved: Vec<String>,
100    },
101
102    /// V4 — strategy applied to an input whose metadata-derived
103    /// [`IndexFn`] it cannot accept (per-strategy table in
104    /// `check_strategy_input_shape`). V5's one-filter
105    /// look-through is honoured; nested filters are rejected.
106    V4InputShape {
107        /// The strategy applied.
108        strategy: StrategyName,
109        /// Why its input's shape is unacceptable.
110        reason: String,
111    },
112
113    /// V6 — non-Lex order or Strict/Truncate zip applied to an
114    /// `Unbounded` discrete input.
115    V6UnboundedDiscrete {
116        /// The operator applied.
117        operator: &'static str,
118        /// The unbounded input's cardinality class.
119        cardinality: CardinalityClass,
120    },
121
122    /// V7 — zip cardinality contract violated. Three sub-cases:
123    /// Strict-mode mismatch, mixed-class children, or any
124    /// continuous child.
125    V7ZipCardinality {
126        /// The zip mode in force.
127        mode: ZipMode,
128        /// Which contract was violated.
129        reason: String,
130    },
131
132    /// V8 — continuous source requires explicit sampling OR
133    /// source declares a non-integrable measure.
134    V8ContinuousRequirement {
135        /// What the source lacks.
136        reason: String,
137    },
138
139    /// V9 — union children include a continuous or mixed-class
140    /// child.
141    V9UnionClassMismatch {
142        /// Which child mismatches, and how.
143        reason: String,
144    },
145
146    /// Strict mode (spec §5.8): a degenerate composition the
147    /// permissive mode only warns about.
148    StrictWarning(ValidationWarning),
149    /// A source that needs a scope, on the scope-less surfaces
150    /// (comprehension_forms.md §9.5.2, §10.7.0): a coordinate stream
151    /// binds no names, the traversal does.
152    ContextRequired {
153        /// The clause.
154        name: String,
155        /// The names its source references.
156        references: Vec<String>,
157    },
158    /// A context-free source that could not be evaluated, on the
159    /// scope-less surfaces. Its only evaluation is the compile's, in
160    /// the empty scope, so its failure is the comprehension's error.
161    /// It used to be kept for a traversal that a coordinate stream
162    /// never has, and the clause silently dispensed nothing.
163    SourceFailed {
164        /// The clause.
165        name: String,
166        /// The evaluator's message.
167        message: String,
168    },
169}
170
171impl std::fmt::Display for ValidationError {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::V1DuplicateName { combinator, name } => {
175                write!(
176                    f,
177                    "V1: `{combinator}` binds the name `{name}` more than once"
178                )
179            }
180            Self::V2ShapeMismatch { expected, actual } => write!(
181                f,
182                "V2: tuple shape ({}) does not match ({})",
183                actual.join(", "),
184                expected.join(", ")
185            ),
186            Self::V3UnresolvedNames {
187                predicate,
188                coords,
189                unresolved,
190            } => write!(
191                f,
192                "V3: predicate `{predicate}` names {} not bound by the tuple ({})",
193                unresolved.join(", "),
194                coords.join(", ")
195            ),
196            Self::V4InputShape { strategy, reason } => {
197                write!(
198                    f,
199                    "V4: strategy `{strategy:?}` cannot take this input: {reason}"
200                )
201            }
202            Self::V6UnboundedDiscrete {
203                operator,
204                cardinality,
205            } => write!(
206                f,
207                "V6: `{operator}` cannot materialize a {cardinality:?} stream"
208            ),
209            Self::V7ZipCardinality { mode, reason } => {
210                write!(f, "V7: zip in {mode:?} mode: {reason}")
211            }
212            Self::V8ContinuousRequirement { reason } => {
213                write!(f, "V8: continuous source: {reason}")
214            }
215            Self::StrictWarning(w) => write!(f, "strict mode: {w}"),
216            Self::ContextRequired { name, references } => write!(
217                f,
218                "clause '{name}' needs a scope to bind {}; a coordinate stream has none: \n                 traverse it with `for`, which captures those names when it opens",
219                references.join(", ")
220            ),
221            Self::V9UnionClassMismatch { reason } => {
222                write!(f, "V9: union children differ in class: {reason}")
223            }
224            Self::SourceFailed { name, message } => {
225                write!(f, "clause '{name}' cannot be evaluated: {message}")
226            }
227        }
228    }
229}
230
231impl std::error::Error for ValidationError {}
232
233#[derive(Debug, Clone, PartialEq)]
234/// Non-blocking warning for degenerate-but-defined compositions
235/// per spec §5.8.
236pub enum ValidationWarning {
237    /// Lattice-geometric strategy (`Extrema` / `Shells` /
238    /// `Diagonal` / `Antidiagonal`) over a 1-axis input.
239    /// Collapses to {first, last} or a trivial walk; usually
240    /// not what the author meant.
241    DegenerateGeometric {
242        /// The strategy applied.
243        strategy: StrategyName,
244    },
245
246    /// `Lhs` over a 1-axis input. Equivalent to `Shuffle`;
247    /// two names for one behavior.
248    LhsDegenerate,
249
250    /// `filter(c, "true")`. Empty-effect filter — usually a
251    /// bug-shaped predicate. The optimizer's R0a elides it.
252    TriviallyTrueFilter,
253
254    /// `filter(c, "false")`. Empty dispense sequence. If
255    /// intentional, use an empty literal source; otherwise the
256    /// predicate is bug-shaped.
257    TriviallyFalseFilter,
258
259    /// A clause whose source is *provably* empty: its cardinality
260    /// is `Bounded(0)` (`x in []`, `x in 5..5`, a context-free
261    /// generator the compile evaluated to nothing). The clause is
262    /// well-formed and an empty stream is a legal value, so this
263    /// is degenerate rather than wrong — but it empties every
264    /// cartesian it takes part in, so a whole traversal dispenses
265    /// nothing and usually that is a typo in the source.
266    ///
267    /// A source whose count is not known at construction (an
268    /// interpolated call, a parameter without a length) is
269    /// `Unbounded`, never `Bounded(0)`, so it cannot reach here:
270    /// emptiness it discovers at evaluation is reported by the
271    /// per-clause yields instead ([`super::runtime::ClauseYield`]).
272    EmptySource {
273        /// The clause's element name.
274        var: String,
275        /// The source's canonical text, when it has one.
276        source: Option<String>,
277    },
278
279    /// Singleton variant of a combinator: `zip([c], _)`,
280    /// `cartesian(c)`, `union(c)`. Identity per spec §4.2 I1-I3;
281    /// the optimizer's R0a elides it.
282    SingletonCombinator {
283        /// The combinator with one child.
284        combinator: &'static str,
285    },
286}
287
288impl std::fmt::Display for ValidationWarning {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        match self {
291            Self::DegenerateGeometric { strategy } => write!(
292                f,
293                "`{strategy:?}` over a one-axis input collapses to its ends; use `Lex` with a truncation, or restate over a multi-axis cartesian"
294            ),
295            Self::LhsDegenerate => {
296                write!(f, "`Lhs` over a one-axis input is `Shuffle`; say `Shuffle`")
297            }
298            Self::TriviallyTrueFilter => write!(f, "the filter is always true; drop it"),
299            Self::TriviallyFalseFilter => write!(
300                f,
301                "the filter is always false; the comprehension dispenses nothing"
302            ),
303            Self::SingletonCombinator { combinator } => write!(
304                f,
305                "`{combinator}` over one child is that child; the wrapper adds nothing"
306            ),
307            Self::EmptySource { var, source } => match source {
308                Some(text) => write!(
309                    f,
310                    "`{var} in {text}` has no values; every composition it takes part in dispenses nothing"
311                ),
312                None => write!(
313                    f,
314                    "`{var}` has no values; every composition it takes part in dispenses nothing"
315                ),
316            },
317        }
318    }
319}
320
321/// Validate a comprehension AST per spec §5.
322///
323/// In `Permissive` mode, V1-V9 errors abort with a typed
324/// [`ValidationError`] and degenerate-composition warnings
325/// accumulate into the returned [`ValidationReport`]. In
326/// `Strict` mode, the first warning is promoted to an error.
327pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
328    let mut report = ValidationReport {
329        warnings: Vec::new(),
330    };
331    visit(c, &mut report)?;
332    if mode == Mode::Strict
333        && let Some(warning) = report.warnings.first()
334    {
335        // Strict-mode promotion (spec §5.8): the first degenerate
336        // composition is the error.
337        return Err(ValidationError::StrictWarning(warning.clone()));
338    }
339    Ok(report)
340}
341
342fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
343    // Bottom-up: validate children first so each node sees
344    // already-well-formed operands per spec C2.
345    for child in c.children() {
346        visit(child, report)?;
347    }
348
349    match c {
350        Comprehension::Clause { name, source } => visit_clause(name, source, report),
351        Comprehension::Cartesian { children } => visit_cartesian(children, report),
352        Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
353        Comprehension::Union { children } => visit_union(children, report),
354        Comprehension::Filter { child, predicate } => visit_filter(child, predicate, report),
355        Comprehension::Order {
356            child,
357            strategy,
358            truncation,
359            ..
360        } => visit_order(child, *strategy, *truncation, report),
361    }
362}
363
364fn visit_clause(
365    name: &str,
366    source: &Source,
367    report: &mut ValidationReport,
368) -> Result<(), ValidationError> {
369    // Degenerate composition (spec §5.8): a source the construction
370    // can already count, and the count is zero. Read off the
371    // cardinality algebra rather than matched shape by shape, so a
372    // source whose count is unknown is `Unbounded` and says nothing
373    // here.
374    if matches!(
375        source.cardinality(),
376        crate::iteration::comprehension::CardinalityClass::Bounded(0)
377    ) {
378        report.warnings.push(ValidationWarning::EmptySource {
379            var: name.to_string(),
380            source: source.to_text(),
381        });
382    }
383    // V8 source-side check: continuous source must have an
384    // integrable measure. Unbounded + Uniform is the canonical
385    // failure case.
386    if let Source::ContinuousInterval { interval, measure } = source
387        && !measure.is_integrable(std::slice::from_ref(interval))
388    {
389        let _ = report; // no warning here; this is a hard error
390        return Err(ValidationError::V8ContinuousRequirement {
391            reason: format!(
392                "continuous source has non-integrable measure: \
393                 interval [{}, {}] + {:?}",
394                interval.lo, interval.hi, measure
395            ),
396        });
397    }
398    // V8, on the declared measure: a named distribution's parameters
399    // must be the measure's own (`MeasureName::parameter_names`), or
400    // absent for the standard ones.
401    if let Source::Distribution {
402        distribution,
403        params,
404        ..
405    } = source
406        && let Err(reason) = distribution.resolve_params(params)
407    {
408        return Err(ValidationError::V8ContinuousRequirement { reason });
409    }
410    Ok(())
411}
412
413fn visit_cartesian(
414    children: &[Comprehension],
415    report: &mut ValidationReport,
416) -> Result<(), ValidationError> {
417    check_disjoint_names("cartesian", children)?;
418    if children.len() == 1 {
419        report
420            .warnings
421            .push(ValidationWarning::SingletonCombinator {
422                combinator: "cartesian",
423            });
424    }
425    Ok(())
426}
427
428fn visit_zip(
429    children: &[Comprehension],
430    mode: ZipMode,
431    report: &mut ValidationReport,
432) -> Result<(), ValidationError> {
433    check_disjoint_names("zip", children)?;
434
435    // V7: discrete-only children. We use the leaf-clause check
436    // here as the cheapest reliable proxy: walk to find any
437    // continuous source in any child.
438    for child in children {
439        if contains_continuous_source(child) {
440            return Err(ValidationError::V7ZipCardinality {
441                mode,
442                reason: "zip children must all be discrete; \
443                         a continuous source was found"
444                    .to_string(),
445            });
446        }
447    }
448
449    if children.len() == 1 {
450        report
451            .warnings
452            .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
453    }
454
455    // V6: Strict/Truncate require bounded children. Checked
456    // here on direct-clause children via source cardinality;
457    // combinator children are left to the metadata-based
458    // checks.
459    if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
460        for child in children {
461            if let Some(card) = direct_source_cardinality(child)
462                && matches!(card, CardinalityClass::Unbounded)
463            {
464                return Err(ValidationError::V6UnboundedDiscrete {
465                    operator: "zip",
466                    cardinality: card,
467                });
468            }
469        }
470    }
471
472    Ok(())
473}
474
475fn visit_union(
476    children: &[Comprehension],
477    report: &mut ValidationReport,
478) -> Result<(), ValidationError> {
479    // V9 first: all children must be discrete.
480    for child in children {
481        if contains_continuous_source(child) {
482            return Err(ValidationError::V9UnionClassMismatch {
483                reason: "union children must all be discrete; \
484                         a continuous source was found"
485                    .to_string(),
486            });
487        }
488    }
489
490    // V2: identical tuple shape (same names, same order).
491    if let Some(first) = children.first() {
492        let expected = first.coordinate_names();
493        for sibling in &children[1..] {
494            let actual = sibling.coordinate_names();
495            if actual != expected {
496                return Err(ValidationError::V2ShapeMismatch { expected, actual });
497            }
498        }
499    }
500
501    if children.len() == 1 {
502        report
503            .warnings
504            .push(ValidationWarning::SingletonCombinator {
505                combinator: "union",
506            });
507    }
508    Ok(())
509}
510
511fn visit_filter(
512    child: &Comprehension,
513    predicate: &str,
514    report: &mut ValidationReport,
515) -> Result<(), ValidationError> {
516    // V3: name closure — every `{name}` reference in the
517    // predicate must be in the child's coords OR resolved by
518    // the parent scope. The parent-scope half is the consumer's
519    // job; here we accumulate the unresolved-at-this-layer
520    // names and let the consumer decide.
521    let coords = child.coordinate_names();
522    let referenced = extract_interpolated_names(predicate);
523    let unresolved: Vec<String> = referenced
524        .into_iter()
525        .filter(|n| !coords.contains(n))
526        .collect();
527
528    // The consumer is responsible for the link-time check;
529    // we only error here when there's clearly nothing the
530    // parent could possibly provide. For now, emit no error —
531    // just record candidates for downstream consumption.
532    // (A structured "carry the unresolved set to the consumer"
533    // hand-off is not implemented.)
534    let _ = unresolved;
535
536    // §5.8 warnings for trivially-true / trivially-false
537    // predicates. We recognize the literal strings "true" and
538    // "false" as the bug-shaped cases; richer recognition
539    // happens when the predicate analyzer (Phase 5) lands.
540    let trimmed = predicate.trim();
541    if trimmed.eq_ignore_ascii_case("true") {
542        report.warnings.push(ValidationWarning::TriviallyTrueFilter);
543    } else if trimmed.eq_ignore_ascii_case("false") {
544        report
545            .warnings
546            .push(ValidationWarning::TriviallyFalseFilter);
547    }
548
549    let _ = child;
550    Ok(())
551}
552
553fn visit_order(
554    child: &Comprehension,
555    strategy: StrategyName,
556    truncation: Option<u64>,
557    report: &mut ValidationReport,
558) -> Result<(), ValidationError> {
559    // V4: per-strategy input-shape contract using the metadata
560    // algebra. V5's one-filter look-through is implemented by
561    // computing metadata against either the child directly OR
562    // (when the child is a Filter) against the filter's
563    // inner child.
564    let metadata_target = match child {
565        Comprehension::Filter { child: inner, .. } => inner.as_ref(),
566        other => other,
567    };
568
569    // If we'd need to look through more than one filter layer
570    // (nested filters), V5 says fold first.
571    if !matches!(strategy, StrategyName::Lex)
572        && matches!(metadata_target, Comprehension::Filter { .. })
573    {
574        return Err(ValidationError::V4InputShape {
575            strategy,
576            reason: "non-Lex strategy applied to nested filter; \
577                     fold filters first (spec F1 / R6)"
578                .to_string(),
579        });
580    }
581
582    let target_metadata = metadata_target.metadata();
583    check_strategy_input_shape(strategy, &target_metadata, report)?;
584
585    // V6: non-Lex strategy requires bounded input. Now via
586    // metadata cardinality (not the source-only direct check).
587    if !matches!(strategy, StrategyName::Lex)
588        && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
589    {
590        return Err(ValidationError::V6UnboundedDiscrete {
591            operator: "order",
592            cardinality: target_metadata.cardinality.clone(),
593        });
594    }
595
596    // V8: continuous input requires sampling — wrapped order
597    // with finite truncation is the discharge mechanism.
598    let is_continuous = matches!(
599        target_metadata.cardinality,
600        CardinalityClass::Continuous { .. }
601            | CardinalityClass::ContinuousAtMost { .. }
602            | CardinalityClass::Hybrid(_)
603    );
604    if is_continuous {
605        if truncation.is_none() {
606            return Err(ValidationError::V8ContinuousRequirement {
607                reason: "continuous comprehension requires order(_, \
608                         sampling-strategy, Some(n)) with finite \
609                         truncation"
610                    .to_string(),
611            });
612        }
613        if matches!(strategy, StrategyName::Lex) {
614            return Err(ValidationError::V8ContinuousRequirement {
615                reason: "Lex does not sample continuous inputs; use \
616                         Halton / Sobol / Lhs / Shuffle / Extrema"
617                    .to_string(),
618            });
619        }
620    }
621
622    Ok(())
623}
624
625/// Per-strategy V4 input-shape check using the metadata
626/// algebra's `IndexFn` variants. Implements the per-strategy
627/// table from spec §3.6:
628///
629/// | Strategy | Accepted IndexFn |
630/// |---|---|
631/// | Lex | any (incl. None) |
632/// | ReverseLex | any non-None discrete |
633/// | Shuffle, Halton, Sobol | any non-None |
634/// | Lhs | any non-None (Lattice multi-axis = native; 1-axis = degenerate) |
635/// | Extrema | any non-None (Lattice ≥2 = native; 1-axis or non-Lattice = degenerate or continuous box) |
636/// | Shells, Diagonal, Antidiagonal | non-None discrete only |
637fn check_strategy_input_shape(
638    strategy: StrategyName,
639    metadata: &Metadata,
640    report: &mut ValidationReport,
641) -> Result<(), ValidationError> {
642    // Lex accepts anything including None.
643    if matches!(strategy, StrategyName::Lex) {
644        return Ok(());
645    }
646
647    let idx = match &metadata.index_addressable {
648        Some(i) => i,
649        None => {
650            return Err(ValidationError::V4InputShape {
651                strategy,
652                reason: "input has no closed-form index function \
653                         (raw filter output, dependent cartesian, or \
654                         nested non-Lex order)"
655                    .to_string(),
656            });
657        }
658    };
659
660    // Continuous / Hybrid acceptance per strategy.
661    let has_continuous = idx.has_continuous_axis();
662    if has_continuous {
663        match strategy {
664            // Index-sampling that accepts continuous.
665            StrategyName::Shuffle
666            | StrategyName::Halton
667            | StrategyName::Sobol
668            | StrategyName::Lhs => {}
669            // Extrema accepts continuous boxes (per spec §3.6).
670            StrategyName::Extrema => {}
671            // Everything else rejects continuous.
672            StrategyName::ReverseLex
673            | StrategyName::Shells
674            | StrategyName::Diagonal
675            | StrategyName::Antidiagonal => {
676                return Err(ValidationError::V4InputShape {
677                    strategy,
678                    reason: format!("{} does not accept continuous input", strategy.as_str()),
679                });
680            }
681            StrategyName::Lex => unreachable!("Lex handled above"),
682        }
683        // Continuous + Lhs/Extrema on 1-D is the same kind of
684        // degenerate as discrete 1-D; emit a warning.
685        if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
686            let dim = continuous_dim(idx);
687            if dim < 2 {
688                if matches!(strategy, StrategyName::Lhs) {
689                    report.warnings.push(ValidationWarning::LhsDegenerate);
690                } else {
691                    report
692                        .warnings
693                        .push(ValidationWarning::DegenerateGeometric { strategy });
694                }
695            }
696        }
697        return Ok(());
698    }
699
700    // Discrete path.
701    // Lattice-geometric strategies require Lattice IndexFn.
702    if strategy.is_lattice_geometric() {
703        match idx {
704            IndexFn::Lattice { axis_sizes } => {
705                if axis_sizes.len() < 2 {
706                    report
707                        .warnings
708                        .push(ValidationWarning::DegenerateGeometric { strategy });
709                }
710            }
711            // Concatenation (union) is V4-rejected for lattice-
712            // geometric — heterogeneous index space.
713            IndexFn::Concatenation { .. } => {
714                return Err(ValidationError::V4InputShape {
715                    strategy,
716                    reason: format!(
717                        "{} requires a cartesian input; got union",
718                        strategy.as_str()
719                    ),
720                });
721            }
722            // Lockstep / Modular (zip) — 1-D index space; these
723            // strategies in 1-D collapse degenerately, but per
724            // §5.8 we allow with warning rather than rejecting.
725            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
726                report
727                    .warnings
728                    .push(ValidationWarning::DegenerateGeometric { strategy });
729            }
730            // Unreachable: continuous handled above.
731            IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
732        }
733        return Ok(());
734    }
735
736    // Lhs on discrete: degenerate over 1-axis Lattice / Lockstep / Modular.
737    if matches!(strategy, StrategyName::Lhs) {
738        match idx {
739            IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
740                report.warnings.push(ValidationWarning::LhsDegenerate);
741            }
742            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
743                report.warnings.push(ValidationWarning::LhsDegenerate);
744            }
745            _ => {}
746        }
747    }
748
749    // ReverseLex / Shuffle / Halton / Sobol on any non-None
750    // discrete IndexFn: always accepted (no degeneracy
751    // warning).
752    Ok(())
753}
754
755fn continuous_dim(idx: &IndexFn) -> usize {
756    match idx {
757        IndexFn::Continuous { intervals, .. } => intervals.len(),
758        IndexFn::Hybrid {
759            discrete_axes,
760            continuous_axes,
761            ..
762        } => discrete_axes.len() + continuous_axes.len(),
763        _ => 0,
764    }
765}
766
767fn check_disjoint_names(
768    combinator: &'static str,
769    children: &[Comprehension],
770) -> Result<(), ValidationError> {
771    let mut seen: Vec<String> = Vec::new();
772    for child in children {
773        for name in child.coordinate_names() {
774            if seen.contains(&name) {
775                return Err(ValidationError::V1DuplicateName { combinator, name });
776            }
777            seen.push(name);
778        }
779    }
780    Ok(())
781}
782
783fn contains_continuous_source(c: &Comprehension) -> bool {
784    match c {
785        Comprehension::Clause { source, .. } => source.is_continuous(),
786        Comprehension::Cartesian { children }
787        | Comprehension::Zip { children, .. }
788        | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
789        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
790            contains_continuous_source(child)
791        }
792    }
793}
794
795/// Return the source's cardinality if `c` is a direct clause;
796/// `None` otherwise. Used by the V6 check on direct-clause
797/// children; combinator children are covered by the
798/// metadata-based checks.
799fn direct_source_cardinality(c: &Comprehension) -> Option<CardinalityClass> {
800    match c {
801        Comprehension::Clause { source, .. } => Some(source.cardinality()),
802        _ => None,
803    }
804}
805
806/// Extract `{name}` interpolation references from a predicate
807/// string. Handles only the simple `{name}` form; nested
808/// expressions and escapes are out of scope for V3's parse-time
809/// check (the consumer handles richer Polydat expression analysis).
810fn extract_interpolated_names(predicate: &str) -> Vec<String> {
811    let mut out = Vec::new();
812    let bytes = predicate.as_bytes();
813    let mut i = 0;
814    while i < bytes.len() {
815        if bytes[i] == b'{'
816            && let Some(close) = predicate[i + 1..].find('}')
817        {
818            let name = predicate[i + 1..i + 1 + close].trim();
819            if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
820                out.push(name.to_string());
821            }
822            i += close + 2;
823            continue;
824        }
825        i += 1;
826    }
827    out
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
834    use crate::iteration::comprehension::source::{LiteralValue, Source};
835
836    fn clause(name: &str, vs: &[i64]) -> Comprehension {
837        Comprehension::clause(
838            name,
839            Source::Literal {
840                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
841            },
842        )
843    }
844
845    fn empty_warnings(c: &Comprehension) -> Vec<String> {
846        validate(c, Mode::Permissive)
847            .expect("an empty source is degenerate, not invalid")
848            .warnings
849            .into_iter()
850            .filter_map(|w| match w {
851                ValidationWarning::EmptySource { var, .. } => Some(var),
852                _ => None,
853            })
854            .collect()
855    }
856
857    /// A source the construction can already count as empty is a
858    /// degenerate composition, warned and not refused.
859    #[test]
860    fn a_provably_empty_source_warns() {
861        assert_eq!(empty_warnings(&clause("x", &[])), ["x"]);
862        assert_eq!(
863            empty_warnings(&Comprehension::clause(
864                "k",
865                Source::IntRange {
866                    lo: 5,
867                    hi: 5,
868                    step: 1,
869                },
870            )),
871            ["k"],
872            "a half-open range over no values is the same fact"
873        );
874    }
875
876    /// The rule reads the cardinality algebra, so a source whose count
877    /// is not known at construction says nothing here. Its emptiness,
878    /// if any, is the evaluator's to report.
879    #[test]
880    fn a_source_of_unknown_count_does_not_warn() {
881        let generator = Comprehension::clause(
882            "g",
883            Source::Generator {
884                expr: "matching_profiles('a')".into(),
885                cardinality_hint: None,
886            },
887        );
888        assert!(empty_warnings(&generator).is_empty());
889
890        let param = Comprehension::clause(
891            "p",
892            Source::WorkloadParamList {
893                name: "sizes".into(),
894                len_hint: None,
895            },
896        );
897        assert!(empty_warnings(&param).is_empty());
898    }
899
900    /// A generator the compile did count, and counted as zero, is
901    /// known at construction and warns like a literal.
902    #[test]
903    fn a_generator_counted_as_zero_warns() {
904        let counted = Comprehension::clause(
905            "g",
906            Source::Generator {
907                expr: "matching_profiles('nope')".into(),
908                cardinality_hint: Some(0),
909            },
910        );
911        assert_eq!(empty_warnings(&counted), ["g"]);
912    }
913
914    /// Non-blocking by default, the error under `Strict` — the same
915    /// duality every other degenerate composition has.
916    #[test]
917    fn an_empty_source_is_the_error_under_strict() {
918        let c = clause("x", &[]);
919        assert!(validate(&c, Mode::Permissive).is_ok());
920        match validate(&c, Mode::Strict) {
921            Err(ValidationError::StrictWarning(ValidationWarning::EmptySource { var, .. })) => {
922                assert_eq!(var, "x");
923            }
924            other => panic!("expected the empty source to be the strict error, got {other:?}"),
925        }
926    }
927
928    /// Nested, so a host sees which clause of a product is the cause.
929    #[test]
930    fn an_empty_clause_is_named_inside_a_cartesian() {
931        let c = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[])]);
932        assert_eq!(empty_warnings(&c), ["b"]);
933    }
934
935    fn continuous_clause(name: &str) -> Comprehension {
936        Comprehension::clause(
937            name,
938            Source::ContinuousInterval {
939                interval: Interval::closed(0.0, 1.0),
940                measure: ProductMeasure::Uniform,
941            },
942        )
943    }
944
945    #[test]
946    fn v1_rejects_duplicate_names_in_cartesian() {
947        let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
948        let result = validate(&bad, Mode::Permissive);
949        assert!(matches!(
950            result,
951            Err(ValidationError::V1DuplicateName {
952                combinator: "cartesian",
953                ..
954            })
955        ));
956    }
957
958    #[test]
959    fn v1_accepts_disjoint_names() {
960        let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
961        assert!(validate(&ok, Mode::Permissive).is_ok());
962    }
963
964    #[test]
965    fn v2_rejects_union_shape_mismatch() {
966        let bad = Comprehension::union(vec![
967            Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
968            Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
969        ]);
970        let result = validate(&bad, Mode::Permissive);
971        assert!(matches!(
972            result,
973            Err(ValidationError::V2ShapeMismatch { .. })
974        ));
975    }
976
977    #[test]
978    fn v2_accepts_matching_union_shape() {
979        let ok = Comprehension::union(vec![
980            Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
981            Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
982        ]);
983        assert!(validate(&ok, Mode::Permissive).is_ok());
984    }
985
986    #[test]
987    fn v4_rejects_lattice_geometric_over_union() {
988        let bad = Comprehension::order(
989            Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
990            StrategyName::Extrema,
991            Some(2),
992        );
993        assert!(matches!(
994            validate(&bad, Mode::Permissive),
995            Err(ValidationError::V4InputShape {
996                strategy: StrategyName::Extrema,
997                ..
998            })
999        ));
1000    }
1001
1002    #[test]
1003    fn v4_lattice_geometric_over_1axis_warns_not_errors() {
1004        let degenerate =
1005            Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1006        let report = validate(&degenerate, Mode::Permissive).unwrap();
1007        assert!(report.warnings.iter().any(|w| matches!(
1008            w,
1009            ValidationWarning::DegenerateGeometric {
1010                strategy: StrategyName::Extrema
1011            }
1012        )));
1013    }
1014
1015    #[test]
1016    fn v4_strict_mode_promotes_warning() {
1017        let degenerate =
1018            Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1019        assert!(validate(&degenerate, Mode::Strict).is_err());
1020    }
1021
1022    #[test]
1023    fn v7_rejects_continuous_in_zip() {
1024        let bad = Comprehension::zip(
1025            vec![continuous_clause("alpha"), continuous_clause("beta")],
1026            ZipMode::Strict,
1027        );
1028        assert!(matches!(
1029            validate(&bad, Mode::Permissive),
1030            Err(ValidationError::V7ZipCardinality { .. })
1031        ));
1032    }
1033
1034    #[test]
1035    fn v8_rejects_continuous_without_sampling() {
1036        // Continuous clause at the outermost level — no order.
1037        let bad = continuous_clause("theta");
1038        assert!(validate(&bad, Mode::Permissive).is_ok());
1039        // The error fires at the outermost reachable point; for
1040        // a bare clause we need a wrapping check the consumer
1041        // does. Wrap it in order(Lex, None) — Lex doesn't sample
1042        // continuous; V8 fires.
1043        let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
1044        assert!(matches!(
1045            validate(&bad_lex, Mode::Permissive),
1046            Err(ValidationError::V8ContinuousRequirement { .. })
1047        ));
1048    }
1049
1050    #[test]
1051    fn v8_accepts_continuous_with_sampling() {
1052        let ok = Comprehension::order(
1053            Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
1054            StrategyName::Halton,
1055            Some(100),
1056        );
1057        assert!(validate(&ok, Mode::Permissive).is_ok());
1058    }
1059
1060    #[test]
1061    fn v8_rejects_unbounded_uniform_at_source() {
1062        let bad = Comprehension::clause(
1063            "x",
1064            Source::ContinuousInterval {
1065                interval: Interval {
1066                    lo: 0.0,
1067                    hi: f64::INFINITY,
1068                    lo_open: false,
1069                    hi_open: true,
1070                },
1071                measure: ProductMeasure::Uniform,
1072            },
1073        );
1074        assert!(matches!(
1075            validate(&bad, Mode::Permissive),
1076            Err(ValidationError::V8ContinuousRequirement { .. })
1077        ));
1078    }
1079
1080    #[test]
1081    fn v9_rejects_continuous_in_union() {
1082        let bad = Comprehension::union(vec![
1083            Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1084            Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1085        ]);
1086        // Note: this also trips V9 via continuous-in-union before V2 even fires.
1087        assert!(matches!(
1088            validate(&bad, Mode::Permissive),
1089            Err(ValidationError::V9UnionClassMismatch { .. })
1090        ));
1091    }
1092
1093    #[test]
1094    fn singleton_combinator_warns() {
1095        let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
1096        let report = validate(&degenerate, Mode::Permissive).unwrap();
1097        assert!(report.warnings.iter().any(|w| matches!(
1098            w,
1099            ValidationWarning::SingletonCombinator {
1100                combinator: "cartesian"
1101            }
1102        )));
1103    }
1104
1105    #[test]
1106    fn trivially_true_filter_warns() {
1107        let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
1108        let report = validate(&degenerate, Mode::Permissive).unwrap();
1109        assert!(
1110            report
1111                .warnings
1112                .iter()
1113                .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
1114        );
1115    }
1116
1117    #[test]
1118    fn name_extraction_handles_simple_predicates() {
1119        assert_eq!(extract_interpolated_names("{k} > 0"), vec!["k"]);
1120        assert_eq!(
1121            extract_interpolated_names("{k} * {limit} <= 1000"),
1122            vec!["k", "limit"]
1123        );
1124        assert_eq!(
1125            extract_interpolated_names("no refs here"),
1126            Vec::<String>::new()
1127        );
1128    }
1129}