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