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 best-effort** — this module, when a caller
16//!    runs `validate` on the AST, against the AST's static
17//!    metadata-derived [`IndexFn`]; catches shape violations
18//!    the static estimate can prove. For
19//!    [`crate::iteration::comprehension::eval_source::EvalClass::Static`]
20//!    sources the static IndexFn equals the runtime IndexFn,
21//!    so this fire is exact; for `ContextRequired` sources
22//!    (Generator without registry recognition,
23//!    WorkloadParamList) the static estimate may be
24//!    conservative (uses `cardinality_hint`, or `None` if
25//!    absent) and the strategy-invocation-time fire below
26//!    is load-bearing.
27//! 2. **Strategy-invocation-time (load-bearing)** —
28//!    [`crate::iteration::comprehension::runtime::evaluate_for_iteration`]'s
29//!    `apply_order` fires
30//!    [`crate::iteration::comprehension::strategies::Strategy::accepts_input`]
31//!    against the [`crate::iteration::comprehension::eval_source::EvaluatedSource`]'s
32//!    actual `index_fn` after source evaluation. This is the
33//!    definitive V4 check per spec §10.7.8.
34
35use serde::{Deserialize, Serialize};
36
37use super::ast::Comprehension;
38use super::cardinality::CardinalityClass;
39use super::metadata::{IndexFn, Metadata};
40use super::source::Source;
41use super::strategy::{StrategyName, ZipMode};
42
43/// Validation mode per spec §5.8.
44///
45/// `Permissive` (default) enforces V1-V9 as errors and surfaces
46/// degenerate-composition warnings non-blockingly. `Strict`
47/// promotes those warnings to errors.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
49pub enum Mode {
50    #[default]
51    /// V1 through V9 are errors; degenerate compositions are warnings.
52    Permissive,
53    /// Degenerate compositions are errors too.
54    Strict,
55}
56
57/// Result of a validation pass.
58#[derive(Debug, Clone)]
59pub struct ValidationReport {
60    /// The warnings the pass raised.
61    pub warnings: Vec<ValidationWarning>,
62}
63
64/// V-axiom violation. Each variant carries enough context to
65/// produce a useful diagnostic at the call site.
66#[derive(Debug, Clone, PartialEq)]
67pub enum ValidationError {
68    /// V1 — cartesian or zip children share a name.
69    V1DuplicateName {
70        /// The combinator whose children share the name.
71        combinator: &'static str,
72        /// The duplicated name.
73        name: String,
74    },
75
76    /// V2 — union children disagree on tuple shape.
77    V2ShapeMismatch {
78        /// The tuple shape of the first child.
79        expected: Vec<String>,
80        /// The shape that differs.
81        actual: Vec<String>,
82    },
83
84    /// V3 — filter predicate references a name neither in the
85    /// child's coordinates nor in the parent scope. Parser-time
86    /// validation only — link-time (parent scope) check lives
87    /// in the consumer.
88    ///
89    /// `coords` is the wrapped comprehension's coordinate set;
90    /// the predicate may also reference names from the parent
91    /// scope which this layer doesn't see.
92    V3UnresolvedNames {
93        /// The predicate text.
94        predicate: String,
95        /// The coordinates the comprehension binds.
96        coords: Vec<String>,
97        /// The names the predicate references that are neither coordinates nor known here.
98        unresolved: Vec<String>,
99    },
100
101    /// V4 — strategy applied to an input whose metadata-derived
102    /// [`IndexFn`] it cannot accept (per-strategy table in
103    /// `check_strategy_input_shape`). V5's one-filter
104    /// look-through is honoured; nested filters are rejected.
105    V4InputShape {
106        /// The strategy applied.
107        strategy: StrategyName,
108        /// Why its input's shape is unacceptable.
109        reason: String,
110    },
111
112    /// V6 — non-Lex order or Strict/Truncate zip applied to an
113    /// `Unbounded` discrete input.
114    V6UnboundedDiscrete {
115        /// The operator applied.
116        operator: &'static str,
117        /// The unbounded input's cardinality class.
118        cardinality: CardinalityClass,
119    },
120
121    /// V7 — zip cardinality contract violated. Three sub-cases:
122    /// Strict-mode mismatch, mixed-class children, or any
123    /// continuous child.
124    V7ZipCardinality {
125        /// The zip mode in force.
126        mode: ZipMode,
127        /// Which contract was violated.
128        reason: String,
129    },
130
131    /// V8 — continuous source requires explicit sampling OR
132    /// source declares a non-integrable measure.
133    V8ContinuousRequirement {
134        /// What the source lacks.
135        reason: String,
136    },
137
138    /// V9 — union children include a continuous or mixed-class
139    /// child.
140    V9UnionClassMismatch {
141        /// Which child mismatches, and how.
142        reason: String,
143    },
144}
145
146/// Non-blocking warning for degenerate-but-defined compositions
147/// per spec §5.8.
148#[derive(Debug, Clone, PartialEq)]
149pub enum ValidationWarning {
150    /// Lattice-geometric strategy (`Extrema` / `Shells` /
151    /// `Diagonal` / `Antidiagonal`) over a 1-axis input.
152    /// Collapses to {first, last} or a trivial walk; usually
153    /// not what the author meant.
154    DegenerateGeometric {
155        /// The strategy applied.
156        strategy: StrategyName,
157    },
158
159    /// `Lhs` over a 1-axis input. Equivalent to `Shuffle`;
160    /// two names for one behavior.
161    LhsDegenerate,
162
163    /// `filter(c, "true")`. Empty-effect filter — usually a
164    /// bug-shaped predicate. The optimizer's R0a elides it.
165    TriviallyTrueFilter,
166
167    /// `filter(c, "false")`. Empty dispense sequence. If
168    /// intentional, use an empty literal source; otherwise the
169    /// predicate is bug-shaped.
170    TriviallyFalseFilter,
171
172    /// Singleton variant of a combinator: `zip([c], _)`,
173    /// `cartesian(c)`, `union(c)`. Identity per spec §4.2 I1-I3;
174    /// the optimizer's R0a elides it.
175    SingletonCombinator {
176        /// The combinator with one child.
177        combinator: &'static str,
178    },
179}
180
181/// Validate a comprehension AST per spec §5.
182///
183/// In `Permissive` mode, V1-V9 errors abort with a typed
184/// [`ValidationError`] and degenerate-composition warnings
185/// accumulate into the returned [`ValidationReport`]. In
186/// `Strict` mode, the first warning is promoted to an error.
187pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
188    let mut report = ValidationReport {
189        warnings: Vec::new(),
190    };
191    visit(c, &mut report)?;
192    if mode == Mode::Strict
193        && let Some(_warning) = report.warnings.first()
194    {
195        // Strict-mode promotion: encode the first warning as a
196        // V8-like error using a synthetic reason. We don't
197        // currently have a dedicated ValidationError variant
198        // for "warning promoted"; the diagnostic is still
199        // useful because the warning itself carries the
200        // location-equivalent context.
201        return Err(ValidationError::V8ContinuousRequirement {
202            reason: format!(
203                "strict mode: warning promoted: {:?}",
204                report.warnings.first().unwrap()
205            ),
206        });
207    }
208    Ok(report)
209}
210
211fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
212    // Bottom-up: validate children first so each node sees
213    // already-well-formed operands per spec C2.
214    for child in c.children() {
215        visit(child, report)?;
216    }
217
218    match c {
219        Comprehension::Clause { source, .. } => visit_clause(source, report),
220        Comprehension::Cartesian { children } => visit_cartesian(children, report),
221        Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
222        Comprehension::Union { children } => visit_union(children, report),
223        Comprehension::Filter { child, predicate } => visit_filter(child, predicate, report),
224        Comprehension::Order {
225            child,
226            strategy,
227            truncation,
228        } => visit_order(child, *strategy, *truncation, report),
229    }
230}
231
232fn visit_clause(source: &Source, report: &mut ValidationReport) -> Result<(), ValidationError> {
233    // V8 source-side check: continuous source must have an
234    // integrable measure. Unbounded + Uniform is the canonical
235    // failure case.
236    if let Source::ContinuousInterval { interval, measure } = source
237        && !measure.is_integrable(std::slice::from_ref(interval))
238    {
239        let _ = report; // no warning here; this is a hard error
240        return Err(ValidationError::V8ContinuousRequirement {
241            reason: format!(
242                "continuous source has non-integrable measure: \
243                 interval [{}, {}] + {:?}",
244                interval.lo, interval.hi, measure
245            ),
246        });
247    }
248    Ok(())
249}
250
251fn visit_cartesian(
252    children: &[Comprehension],
253    report: &mut ValidationReport,
254) -> Result<(), ValidationError> {
255    check_disjoint_names("cartesian", children)?;
256    if children.len() == 1 {
257        report
258            .warnings
259            .push(ValidationWarning::SingletonCombinator {
260                combinator: "cartesian",
261            });
262    }
263    Ok(())
264}
265
266fn visit_zip(
267    children: &[Comprehension],
268    mode: ZipMode,
269    report: &mut ValidationReport,
270) -> Result<(), ValidationError> {
271    check_disjoint_names("zip", children)?;
272
273    // V7: discrete-only children. We use the leaf-clause check
274    // here as the cheapest reliable proxy: walk to find any
275    // continuous source in any child.
276    for child in children {
277        if contains_continuous_source(child) {
278            return Err(ValidationError::V7ZipCardinality {
279                mode,
280                reason: "zip children must all be discrete; \
281                         a continuous source was found"
282                    .to_string(),
283            });
284        }
285    }
286
287    if children.len() == 1 {
288        report
289            .warnings
290            .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
291    }
292
293    // V6: Strict/Truncate require bounded children. Checked
294    // here on direct-clause children via source cardinality;
295    // combinator children are left to the metadata-based
296    // checks.
297    if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
298        for child in children {
299            if let Some(card) = direct_source_cardinality(child)
300                && matches!(card, CardinalityClass::Unbounded)
301            {
302                return Err(ValidationError::V6UnboundedDiscrete {
303                    operator: "zip",
304                    cardinality: card,
305                });
306            }
307        }
308    }
309
310    Ok(())
311}
312
313fn visit_union(
314    children: &[Comprehension],
315    report: &mut ValidationReport,
316) -> Result<(), ValidationError> {
317    // V9 first: all children must be discrete.
318    for child in children {
319        if contains_continuous_source(child) {
320            return Err(ValidationError::V9UnionClassMismatch {
321                reason: "union children must all be discrete; \
322                         a continuous source was found"
323                    .to_string(),
324            });
325        }
326    }
327
328    // V2: identical tuple shape (same names, same order).
329    if let Some(first) = children.first() {
330        let expected = first.coordinate_names();
331        for sibling in &children[1..] {
332            let actual = sibling.coordinate_names();
333            if actual != expected {
334                return Err(ValidationError::V2ShapeMismatch { expected, actual });
335            }
336        }
337    }
338
339    if children.len() == 1 {
340        report
341            .warnings
342            .push(ValidationWarning::SingletonCombinator {
343                combinator: "union",
344            });
345    }
346    Ok(())
347}
348
349fn visit_filter(
350    child: &Comprehension,
351    predicate: &str,
352    report: &mut ValidationReport,
353) -> Result<(), ValidationError> {
354    // V3: name closure — every `{name}` reference in the
355    // predicate must be in the child's coords OR resolved by
356    // the parent scope. The parent-scope half is the consumer's
357    // job; here we accumulate the unresolved-at-this-layer
358    // names and let the consumer decide.
359    let coords = child.coordinate_names();
360    let referenced = extract_interpolated_names(predicate);
361    let unresolved: Vec<String> = referenced
362        .into_iter()
363        .filter(|n| !coords.contains(n))
364        .collect();
365
366    // The consumer is responsible for the link-time check;
367    // we only error here when there's clearly nothing the
368    // parent could possibly provide. For now, emit no error —
369    // just record candidates for downstream consumption.
370    // (A structured "carry the unresolved set to the consumer"
371    // hand-off is not implemented.)
372    let _ = unresolved;
373
374    // §5.8 warnings for trivially-true / trivially-false
375    // predicates. We recognize the literal strings "true" and
376    // "false" as the bug-shaped cases; richer recognition
377    // happens when the predicate analyzer (Phase 5) lands.
378    let trimmed = predicate.trim();
379    if trimmed.eq_ignore_ascii_case("true") {
380        report.warnings.push(ValidationWarning::TriviallyTrueFilter);
381    } else if trimmed.eq_ignore_ascii_case("false") {
382        report
383            .warnings
384            .push(ValidationWarning::TriviallyFalseFilter);
385    }
386
387    let _ = child;
388    Ok(())
389}
390
391fn visit_order(
392    child: &Comprehension,
393    strategy: StrategyName,
394    truncation: Option<u64>,
395    report: &mut ValidationReport,
396) -> Result<(), ValidationError> {
397    // V4: per-strategy input-shape contract using the metadata
398    // algebra. V5's one-filter look-through is implemented by
399    // computing metadata against either the child directly OR
400    // (when the child is a Filter) against the filter's
401    // inner child.
402    let metadata_target = match child {
403        Comprehension::Filter { child: inner, .. } => inner.as_ref(),
404        other => other,
405    };
406
407    // If we'd need to look through more than one filter layer
408    // (nested filters), V5 says fold first.
409    if !matches!(strategy, StrategyName::Lex)
410        && matches!(metadata_target, Comprehension::Filter { .. })
411    {
412        return Err(ValidationError::V4InputShape {
413            strategy,
414            reason: "non-Lex strategy applied to nested filter; \
415                     fold filters first (spec F1 / R6)"
416                .to_string(),
417        });
418    }
419
420    let target_metadata = metadata_target.metadata();
421    check_strategy_input_shape(strategy, &target_metadata, report)?;
422
423    // V6: non-Lex strategy requires bounded input. Now via
424    // metadata cardinality (not the source-only direct check).
425    if !matches!(strategy, StrategyName::Lex)
426        && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
427    {
428        return Err(ValidationError::V6UnboundedDiscrete {
429            operator: "order",
430            cardinality: target_metadata.cardinality.clone(),
431        });
432    }
433
434    // V8: continuous input requires sampling — wrapped order
435    // with finite truncation is the discharge mechanism.
436    let is_continuous = matches!(
437        target_metadata.cardinality,
438        CardinalityClass::Continuous { .. }
439            | CardinalityClass::ContinuousAtMost { .. }
440            | CardinalityClass::Hybrid(_)
441    );
442    if is_continuous {
443        if truncation.is_none() {
444            return Err(ValidationError::V8ContinuousRequirement {
445                reason: "continuous comprehension requires order(_, \
446                         sampling-strategy, Some(n)) with finite \
447                         truncation"
448                    .to_string(),
449            });
450        }
451        if matches!(strategy, StrategyName::Lex) {
452            return Err(ValidationError::V8ContinuousRequirement {
453                reason: "Lex does not sample continuous inputs; use \
454                         Halton / Sobol / Lhs / Shuffle / Extrema"
455                    .to_string(),
456            });
457        }
458    }
459
460    Ok(())
461}
462
463/// Per-strategy V4 input-shape check using the metadata
464/// algebra's `IndexFn` variants. Implements the per-strategy
465/// table from spec §3.6:
466///
467/// | Strategy | Accepted IndexFn |
468/// |---|---|
469/// | Lex | any (incl. None) |
470/// | ReverseLex | any non-None discrete |
471/// | Shuffle, Halton, Sobol | any non-None |
472/// | Lhs | any non-None (Lattice multi-axis = native; 1-axis = degenerate) |
473/// | Extrema | any non-None (Lattice ≥2 = native; 1-axis or non-Lattice = degenerate or continuous box) |
474/// | Shells, Diagonal, Antidiagonal | non-None discrete only |
475fn check_strategy_input_shape(
476    strategy: StrategyName,
477    metadata: &Metadata,
478    report: &mut ValidationReport,
479) -> Result<(), ValidationError> {
480    // Lex accepts anything including None.
481    if matches!(strategy, StrategyName::Lex) {
482        return Ok(());
483    }
484
485    let idx = match &metadata.index_addressable {
486        Some(i) => i,
487        None => {
488            return Err(ValidationError::V4InputShape {
489                strategy,
490                reason: "input has no closed-form index function \
491                         (raw filter output, dependent cartesian, or \
492                         nested non-Lex order)"
493                    .to_string(),
494            });
495        }
496    };
497
498    // Continuous / Hybrid acceptance per strategy.
499    let has_continuous = idx.has_continuous_axis();
500    if has_continuous {
501        match strategy {
502            // Index-sampling that accepts continuous.
503            StrategyName::Shuffle
504            | StrategyName::Halton
505            | StrategyName::Sobol
506            | StrategyName::Lhs => {}
507            // Extrema accepts continuous boxes (per spec §3.6).
508            StrategyName::Extrema => {}
509            // Everything else rejects continuous.
510            StrategyName::ReverseLex
511            | StrategyName::Shells
512            | StrategyName::Diagonal
513            | StrategyName::Antidiagonal => {
514                return Err(ValidationError::V4InputShape {
515                    strategy,
516                    reason: format!("{} does not accept continuous input", strategy.as_str()),
517                });
518            }
519            StrategyName::Lex => unreachable!("Lex handled above"),
520        }
521        // Continuous + Lhs/Extrema on 1-D is the same kind of
522        // degenerate as discrete 1-D; emit a warning.
523        if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
524            let dim = continuous_dim(idx);
525            if dim < 2 {
526                if matches!(strategy, StrategyName::Lhs) {
527                    report.warnings.push(ValidationWarning::LhsDegenerate);
528                } else {
529                    report
530                        .warnings
531                        .push(ValidationWarning::DegenerateGeometric { strategy });
532                }
533            }
534        }
535        return Ok(());
536    }
537
538    // Discrete path.
539    // Lattice-geometric strategies require Lattice IndexFn.
540    if strategy.is_lattice_geometric() {
541        match idx {
542            IndexFn::Lattice { axis_sizes } => {
543                if axis_sizes.len() < 2 {
544                    report
545                        .warnings
546                        .push(ValidationWarning::DegenerateGeometric { strategy });
547                }
548            }
549            // Concatenation (union) is V4-rejected for lattice-
550            // geometric — heterogeneous index space.
551            IndexFn::Concatenation { .. } => {
552                return Err(ValidationError::V4InputShape {
553                    strategy,
554                    reason: format!(
555                        "{} requires a cartesian input; got union",
556                        strategy.as_str()
557                    ),
558                });
559            }
560            // Lockstep / Modular (zip) — 1-D index space; these
561            // strategies in 1-D collapse degenerately, but per
562            // §5.8 we allow with warning rather than rejecting.
563            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
564                report
565                    .warnings
566                    .push(ValidationWarning::DegenerateGeometric { strategy });
567            }
568            // Unreachable: continuous handled above.
569            IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
570        }
571        return Ok(());
572    }
573
574    // Lhs on discrete: degenerate over 1-axis Lattice / Lockstep / Modular.
575    if matches!(strategy, StrategyName::Lhs) {
576        match idx {
577            IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
578                report.warnings.push(ValidationWarning::LhsDegenerate);
579            }
580            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
581                report.warnings.push(ValidationWarning::LhsDegenerate);
582            }
583            _ => {}
584        }
585    }
586
587    // ReverseLex / Shuffle / Halton / Sobol on any non-None
588    // discrete IndexFn: always accepted (no degeneracy
589    // warning).
590    Ok(())
591}
592
593fn continuous_dim(idx: &IndexFn) -> usize {
594    match idx {
595        IndexFn::Continuous { intervals, .. } => intervals.len(),
596        IndexFn::Hybrid {
597            discrete_axes,
598            continuous_axes,
599            ..
600        } => discrete_axes.len() + continuous_axes.len(),
601        _ => 0,
602    }
603}
604
605fn check_disjoint_names(
606    combinator: &'static str,
607    children: &[Comprehension],
608) -> Result<(), ValidationError> {
609    let mut seen: Vec<String> = Vec::new();
610    for child in children {
611        for name in child.coordinate_names() {
612            if seen.contains(&name) {
613                return Err(ValidationError::V1DuplicateName { combinator, name });
614            }
615            seen.push(name);
616        }
617    }
618    Ok(())
619}
620
621fn contains_continuous_source(c: &Comprehension) -> bool {
622    match c {
623        Comprehension::Clause { source, .. } => source.is_continuous(),
624        Comprehension::Cartesian { children }
625        | Comprehension::Zip { children, .. }
626        | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
627        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
628            contains_continuous_source(child)
629        }
630    }
631}
632
633/// Return the source's cardinality if `c` is a direct clause;
634/// `None` otherwise. Used by the V6 check on direct-clause
635/// children; combinator children are covered by the
636/// metadata-based checks.
637fn direct_source_cardinality(c: &Comprehension) -> Option<CardinalityClass> {
638    match c {
639        Comprehension::Clause { source, .. } => Some(source.cardinality()),
640        _ => None,
641    }
642}
643
644/// Extract `{name}` interpolation references from a predicate
645/// string. Handles only the simple `{name}` form; nested
646/// expressions and escapes are out of scope for V3's parse-time
647/// check (the consumer handles richer Polydat expression analysis).
648fn extract_interpolated_names(predicate: &str) -> Vec<String> {
649    let mut out = Vec::new();
650    let bytes = predicate.as_bytes();
651    let mut i = 0;
652    while i < bytes.len() {
653        if bytes[i] == b'{'
654            && let Some(close) = predicate[i + 1..].find('}')
655        {
656            let name = predicate[i + 1..i + 1 + close].trim();
657            if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
658                out.push(name.to_string());
659            }
660            i += close + 2;
661            continue;
662        }
663        i += 1;
664    }
665    out
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
672    use crate::iteration::comprehension::source::{LiteralValue, Source};
673
674    fn clause(name: &str, vs: &[i64]) -> Comprehension {
675        Comprehension::clause(
676            name,
677            Source::Literal {
678                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
679            },
680        )
681    }
682
683    fn continuous_clause(name: &str) -> Comprehension {
684        Comprehension::clause(
685            name,
686            Source::ContinuousInterval {
687                interval: Interval::closed(0.0, 1.0),
688                measure: ProductMeasure::Uniform,
689            },
690        )
691    }
692
693    #[test]
694    fn v1_rejects_duplicate_names_in_cartesian() {
695        let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
696        let result = validate(&bad, Mode::Permissive);
697        assert!(matches!(
698            result,
699            Err(ValidationError::V1DuplicateName {
700                combinator: "cartesian",
701                ..
702            })
703        ));
704    }
705
706    #[test]
707    fn v1_accepts_disjoint_names() {
708        let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
709        assert!(validate(&ok, Mode::Permissive).is_ok());
710    }
711
712    #[test]
713    fn v2_rejects_union_shape_mismatch() {
714        let bad = Comprehension::union(vec![
715            Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
716            Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
717        ]);
718        let result = validate(&bad, Mode::Permissive);
719        assert!(matches!(
720            result,
721            Err(ValidationError::V2ShapeMismatch { .. })
722        ));
723    }
724
725    #[test]
726    fn v2_accepts_matching_union_shape() {
727        let ok = Comprehension::union(vec![
728            Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
729            Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
730        ]);
731        assert!(validate(&ok, Mode::Permissive).is_ok());
732    }
733
734    #[test]
735    fn v4_rejects_lattice_geometric_over_union() {
736        let bad = Comprehension::order(
737            Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
738            StrategyName::Extrema,
739            Some(2),
740        );
741        assert!(matches!(
742            validate(&bad, Mode::Permissive),
743            Err(ValidationError::V4InputShape {
744                strategy: StrategyName::Extrema,
745                ..
746            })
747        ));
748    }
749
750    #[test]
751    fn v4_lattice_geometric_over_1axis_warns_not_errors() {
752        let degenerate =
753            Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
754        let report = validate(&degenerate, Mode::Permissive).unwrap();
755        assert!(report.warnings.iter().any(|w| matches!(
756            w,
757            ValidationWarning::DegenerateGeometric {
758                strategy: StrategyName::Extrema
759            }
760        )));
761    }
762
763    #[test]
764    fn v4_strict_mode_promotes_warning() {
765        let degenerate =
766            Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
767        assert!(validate(&degenerate, Mode::Strict).is_err());
768    }
769
770    #[test]
771    fn v7_rejects_continuous_in_zip() {
772        let bad = Comprehension::zip(
773            vec![continuous_clause("alpha"), continuous_clause("beta")],
774            ZipMode::Strict,
775        );
776        assert!(matches!(
777            validate(&bad, Mode::Permissive),
778            Err(ValidationError::V7ZipCardinality { .. })
779        ));
780    }
781
782    #[test]
783    fn v8_rejects_continuous_without_sampling() {
784        // Continuous clause at the outermost level — no order.
785        let bad = continuous_clause("theta");
786        assert!(validate(&bad, Mode::Permissive).is_ok());
787        // The error fires at the outermost reachable point; for
788        // a bare clause we need a wrapping check the consumer
789        // does. Wrap it in order(Lex, None) — Lex doesn't sample
790        // continuous; V8 fires.
791        let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
792        assert!(matches!(
793            validate(&bad_lex, Mode::Permissive),
794            Err(ValidationError::V8ContinuousRequirement { .. })
795        ));
796    }
797
798    #[test]
799    fn v8_accepts_continuous_with_sampling() {
800        let ok = Comprehension::order(
801            Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
802            StrategyName::Halton,
803            Some(100),
804        );
805        assert!(validate(&ok, Mode::Permissive).is_ok());
806    }
807
808    #[test]
809    fn v8_rejects_unbounded_uniform_at_source() {
810        let bad = Comprehension::clause(
811            "x",
812            Source::ContinuousInterval {
813                interval: Interval {
814                    lo: 0.0,
815                    hi: f64::INFINITY,
816                    lo_open: false,
817                    hi_open: true,
818                },
819                measure: ProductMeasure::Uniform,
820            },
821        );
822        assert!(matches!(
823            validate(&bad, Mode::Permissive),
824            Err(ValidationError::V8ContinuousRequirement { .. })
825        ));
826    }
827
828    #[test]
829    fn v9_rejects_continuous_in_union() {
830        let bad = Comprehension::union(vec![
831            Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
832            Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
833        ]);
834        // Note: this also trips V9 via continuous-in-union before V2 even fires.
835        assert!(matches!(
836            validate(&bad, Mode::Permissive),
837            Err(ValidationError::V9UnionClassMismatch { .. })
838        ));
839    }
840
841    #[test]
842    fn singleton_combinator_warns() {
843        let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
844        let report = validate(&degenerate, Mode::Permissive).unwrap();
845        assert!(report.warnings.iter().any(|w| matches!(
846            w,
847            ValidationWarning::SingletonCombinator {
848                combinator: "cartesian"
849            }
850        )));
851    }
852
853    #[test]
854    fn trivially_true_filter_warns() {
855        let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
856        let report = validate(&degenerate, Mode::Permissive).unwrap();
857        assert!(
858            report
859                .warnings
860                .iter()
861                .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
862        );
863    }
864
865    #[test]
866    fn name_extraction_handles_simple_predicates() {
867        assert_eq!(extract_interpolated_names("{k} > 0"), vec!["k"]);
868        assert_eq!(
869            extract_interpolated_names("{k} * {limit} <= 1000"),
870            vec!["k", "limit"]
871        );
872        assert_eq!(
873            extract_interpolated_names("no refs here"),
874            Vec::<String>::new()
875        );
876    }
877}