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 — comprehension_forms.md §5 (V1-V9) and §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. The compile stage
11//! (`CompiledComprehension::from_ast` and the `for` lowering) runs it
12//! on the tree as written, before any rewrite, so acceptance is a
13//! property of the source text, and every rewrite keeps an accepted
14//! tree accepted.
15//!
16//! V4 (per-strategy input-shape contract) fires at two times
17//! (comprehension_forms.md §10.7.8):
18//!
19//! 1. **Compile time** — this module, against the AST's static
20//!    metadata-derived [`IndexFn`]. For
21//!    [`crate::iteration::comprehension::eval_source::EvalClass::Static`]
22//!    sources the static IndexFn equals the runtime IndexFn, so a
23//!    tree accepted here is never refused at a strategy. A
24//!    `ContextRequired` source (a Generator that references a name, a
25//!    WorkloadParamList) is judged by what its text declares: its
26//!    `cardinality_hint`, or an unknown count.
27//! 2. **Strategy invocation** — the runtime evaluator
28//!    ([`crate::iteration::comprehension::runtime::evaluate_indexed`])
29//!    calls
30//!    [`crate::iteration::comprehension::strategies::Strategy::accepts_input`]
31//!    against the evaluated input's `index_fn`. For a context-required
32//!    source this is the authoritative check.
33
34use serde::{Deserialize, Serialize};
35
36use super::ast::Comprehension;
37use super::cardinality::CardinalityClass;
38use super::metadata::{IndexFn, Metadata};
39use super::source::Source;
40use super::strategy::{StrategyName, ZipMode};
41
42/// Validation mode (comprehension_forms.md §5.8).
43///
44/// `Permissive` (default) enforces V1-V9 as errors and surfaces
45/// degenerate-composition warnings non-blockingly. `Strict`
46/// promotes those warnings to errors.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48pub enum Mode {
49    #[default]
50    /// V1 through V9 are errors; degenerate compositions are warnings.
51    Permissive,
52    /// Degenerate compositions are errors too.
53    Strict,
54}
55
56/// Result of a validation pass.
57#[derive(Debug, Clone)]
58pub struct ValidationReport {
59    /// The warnings the pass raised.
60    pub warnings: Vec<ValidationWarning>,
61}
62
63/// V-axiom violation. Each variant carries enough context to
64/// produce a useful diagnostic at the call site.
65#[derive(Debug, Clone, PartialEq)]
66pub enum ValidationError {
67    /// V1 — cartesian or zip children share a name.
68    V1DuplicateName {
69        /// The combinator whose children share the name.
70        combinator: &'static str,
71        /// The duplicated name.
72        name: String,
73    },
74
75    /// V2 — union children disagree on tuple shape.
76    V2ShapeMismatch {
77        /// The tuple shape of the first child.
78        expected: Vec<String>,
79        /// The shape that differs.
80        actual: Vec<String>,
81    },
82
83    /// V3 — a clause source or a filter predicate reads a name that
84    /// neither the comprehension binds where it is read nor the surface
85    /// it is evaluated on supplies ([`check_names`]).
86    V3UnresolvedNames {
87        /// Each such name, with where it is read, in tree order.
88        reads: Vec<NameRead>,
89    },
90
91    /// V4 — strategy applied to an input whose metadata-derived
92    /// [`IndexFn`] it cannot accept (per-strategy table in
93    /// `check_strategy_input_shape`). A strategy that selects from
94    /// the shape reads through the untruncated orders under it, and
95    /// V5 looks through one filter layer; nested filters are
96    /// rejected.
97    V4InputShape {
98        /// The strategy applied.
99        strategy: StrategyName,
100        /// Why its input's shape is unacceptable.
101        reason: String,
102    },
103
104    /// V6 — non-Lex order or Strict/Truncate zip applied to an
105    /// `Unbounded` discrete input.
106    V6UnboundedDiscrete {
107        /// The operator applied.
108        operator: &'static str,
109        /// The unbounded input's cardinality class.
110        cardinality: CardinalityClass,
111    },
112
113    /// V7 — zip cardinality contract violated. Three sub-cases:
114    /// Strict-mode mismatch, mixed-class children, or any
115    /// continuous child.
116    V7ZipCardinality {
117        /// The zip mode in force.
118        mode: ZipMode,
119        /// Which contract was violated.
120        reason: String,
121    },
122
123    /// V8 — continuous source requires explicit sampling OR
124    /// source declares a non-integrable measure.
125    V8ContinuousRequirement {
126        /// What the source lacks.
127        reason: String,
128    },
129
130    /// V9 — union children include a continuous or mixed-class
131    /// child.
132    V9UnionClassMismatch {
133        /// Which child mismatches, and how.
134        reason: String,
135    },
136
137    /// Strict mode (comprehension_forms.md §5.8): a degenerate composition the
138    /// permissive mode only warns about.
139    StrictWarning(ValidationWarning),
140    /// A source that needs a scope, on the scope-less surfaces
141    /// (comprehension_forms.md §9.5.2, §10.7.0): a coordinate stream
142    /// binds no names, the traversal does.
143    ContextRequired {
144        /// The clause.
145        name: String,
146        /// The names its source references.
147        references: Vec<String>,
148    },
149    /// A filter whose predicate names what its tuples do not bind, on
150    /// the scope-less surfaces: a traversal resolves such a name in
151    /// the scope it opens in, and a coordinate stream has none.
152    PredicateContextRequired {
153        /// The predicate.
154        predicate: String,
155        /// The names it references that its tuples do not bind.
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    /// rather than a clause that dispenses nothing.
162    SourceFailed {
163        /// The clause.
164        name: String,
165        /// The evaluator's message.
166        message: String,
167    },
168}
169
170impl std::fmt::Display for ValidationError {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            Self::V1DuplicateName { combinator, name } => {
174                write!(
175                    f,
176                    "V1: `{combinator}` binds the name `{name}` more than once"
177                )
178            }
179            Self::V2ShapeMismatch { expected, actual } => write!(
180                f,
181                "V2: tuple shape ({}) does not match ({})",
182                actual.join(", "),
183                expected.join(", ")
184            ),
185            Self::V3UnresolvedNames { reads } => write_unresolved(f, reads, false),
186            Self::V4InputShape { strategy, reason } => {
187                write!(
188                    f,
189                    "V4: strategy `{strategy:?}` cannot take this input: {reason}"
190                )
191            }
192            Self::V6UnboundedDiscrete {
193                operator,
194                cardinality,
195            } => write!(
196                f,
197                "V6: `{operator}` cannot materialize a {cardinality:?} stream"
198            ),
199            Self::V7ZipCardinality { mode, reason } => {
200                write!(f, "V7: zip in {mode:?} mode: {reason}")
201            }
202            Self::V8ContinuousRequirement { reason } => {
203                write!(f, "V8: continuous source: {reason}")
204            }
205            Self::StrictWarning(w) => write!(f, "strict mode: {w}"),
206            Self::ContextRequired { name, references } => write!(
207                f,
208                "clause '{name}' needs a scope to bind {}; a coordinate stream has none: \n                 traverse it with `for`, which captures those names when it opens",
209                references.join(", ")
210            ),
211            Self::PredicateContextRequired {
212                predicate,
213                references,
214            } => write!(
215                f,
216                "predicate '{predicate}' needs a scope to bind {}; a coordinate stream has none: \
217                 traverse it with `for`, which captures those names when it opens",
218                references.join(", ")
219            ),
220            Self::V9UnionClassMismatch { reason } => {
221                write!(f, "V9: union children differ in class: {reason}")
222            }
223            Self::SourceFailed { name, message } => {
224                write!(f, "clause '{name}' cannot be evaluated: {message}")
225            }
226        }
227    }
228}
229
230impl std::error::Error for ValidationError {}
231
232/// The V3 diagnostic over `reads`: each name with where it is read, and
233/// for a bare word in a predicate, how a string and a scope's name are
234/// written. `lax` says the names compile and read None
235/// (comprehension_forms.md §5 V3).
236fn write_unresolved(
237    f: &mut std::fmt::Formatter<'_>,
238    reads: &[NameRead],
239    lax: bool,
240) -> std::fmt::Result {
241    // `all(<cursor>)` reads the cursor's two extent outputs; each names
242    // the cursor, once.
243    let mut each: Vec<String> = reads
244        .iter()
245        .map(
246            |r| match polydat_grammar::comprehension::source::cursor_of_extent_name(&r.name) {
247                Some(cursor) => format!("the extent of cursor `{cursor}` read by {}", r.site),
248                None => format!("`{}` read by {}", r.name, r.site),
249            },
250        )
251        .collect();
252    each.dedup();
253    write!(
254        f,
255        "V3: {} {} bound neither by the comprehension nor by the scope it is evaluated in",
256        each.join(", "),
257        if each.len() == 1 { "is" } else { "are" }
258    )?;
259    if lax {
260        write!(
261            f,
262            ", so {} None, as a name nothing binds does outside `pragma strict`",
263            if each.len() == 1 {
264                "it reads"
265            } else {
266                "each reads"
267            }
268        )?;
269    }
270    if let Some(bare) = reads.iter().find(|r| r.bare) {
271        write!(
272            f,
273            "; a bare word in a predicate is a name, which nothing supplies: a string in a \
274             predicate is quoted, as in `\"{}\"`, and a scope's name is read as `{{{}}}`",
275            bare.name, bare.name
276        )?;
277    }
278    Ok(())
279}
280
281#[derive(Debug, Clone, PartialEq)]
282/// Non-blocking warning for degenerate-but-defined compositions
283/// (comprehension_forms.md §5.8).
284pub enum ValidationWarning {
285    /// Lattice-geometric strategy (`Extrema` / `Shells` /
286    /// `Diagonal` / `Antidiagonal`) over a 1-axis input.
287    /// Collapses to {first, last} or a trivial walk; usually
288    /// not what the author meant.
289    DegenerateGeometric {
290        /// The strategy applied.
291        strategy: StrategyName,
292    },
293
294    /// `Lhs` over a 1-axis input. Equivalent to `Shuffle`;
295    /// two names for one behavior.
296    LhsDegenerate,
297
298    /// `filter(c, "true")`. Empty-effect filter — usually a
299    /// bug-shaped predicate. The optimizer's R0a elides it.
300    TriviallyTrueFilter,
301
302    /// `filter(c, "false")`. Empty dispense sequence. If
303    /// intentional, use an empty literal source; otherwise the
304    /// predicate is bug-shaped.
305    TriviallyFalseFilter,
306
307    /// A clause whose source is *provably* empty: its cardinality
308    /// is `Bounded(0)` (`x in []`, `x in 5..5`, a context-free
309    /// generator the compile evaluated to nothing). The clause is
310    /// well-formed and an empty stream is a legal value, so this
311    /// is degenerate rather than wrong — but it empties every
312    /// cartesian it takes part in, so a whole traversal dispenses
313    /// nothing and usually that is a typo in the source.
314    ///
315    /// A source whose count is not known at construction (an
316    /// interpolated call, a parameter without a length) is
317    /// `Unbounded`, never `Bounded(0)`, so it cannot reach here:
318    /// emptiness it discovers at evaluation is reported by the
319    /// per-clause yields instead ([`super::runtime::ClauseYield`]).
320    EmptySource {
321        /// The clause's element name.
322        var: String,
323        /// The source's canonical text, when it has one.
324        source: Option<String>,
325    },
326
327    /// Singleton variant of a combinator: `zip([c], _)`,
328    /// `cartesian(c)`, `union(c)`. Identity per
329    /// comprehension_forms.md §4.2 I1-I3; the optimizer's R0a
330    /// elides it.
331    SingletonCombinator {
332        /// The combinator with one child.
333        combinator: &'static str,
334    },
335
336    /// V3 outside strictness (§5 V3): names the comprehension reads
337    /// that neither it binds nor the surface supplies. The comprehension
338    /// compiles, and each such name reads None. A `Strict` compile
339    /// refuses it as [`ValidationError::V3UnresolvedNames`].
340    UnresolvedNames {
341        /// Each such name, with where it is read, in tree order.
342        reads: Vec<NameRead>,
343    },
344}
345
346impl std::fmt::Display for ValidationWarning {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        match self {
349            Self::DegenerateGeometric { strategy } => write!(
350                f,
351                "`{strategy:?}` over a one-axis input collapses to its ends; use `Lex` with a truncation, or restate over a multi-axis cartesian"
352            ),
353            Self::LhsDegenerate => {
354                write!(f, "`Lhs` over a one-axis input is `Shuffle`; say `Shuffle`")
355            }
356            Self::TriviallyTrueFilter => write!(f, "the filter is always true; drop it"),
357            Self::TriviallyFalseFilter => write!(
358                f,
359                "the filter is always false; the comprehension dispenses nothing"
360            ),
361            Self::SingletonCombinator { combinator } => write!(
362                f,
363                "`{combinator}` over one child is that child; the wrapper adds nothing"
364            ),
365            Self::EmptySource { var, source } => match source {
366                Some(text) => write!(
367                    f,
368                    "`{var} in {text}` has no values; every composition it takes part in dispenses nothing"
369                ),
370                None => write!(
371                    f,
372                    "`{var}` has no values; every composition it takes part in dispenses nothing"
373                ),
374            },
375            Self::UnresolvedNames { reads } => write_unresolved(f, reads, true),
376        }
377    }
378}
379
380/// Where a comprehension reads a name (comprehension_forms.md §5 V3).
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum ReadSite {
383    /// A clause's source.
384    Source {
385        /// The clause's element name.
386        clause: String,
387        /// The source's text.
388        source: String,
389    },
390    /// A filter's predicate.
391    Predicate {
392        /// The predicate's text.
393        predicate: String,
394    },
395}
396
397impl std::fmt::Display for ReadSite {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            Self::Source { clause, source } => write!(f, "clause '{clause}' in `{source}`"),
401            Self::Predicate { predicate } => write!(f, "predicate `{predicate}`"),
402        }
403    }
404}
405
406/// A name a comprehension reads where it does not bind it
407/// ([`outer_reads`]).
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct NameRead {
410    /// The name.
411    pub name: String,
412    /// Where it is read.
413    pub site: ReadSite,
414    /// Whether it is a bare word in a predicate. A predicate reads a
415    /// scope only through `{name}`, so no surface supplies a bare word.
416    pub bare: bool,
417}
418
419/// What a consumption surface supplies to the names a comprehension's
420/// sources and predicates read (comprehension_forms.md §5 V3, §9.5).
421#[derive(Clone, Copy)]
422pub enum Surface<'a> {
423    /// A coordinate stream or a scoped-kernel stream: no name.
424    Stream,
425    /// A `for` traversal: the names of the scope it opens in, which it
426    /// captures when it opens. The function answers whether that scope
427    /// has a name.
428    Traversal(&'a dyn Fn(&str) -> bool),
429}
430
431impl Surface<'_> {
432    /// Whether the surface supplies `name`.
433    pub fn supplies(&self, name: &str) -> bool {
434        match self {
435            Surface::Stream => false,
436            Surface::Traversal(has) => has(name),
437        }
438    }
439}
440
441/// Every name `c` reads where it does not bind it, in tree order, once
442/// per place it is read (comprehension_forms.md §5 V3).
443///
444/// A clause's source reads the names evaluating it reads
445/// ([`Source::names_read`](crate::iteration::comprehension::source::Source::names_read)):
446/// a composed name's leaves, whose composition is read when the source
447/// is evaluated, and a cursor's extent for `all(<cursor>)`. It reads
448/// with the names the clauses before it in every
449/// enclosing cartesian bind in front of it, the dependent product of
450/// §3.2; a zip's and a union's operands see only what their enclosing
451/// cartesians bind. A filter's predicate reads each tuple of its input,
452/// so its `{name}` elements are bound by the names that input binds, and
453/// a bare word in it is never bound. What remains is what the scope the
454/// comprehension is evaluated in must supply.
455pub fn outer_reads(c: &Comprehension) -> Vec<NameRead> {
456    let mut out = Vec::new();
457    collect_outer_reads(c, &mut Vec::new(), &mut out);
458    out
459}
460
461fn collect_outer_reads(c: &Comprehension, before: &mut Vec<String>, out: &mut Vec<NameRead>) {
462    match c {
463        Comprehension::Clause { name, source } => {
464            for read in source.names_read() {
465                if !before.contains(&read) {
466                    out.push(NameRead {
467                        name: read,
468                        site: ReadSite::Source {
469                            clause: name.clone(),
470                            source: source.to_text().unwrap_or_else(|| "<source>".into()),
471                        },
472                        bare: false,
473                    });
474                }
475            }
476        }
477        Comprehension::Cartesian { children } => {
478            let depth = before.len();
479            for child in children {
480                collect_outer_reads(child, before, out);
481                before.extend(child.coordinate_names());
482            }
483            before.truncate(depth);
484        }
485        Comprehension::Zip { children, .. } | Comprehension::Union { children } => {
486            for child in children {
487                collect_outer_reads(child, before, out);
488            }
489        }
490        Comprehension::Filter { child, predicate } => {
491            collect_outer_reads(child, before, out);
492            let bound = child.coordinate_names();
493            let reads = polydat_grammar::comprehension::predicate::predicate_reads(predicate);
494            let site = || ReadSite::Predicate {
495                predicate: predicate.clone(),
496            };
497            for name in reads.elements {
498                if !bound.contains(&name) {
499                    out.push(NameRead {
500                        name,
501                        site: site(),
502                        bare: false,
503                    });
504                }
505            }
506            for name in reads.bare {
507                out.push(NameRead {
508                    name,
509                    site: site(),
510                    bare: true,
511                });
512            }
513        }
514        Comprehension::Order { child, .. } => collect_outer_reads(child, before, out),
515    }
516}
517
518/// Every name a clause source or a filter predicate of `c` reads that
519/// neither the comprehension binds where it is read nor `surface`
520/// supplies, with where it is read, in tree order
521/// (comprehension_forms.md §5 V3). Under `pragma strict` each is an
522/// error ([`check_names`]); outside it each is a warning, and the name
523/// reads None.
524pub fn unresolved_names(c: &Comprehension, surface: Surface<'_>) -> Vec<NameRead> {
525    outer_reads(c)
526        .into_iter()
527        .filter(|r| r.bare || !surface.supplies(&r.name))
528        .collect()
529}
530
531/// V3 under strictness (comprehension_forms.md §5): every name a clause
532/// source or a filter predicate of `c` reads is bound by the
533/// comprehension where it is read, or supplied by `surface`. The error
534/// names each name that is neither, with where it is read
535/// ([`unresolved_names`]).
536pub fn check_names(c: &Comprehension, surface: Surface<'_>) -> Result<(), ValidationError> {
537    let reads = unresolved_names(c, surface);
538    if reads.is_empty() {
539        Ok(())
540    } else {
541        Err(ValidationError::V3UnresolvedNames { reads })
542    }
543}
544
545/// Validate a comprehension AST as written (comprehension_forms.md §5).
546///
547/// V3 depends on the surface the comprehension is evaluated on, so it is
548/// [`check_names`], which each surface's compile calls with what that
549/// surface supplies; this function judges every other axiom.
550///
551/// In `Permissive` mode, V1-V9 errors abort with a typed
552/// [`ValidationError`] and degenerate-composition warnings
553/// accumulate into the returned [`ValidationReport`]. In
554/// `Strict` mode, the first warning is promoted to an error.
555pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
556    let mut report = ValidationReport {
557        warnings: Vec::new(),
558    };
559    visit(c, &mut report)?;
560    if mode == Mode::Strict
561        && let Some(warning) = report.warnings.first()
562    {
563        // Strict-mode promotion (§5.8): the first degenerate
564        // composition is the error.
565        return Err(ValidationError::StrictWarning(warning.clone()));
566    }
567    Ok(report)
568}
569
570fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
571    // Bottom-up: validate children first so each node sees
572    // already-well-formed operands (comprehension_forms.md §4.1 C2).
573    for child in c.children() {
574        visit(child, report)?;
575    }
576
577    match c {
578        Comprehension::Clause { name, source } => visit_clause(name, source, report),
579        Comprehension::Cartesian { children } => visit_cartesian(children, report),
580        Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
581        Comprehension::Union { children } => visit_union(children, report),
582        Comprehension::Filter { predicate, .. } => visit_filter(predicate, report),
583        Comprehension::Order {
584            child,
585            strategy,
586            truncation,
587            ..
588        } => visit_order(child, *strategy, *truncation, report),
589    }
590}
591
592fn visit_clause(
593    name: &str,
594    source: &Source,
595    report: &mut ValidationReport,
596) -> Result<(), ValidationError> {
597    // Degenerate composition (§5.8): a source the construction
598    // can already count, and the count is zero. Read off the
599    // cardinality algebra rather than matched shape by shape, so a
600    // source whose count is unknown is `Unbounded` and says nothing
601    // here.
602    if matches!(
603        source.cardinality(),
604        crate::iteration::comprehension::CardinalityClass::Bounded(0)
605    ) {
606        report.warnings.push(ValidationWarning::EmptySource {
607            var: name.to_string(),
608            source: source.to_text(),
609        });
610    }
611    // V8 source-side check: continuous source must have an
612    // integrable measure. Unbounded + Uniform is the canonical
613    // failure case.
614    if let Source::ContinuousInterval { interval, measure } = source
615        && !measure.is_integrable(std::slice::from_ref(interval))
616    {
617        let _ = report; // no warning here; this is a hard error
618        return Err(ValidationError::V8ContinuousRequirement {
619            reason: format!(
620                "continuous source has non-integrable measure: \
621                 interval [{}, {}] + {:?}",
622                interval.lo, interval.hi, measure
623            ),
624        });
625    }
626    // V8, on the declared measure: a named distribution's parameters
627    // must be the measure's own (`MeasureName::parameter_names`), or
628    // absent for the standard ones.
629    if let Source::Distribution {
630        distribution,
631        params,
632        ..
633    } = source
634        && let Err(reason) = distribution.resolve_params(params)
635    {
636        return Err(ValidationError::V8ContinuousRequirement { reason });
637    }
638    Ok(())
639}
640
641fn visit_cartesian(
642    children: &[Comprehension],
643    report: &mut ValidationReport,
644) -> Result<(), ValidationError> {
645    check_disjoint_names("cartesian", children)?;
646    if children.len() == 1 {
647        report
648            .warnings
649            .push(ValidationWarning::SingletonCombinator {
650                combinator: "cartesian",
651            });
652    }
653    Ok(())
654}
655
656fn visit_zip(
657    children: &[Comprehension],
658    mode: ZipMode,
659    report: &mut ValidationReport,
660) -> Result<(), ValidationError> {
661    check_disjoint_names("zip", children)?;
662
663    // V7: discrete-only children. We use the leaf-clause check
664    // here as the cheapest reliable proxy: walk to find any
665    // continuous source in any child.
666    for child in children {
667        if contains_continuous_source(child) {
668            return Err(ValidationError::V7ZipCardinality {
669                mode,
670                reason: "zip children must all be discrete; \
671                         a continuous source was found"
672                    .to_string(),
673            });
674        }
675    }
676
677    if children.len() == 1 {
678        report
679            .warnings
680            .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
681    }
682
683    // V6: Strict/Truncate require bounded children, read off each
684    // child's metadata, so a child the optimizer unwraps is judged as
685    // it is judged wrapped.
686    if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
687        for child in children {
688            let card = child.metadata().cardinality;
689            if matches!(card, CardinalityClass::Unbounded) {
690                return Err(ValidationError::V6UnboundedDiscrete {
691                    operator: "zip",
692                    cardinality: card,
693                });
694            }
695        }
696    }
697
698    Ok(())
699}
700
701fn visit_union(
702    children: &[Comprehension],
703    report: &mut ValidationReport,
704) -> Result<(), ValidationError> {
705    // V9 first: all children must be discrete.
706    for child in children {
707        if contains_continuous_source(child) {
708            return Err(ValidationError::V9UnionClassMismatch {
709                reason: "union children must all be discrete; \
710                         a continuous source was found"
711                    .to_string(),
712            });
713        }
714    }
715
716    // V2: identical tuple shape (same names, same order).
717    if let Some(first) = children.first() {
718        let expected = first.coordinate_names();
719        for sibling in &children[1..] {
720            let actual = sibling.coordinate_names();
721            if actual != expected {
722                return Err(ValidationError::V2ShapeMismatch { expected, actual });
723            }
724        }
725    }
726
727    if children.len() == 1 {
728        report
729            .warnings
730            .push(ValidationWarning::SingletonCombinator {
731                combinator: "union",
732            });
733    }
734    Ok(())
735}
736
737fn visit_filter(predicate: &str, report: &mut ValidationReport) -> Result<(), ValidationError> {
738    // V3, the predicate's names against its input's tuples and the
739    // surface's scope, is `check_names`.
740
741    // §5.8 warnings for trivially-true / trivially-false
742    // predicates: the literal texts "true" and "false" are the
743    // bug-shaped cases this check recognizes.
744    let trimmed = predicate.trim();
745    if trimmed.eq_ignore_ascii_case("true") {
746        report.warnings.push(ValidationWarning::TriviallyTrueFilter);
747    } else if trimmed.eq_ignore_ascii_case("false") {
748        report
749            .warnings
750            .push(ValidationWarning::TriviallyFalseFilter);
751    }
752
753    Ok(())
754}
755
756fn visit_order(
757    child: &Comprehension,
758    strategy: StrategyName,
759    truncation: Option<u64>,
760    report: &mut ValidationReport,
761) -> Result<(), ValidationError> {
762    // V4: per-strategy input-shape contract using the metadata
763    // algebra. A strategy that selects from the shape reads through
764    // the untruncated orders under it (§7.4 O1), and V5 looks through
765    // one filter layer: the metadata is computed against what remains.
766    let metadata_target = match super::strategies::ranked_filter(child, strategy) {
767        Some((input, _)) => input,
768        None => super::strategies::shape_input(child, strategy),
769    };
770
771    // V5 looks through one filter layer only: nested filters under a
772    // non-Lex order are refused as written, before R6 could fold them.
773    if !matches!(strategy, StrategyName::Lex)
774        && matches!(metadata_target, Comprehension::Filter { .. })
775    {
776        return Err(ValidationError::V4InputShape {
777            strategy,
778            reason: "non-Lex strategy applied to nested filters; \
779                     write them as one filter, `where p && q` \
780                     (comprehension_forms.md §5 V5)"
781                .to_string(),
782        });
783    }
784
785    let target_metadata = metadata_target.metadata();
786    check_strategy_input_shape(strategy, &target_metadata, report)?;
787
788    // V6: non-Lex strategy requires bounded input. Now via
789    // metadata cardinality (not the source-only direct check).
790    if !matches!(strategy, StrategyName::Lex)
791        && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
792    {
793        return Err(ValidationError::V6UnboundedDiscrete {
794            operator: "order",
795            cardinality: target_metadata.cardinality.clone(),
796        });
797    }
798
799    // V8: continuous input requires sampling — wrapped order
800    // with finite truncation is the discharge mechanism.
801    let is_continuous = matches!(
802        target_metadata.cardinality,
803        CardinalityClass::Continuous { .. }
804            | CardinalityClass::ContinuousAtMost { .. }
805            | CardinalityClass::Hybrid(_)
806    );
807    if is_continuous {
808        if truncation.is_none() {
809            return Err(ValidationError::V8ContinuousRequirement {
810                reason: "continuous comprehension requires order(_, \
811                         sampling-strategy, Some(n)) with finite \
812                         truncation"
813                    .to_string(),
814            });
815        }
816        if matches!(strategy, StrategyName::Lex) {
817            return Err(ValidationError::V8ContinuousRequirement {
818                reason: "Lex does not sample continuous inputs; use \
819                         Halton / Sobol / Lhs / Shuffle / Extrema"
820                    .to_string(),
821            });
822        }
823    }
824
825    Ok(())
826}
827
828/// Per-strategy V4 input-shape check using the metadata
829/// algebra's `IndexFn` variants. Implements the per-strategy
830/// table from comprehension_forms.md §3.6:
831///
832/// An order's output is a one-axis `Lattice` of its selection, so every
833/// row below accepts it.
834///
835/// | Strategy | Accepted IndexFn |
836/// |---|---|
837/// | Lex | any (incl. None) |
838/// | ReverseLex | any non-None discrete |
839/// | Shuffle, Halton, Sobol | any non-None |
840/// | Lhs | any non-None (Lattice multi-axis = native; 1-axis = degenerate) |
841/// | Extrema | any non-None (Lattice ≥2 = native; 1-axis or non-Lattice = degenerate or continuous box) |
842/// | Shells, Diagonal, Antidiagonal | non-None discrete only |
843fn check_strategy_input_shape(
844    strategy: StrategyName,
845    metadata: &Metadata,
846    report: &mut ValidationReport,
847) -> Result<(), ValidationError> {
848    // Lex accepts anything including None.
849    if matches!(strategy, StrategyName::Lex) {
850        return Ok(());
851    }
852
853    let idx = match &metadata.index_addressable {
854        Some(i) => i,
855        None => {
856            return Err(ValidationError::V4InputShape {
857                strategy,
858                reason: "input has no closed-form index function \
859                         (a filter's output, a dependent cartesian, or \
860                         a truncated Lex order over one)"
861                    .to_string(),
862            });
863        }
864    };
865
866    // Continuous / Hybrid acceptance per strategy.
867    let has_continuous = idx.has_continuous_axis();
868    if has_continuous {
869        match strategy {
870            // Index-sampling that accepts continuous.
871            StrategyName::Shuffle
872            | StrategyName::Halton
873            | StrategyName::Sobol
874            | StrategyName::Lhs => {}
875            // Extrema accepts continuous boxes (§3.6).
876            StrategyName::Extrema => {}
877            // Everything else rejects continuous.
878            StrategyName::ReverseLex
879            | StrategyName::Shells
880            | StrategyName::Diagonal
881            | StrategyName::Antidiagonal => {
882                return Err(ValidationError::V4InputShape {
883                    strategy,
884                    reason: format!("{} does not accept continuous input", strategy.as_str()),
885                });
886            }
887            StrategyName::Lex => unreachable!("Lex handled above"),
888        }
889        // Continuous + Lhs/Extrema on 1-D is the same kind of
890        // degenerate as discrete 1-D; emit a warning.
891        if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
892            let dim = continuous_dim(idx);
893            if dim < 2 {
894                if matches!(strategy, StrategyName::Lhs) {
895                    report.warnings.push(ValidationWarning::LhsDegenerate);
896                } else {
897                    report
898                        .warnings
899                        .push(ValidationWarning::DegenerateGeometric { strategy });
900                }
901            }
902        }
903        return Ok(());
904    }
905
906    // Discrete path.
907    // Lattice-geometric strategies require Lattice IndexFn.
908    if strategy.is_lattice_geometric() {
909        match idx {
910            IndexFn::Lattice { axis_sizes } => {
911                if axis_sizes.len() < 2 {
912                    report
913                        .warnings
914                        .push(ValidationWarning::DegenerateGeometric { strategy });
915                }
916            }
917            // Concatenation (union) is V4-rejected for lattice-
918            // geometric — heterogeneous index space.
919            IndexFn::Concatenation { .. } => {
920                return Err(ValidationError::V4InputShape {
921                    strategy,
922                    reason: format!(
923                        "{} requires a cartesian input; got union",
924                        strategy.as_str()
925                    ),
926                });
927            }
928            // Lockstep / Modular (zip) — 1-D index space; these
929            // strategies in 1-D collapse degenerately, but per
930            // §5.8 we allow with warning rather than rejecting.
931            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
932                report
933                    .warnings
934                    .push(ValidationWarning::DegenerateGeometric { strategy });
935            }
936            // Unreachable: continuous handled above.
937            IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
938        }
939        return Ok(());
940    }
941
942    // Lhs on discrete: degenerate over 1-axis Lattice / Lockstep / Modular.
943    if matches!(strategy, StrategyName::Lhs) {
944        match idx {
945            IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
946                report.warnings.push(ValidationWarning::LhsDegenerate);
947            }
948            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
949                report.warnings.push(ValidationWarning::LhsDegenerate);
950            }
951            _ => {}
952        }
953    }
954
955    // ReverseLex / Shuffle / Halton / Sobol on any non-None
956    // discrete IndexFn: always accepted (no degeneracy
957    // warning).
958    Ok(())
959}
960
961fn continuous_dim(idx: &IndexFn) -> usize {
962    match idx {
963        IndexFn::Continuous { intervals, .. } => intervals.len(),
964        IndexFn::Hybrid {
965            discrete_axes,
966            continuous_axes,
967            ..
968        } => discrete_axes.len() + continuous_axes.len(),
969        _ => 0,
970    }
971}
972
973fn check_disjoint_names(
974    combinator: &'static str,
975    children: &[Comprehension],
976) -> Result<(), ValidationError> {
977    let mut seen: Vec<String> = Vec::new();
978    for child in children {
979        for name in child.coordinate_names() {
980            if seen.contains(&name) {
981                return Err(ValidationError::V1DuplicateName { combinator, name });
982            }
983            seen.push(name);
984        }
985    }
986    Ok(())
987}
988
989fn contains_continuous_source(c: &Comprehension) -> bool {
990    match c {
991        Comprehension::Clause { source, .. } => source.is_continuous(),
992        Comprehension::Cartesian { children }
993        | Comprehension::Zip { children, .. }
994        | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
995        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
996            contains_continuous_source(child)
997        }
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
1005    use crate::iteration::comprehension::source::{LiteralValue, Source};
1006
1007    fn clause(name: &str, vs: &[i64]) -> Comprehension {
1008        Comprehension::clause(
1009            name,
1010            Source::Literal {
1011                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
1012            },
1013        )
1014    }
1015
1016    fn empty_warnings(c: &Comprehension) -> Vec<String> {
1017        validate(c, Mode::Permissive)
1018            .expect("an empty source is degenerate, not invalid")
1019            .warnings
1020            .into_iter()
1021            .filter_map(|w| match w {
1022                ValidationWarning::EmptySource { var, .. } => Some(var),
1023                _ => None,
1024            })
1025            .collect()
1026    }
1027
1028    /// A source the construction can already count as empty is a
1029    /// degenerate composition, warned and not refused.
1030    #[test]
1031    fn a_provably_empty_source_warns() {
1032        assert_eq!(empty_warnings(&clause("x", &[])), ["x"]);
1033        assert_eq!(
1034            empty_warnings(&Comprehension::clause(
1035                "k",
1036                Source::IntRange {
1037                    lo: 5,
1038                    hi: 5,
1039                    step: 1,
1040                },
1041            )),
1042            ["k"],
1043            "a half-open range over no values is the same fact"
1044        );
1045    }
1046
1047    /// The rule reads the cardinality algebra, so a source whose count
1048    /// is not known at construction says nothing here. Its emptiness,
1049    /// if any, is the evaluator's to report.
1050    #[test]
1051    fn a_source_of_unknown_count_does_not_warn() {
1052        let generator = Comprehension::clause(
1053            "g",
1054            Source::Generator {
1055                expr: "matching_profiles('a')".into(),
1056                cardinality_hint: None,
1057            },
1058        );
1059        assert!(empty_warnings(&generator).is_empty());
1060
1061        let param = Comprehension::clause(
1062            "p",
1063            Source::WorkloadParamList {
1064                name: "sizes".into(),
1065                len_hint: None,
1066            },
1067        );
1068        assert!(empty_warnings(&param).is_empty());
1069    }
1070
1071    /// A generator the compile did count, and counted as zero, is
1072    /// known at construction and warns like a literal.
1073    #[test]
1074    fn a_generator_counted_as_zero_warns() {
1075        let counted = Comprehension::clause(
1076            "g",
1077            Source::Generator {
1078                expr: "matching_profiles('nope')".into(),
1079                cardinality_hint: Some(0),
1080            },
1081        );
1082        assert_eq!(empty_warnings(&counted), ["g"]);
1083    }
1084
1085    /// Non-blocking by default, the error under `Strict` — the same
1086    /// duality every other degenerate composition has.
1087    #[test]
1088    fn an_empty_source_is_the_error_under_strict() {
1089        let c = clause("x", &[]);
1090        assert!(validate(&c, Mode::Permissive).is_ok());
1091        match validate(&c, Mode::Strict) {
1092            Err(ValidationError::StrictWarning(ValidationWarning::EmptySource { var, .. })) => {
1093                assert_eq!(var, "x");
1094            }
1095            other => panic!("expected the empty source to be the strict error, got {other:?}"),
1096        }
1097    }
1098
1099    /// Nested, so a host sees which clause of a product is the cause.
1100    #[test]
1101    fn an_empty_clause_is_named_inside_a_cartesian() {
1102        let c = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[])]);
1103        assert_eq!(empty_warnings(&c), ["b"]);
1104    }
1105
1106    fn continuous_clause(name: &str) -> Comprehension {
1107        Comprehension::clause(
1108            name,
1109            Source::ContinuousInterval {
1110                interval: Interval::closed(0.0, 1.0),
1111                measure: ProductMeasure::Uniform,
1112            },
1113        )
1114    }
1115
1116    #[test]
1117    fn v1_rejects_duplicate_names_in_cartesian() {
1118        let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
1119        let result = validate(&bad, Mode::Permissive);
1120        assert!(matches!(
1121            result,
1122            Err(ValidationError::V1DuplicateName {
1123                combinator: "cartesian",
1124                ..
1125            })
1126        ));
1127    }
1128
1129    #[test]
1130    fn v1_accepts_disjoint_names() {
1131        let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
1132        assert!(validate(&ok, Mode::Permissive).is_ok());
1133    }
1134
1135    #[test]
1136    fn v2_rejects_union_shape_mismatch() {
1137        let bad = Comprehension::union(vec![
1138            Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
1139            Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
1140        ]);
1141        let result = validate(&bad, Mode::Permissive);
1142        assert!(matches!(
1143            result,
1144            Err(ValidationError::V2ShapeMismatch { .. })
1145        ));
1146    }
1147
1148    #[test]
1149    fn v2_accepts_matching_union_shape() {
1150        let ok = Comprehension::union(vec![
1151            Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
1152            Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
1153        ]);
1154        assert!(validate(&ok, Mode::Permissive).is_ok());
1155    }
1156
1157    #[test]
1158    fn v4_rejects_lattice_geometric_over_union() {
1159        let bad = Comprehension::order(
1160            Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
1161            StrategyName::Extrema,
1162            Some(2),
1163        );
1164        assert!(matches!(
1165            validate(&bad, Mode::Permissive),
1166            Err(ValidationError::V4InputShape {
1167                strategy: StrategyName::Extrema,
1168                ..
1169            })
1170        ));
1171    }
1172
1173    #[test]
1174    fn v4_lattice_geometric_over_1axis_warns_not_errors() {
1175        let degenerate =
1176            Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1177        let report = validate(&degenerate, Mode::Permissive).unwrap();
1178        assert!(report.warnings.iter().any(|w| matches!(
1179            w,
1180            ValidationWarning::DegenerateGeometric {
1181                strategy: StrategyName::Extrema
1182            }
1183        )));
1184    }
1185
1186    #[test]
1187    fn v4_strict_mode_promotes_warning() {
1188        let degenerate =
1189            Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1190        assert!(validate(&degenerate, Mode::Strict).is_err());
1191    }
1192
1193    #[test]
1194    fn v7_rejects_continuous_in_zip() {
1195        let bad = Comprehension::zip(
1196            vec![continuous_clause("alpha"), continuous_clause("beta")],
1197            ZipMode::Strict,
1198        );
1199        assert!(matches!(
1200            validate(&bad, Mode::Permissive),
1201            Err(ValidationError::V7ZipCardinality { .. })
1202        ));
1203    }
1204
1205    #[test]
1206    fn v8_rejects_continuous_without_sampling() {
1207        // Continuous clause at the outermost level — no order.
1208        let bad = continuous_clause("theta");
1209        assert!(validate(&bad, Mode::Permissive).is_ok());
1210        // The error fires at the outermost reachable point; for
1211        // a bare clause we need a wrapping check the consumer
1212        // does. Wrap it in order(Lex, None) — Lex doesn't sample
1213        // continuous; V8 fires.
1214        let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
1215        assert!(matches!(
1216            validate(&bad_lex, Mode::Permissive),
1217            Err(ValidationError::V8ContinuousRequirement { .. })
1218        ));
1219    }
1220
1221    #[test]
1222    fn v8_accepts_continuous_with_sampling() {
1223        let ok = Comprehension::order(
1224            Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
1225            StrategyName::Halton,
1226            Some(100),
1227        );
1228        assert!(validate(&ok, Mode::Permissive).is_ok());
1229    }
1230
1231    #[test]
1232    fn v8_rejects_unbounded_uniform_at_source() {
1233        let bad = Comprehension::clause(
1234            "x",
1235            Source::ContinuousInterval {
1236                interval: Interval {
1237                    lo: 0.0,
1238                    hi: f64::INFINITY,
1239                    lo_open: false,
1240                    hi_open: true,
1241                },
1242                measure: ProductMeasure::Uniform,
1243            },
1244        );
1245        assert!(matches!(
1246            validate(&bad, Mode::Permissive),
1247            Err(ValidationError::V8ContinuousRequirement { .. })
1248        ));
1249    }
1250
1251    #[test]
1252    fn v9_rejects_continuous_in_union() {
1253        let bad = Comprehension::union(vec![
1254            Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1255            Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1256        ]);
1257        // Note: this also trips V9 via continuous-in-union before V2 even fires.
1258        assert!(matches!(
1259            validate(&bad, Mode::Permissive),
1260            Err(ValidationError::V9UnionClassMismatch { .. })
1261        ));
1262    }
1263
1264    #[test]
1265    fn singleton_combinator_warns() {
1266        let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
1267        let report = validate(&degenerate, Mode::Permissive).unwrap();
1268        assert!(report.warnings.iter().any(|w| matches!(
1269            w,
1270            ValidationWarning::SingletonCombinator {
1271                combinator: "cartesian"
1272            }
1273        )));
1274    }
1275
1276    #[test]
1277    fn trivially_true_filter_warns() {
1278        let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
1279        let report = validate(&degenerate, Mode::Permissive).unwrap();
1280        assert!(
1281            report
1282                .warnings
1283                .iter()
1284                .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
1285        );
1286    }
1287
1288    fn generator(name: &str, expr: &str) -> Comprehension {
1289        Comprehension::clause(
1290            name,
1291            Source::Generator {
1292                expr: expr.into(),
1293                cardinality_hint: None,
1294            },
1295        )
1296    }
1297
1298    fn unresolved(c: &Comprehension, surface: Surface<'_>) -> Vec<(String, bool)> {
1299        match check_names(c, surface) {
1300            Ok(()) => Vec::new(),
1301            Err(ValidationError::V3UnresolvedNames { reads }) => {
1302                reads.into_iter().map(|r| (r.name, r.bare)).collect()
1303            }
1304            Err(other) => panic!("expected V3, got {other}"),
1305        }
1306    }
1307
1308    /// A source reads with the clauses before it in its cartesian bound,
1309    /// and a predicate with its input's tuple bound; a stream supplies
1310    /// nothing else.
1311    #[test]
1312    fn v3_binds_earlier_axes_for_sources_and_the_tuple_for_predicates() {
1313        let dependent = Comprehension::cartesian(vec![
1314            clause("a", &[1, 2]),
1315            generator("b", "0..{a}"),
1316            generator("c", "0..{b}"),
1317        ]);
1318        assert!(check_names(&dependent, Surface::Stream).is_ok());
1319        let filtered = Comprehension::filter(dependent.clone(), "{a} < {c}");
1320        assert!(check_names(&filtered, Surface::Stream).is_ok());
1321        // A later axis is not bound before it, and a zip's operands do
1322        // not bind for each other.
1323        let later = Comprehension::cartesian(vec![generator("b", "0..{a}"), clause("a", &[1])]);
1324        assert_eq!(
1325            unresolved(&later, Surface::Stream),
1326            [("a".to_string(), false)]
1327        );
1328        let zipped = Comprehension::zip(
1329            vec![clause("a", &[1]), generator("b", "0..{a}")],
1330            ZipMode::Truncate,
1331        );
1332        assert_eq!(
1333            unresolved(&zipped, Surface::Stream),
1334            [("a".to_string(), false)]
1335        );
1336        // A predicate over one axis of a cartesian reads that axis only.
1337        let inner = Comprehension::cartesian(vec![
1338            clause("a", &[1, 2]),
1339            Comprehension::filter(clause("b", &[1, 2]), "{b} < {a}"),
1340        ]);
1341        assert_eq!(
1342            unresolved(&inner, Surface::Stream),
1343            [("a".to_string(), false)]
1344        );
1345    }
1346
1347    /// A traversal supplies the names of the scope it opens in; a bare
1348    /// word in a predicate is a name no surface supplies.
1349    #[test]
1350    fn v3_closes_names_over_the_surface() {
1351        let c = Comprehension::filter(
1352            Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
1353            "{k} < {limit} && {k} != s1",
1354        );
1355        assert_eq!(
1356            unresolved(&c, Surface::Stream),
1357            [
1358                ("n".to_string(), false),
1359                ("limit".to_string(), false),
1360                ("s1".to_string(), true)
1361            ]
1362        );
1363        let scope = |n: &str| n == "n" || n == "limit" || n == "s1";
1364        assert_eq!(
1365            unresolved(&c, Surface::Traversal(&scope)),
1366            [("s1".to_string(), true)]
1367        );
1368        let quoted = Comprehension::filter(
1369            Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
1370            "{k} < {limit} && {k} != \"s1\"",
1371        );
1372        assert!(check_names(&quoted, Surface::Traversal(&scope)).is_ok());
1373        let err = check_names(&c, Surface::Stream).unwrap_err().to_string();
1374        assert!(err.starts_with("V3:"), "{err}");
1375        assert!(err.contains("`n` read by clause 'j' in `0..{n}`"), "{err}");
1376        assert!(
1377            err.contains("`limit` read by predicate `{k} < {limit} && {k} != s1`"),
1378            "{err}"
1379        );
1380        assert!(err.contains("as in `\"s1\"`"), "{err}");
1381    }
1382
1383    /// A call's callee, a cast's type, and `true` are not names; a
1384    /// callee's arguments are.
1385    #[test]
1386    fn v3_reads_opaque_expressions_as_the_language_does() {
1387        let c = Comprehension::filter(
1388            clause("k", &[1, 2]),
1389            "u64_add({k}, width) > 1 && {k} as f64 > 0.5 && true",
1390        );
1391        assert_eq!(
1392            unresolved(&c, Surface::Stream),
1393            [("width".to_string(), true)]
1394        );
1395    }
1396}