Skip to main content

polydat_grammar/comprehension/
ast_legacy.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Comprehension AST — the static shape of an iteration scope.
5//!
6//! ## Shape
7//!
8//! A [`Comprehension`] declares one or more
9//! [`Clause`]s — pairs of `(var, expr)` where `var` is the
10//! name bound on each iteration and `expr` is the source
11//! expression (a workload parameter reference, a literal
12//! comma list, a stdlib call, or any GK-evaluable string)
13//! whose result is split into the iteration values for that
14//! variable.
15//!
16//! The [`ComprehensionMode`] decides how multi-clause
17//! comprehensions combine:
18//!
19//! - **Cartesian** (the common case): one ordered list of
20//!   clauses; the iteration emits the cross product. With a
21//!   single clause this collapses to the simple
22//!   `for_each var in expr` shape.
23//! - **Union**: a list of sub-spaces, each its own
24//!   Cartesian list of clauses; the iteration concatenates
25//!   each sub-space's product. Used when only certain
26//!   coordinate combinations are valid (e.g. `(k=10, limit
27//!   ∈ 10..50)` and `(k=100, limit ∈ 100..500)` — skipping
28//!   the invalid corners).
29//!
30//! ## Detection rule
31//!
32//! When a YAML / textual form lists multiple clauses, the
33//! parser (`crate::comprehension::parse`, Phase B) decides
34//! which mode to emit by checking variable names: if any
35//! name repeats across the supplied pairs, it's
36//! [`ComprehensionMode::Union`] (the repetition is the
37//! signal that the user wanted parallel sub-spaces, not a
38//! cross-product). Otherwise — all distinct var names —
39//! it's [`ComprehensionMode::Cartesian`].
40//!
41//! ## Coordinate-set relationship
42//!
43//! At run time, each iteration of a comprehension scope has
44//! its scope-coordinate set
45//! (the runtime's `ScopeCoord`) populated with one
46//! `(name, value)` for every distinct variable name the
47//! comprehension declares. The names come from
48//! [`Comprehension::coordinate_names`]; the values come
49//! from the runtime's `enumerate_tuples`
50//! (Phase C). With this AST in place, that wiring is a
51//! 1:1 structural mapping rather than a string-parse
52//! round-trip.
53
54use std::fmt;
55
56use serde::{Deserialize, Serialize};
57
58/// One clause of a comprehension: one or more variable
59/// names paired with their source expression(s).
60///
61/// **Single-var clause** (the common case): one variable
62/// binds successive values from one source list.
63/// `Clause::new("k", "1..10")` is the construction shortcut.
64///
65/// **Parallel clause** (SRD-18c Layer 7a): multiple
66/// variables advance in lockstep ("zip") from multiple
67/// source expressions. `Clause::parallel(["x", "y"],
68/// ["1..10", "100..1000..100"])` builds the parallel form.
69///
70/// The string fields are owned because the AST gets stored
71/// on long-lived scope-tree / scenario-tree nodes; sharing
72/// references back into the source text would force
73/// lifetimes through every consumer.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Clause {
76    /// The element names the clause binds.
77    pub vars: Vec<String>,
78    /// Where the values come from.
79    pub source: ClauseSource,
80}
81
82/// A clause's source of values. See [`Clause`] for context.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(tag = "kind", rename_all = "snake_case")]
85pub enum ClauseSource {
86    /// A single expression yielding `Vec<Value>`. Length
87    /// of the value list matches the iteration cardinality.
88    /// `vars.len()` is 1 for the single-var common case;
89    /// `vars.len() > 1` would mean destructure form (Layer
90    /// 7b, gated on `Value::Tuple`).
91    Single(String),
92    /// One expression per var; the sources zip in lockstep.
93    /// `vars.len() == exprs.len() ≥ 2`. Length policy is
94    /// controlled by [`ZipMode`]: strict (default) errors
95    /// on mismatch, truncate cuts to the shortest, cycle
96    /// repeats shorter sources to the longest.
97    Parallel {
98        /// The length policy when the sources differ in length.
99        mode: ZipMode,
100        /// One expression per element name, zipped in lockstep.
101        exprs: Vec<String>,
102    },
103}
104
105/// Length-policy for parallel-iter clauses (SRD-18c Layer 7a).
106///
107/// Authored as the RHS form: bare parens `(e1, e2)` = strict;
108/// `zip_truncate(e1, e2)` = truncate; `zip_cycle(e1, e2)` =
109/// cycle.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
111#[serde(rename_all = "snake_case")]
112pub enum ZipMode {
113    /// Every expression must produce the same number of
114    /// values. Mismatch is an error at iteration time.
115    /// Default for the bare `(e1, e2)` syntax.
116    #[default]
117    Strict,
118    /// Truncate every expression to the length of the
119    /// shortest. The user opts in via `zip_truncate(...)`.
120    Truncate,
121    /// Cycle shorter expressions to match the longest. The
122    /// user opts in via `zip_cycle(...)`.
123    Cycle,
124}
125
126impl Clause {
127    /// Single-var construction: `Clause::new("k", "1..10")`.
128    /// Backward-compatible with the pre-Layer-7a shape — every
129    /// existing call site works unchanged.
130    pub fn new(var: impl Into<String>, expr: impl Into<String>) -> Self {
131        Self {
132            vars: vec![var.into()],
133            source: ClauseSource::Single(expr.into()),
134        }
135    }
136
137    /// Parallel-iter construction (SRD-18c Layer 7a):
138    /// `Clause::parallel(["x", "y"], ["1..10", "fib(8)"])`.
139    /// Defaults to [`ZipMode::Strict`] — length mismatch
140    /// across the parallel group is an iteration-time error.
141    /// Use [`Clause::parallel_with_mode`] to opt into
142    /// truncate / cycle.
143    ///
144    /// Length mismatch between `vars` and `exprs` is a
145    /// programming error and panics — the parser's input
146    /// validation should catch malformed user input before
147    /// it reaches this constructor.
148    pub fn parallel(
149        vars: impl IntoIterator<Item = impl Into<String>>,
150        exprs: impl IntoIterator<Item = impl Into<String>>,
151    ) -> Self {
152        Self::parallel_with_mode(ZipMode::Strict, vars, exprs)
153    }
154
155    /// Parallel-iter construction with explicit zip mode.
156    /// See [`ZipMode`].
157    pub fn parallel_with_mode(
158        mode: ZipMode,
159        vars: impl IntoIterator<Item = impl Into<String>>,
160        exprs: impl IntoIterator<Item = impl Into<String>>,
161    ) -> Self {
162        let vars: Vec<String> = vars.into_iter().map(Into::into).collect();
163        let exprs: Vec<String> = exprs.into_iter().map(Into::into).collect();
164        assert_eq!(
165            vars.len(),
166            exprs.len(),
167            "Clause::parallel: vars and exprs must have equal length"
168        );
169        assert!(
170            vars.len() >= 2,
171            "Clause::parallel: parallel form requires ≥ 2 variables (use Clause::new for single-var)"
172        );
173        Self {
174            vars,
175            source: ClauseSource::Parallel { mode, exprs },
176        }
177    }
178
179    /// Single-var convenience: returns the lone variable
180    /// name when the clause is single-var. `None` for
181    /// parallel-iter forms — those have multiple names.
182    pub fn single_var(&self) -> Option<&str> {
183        if self.vars.len() == 1 {
184            Some(&self.vars[0])
185        } else {
186            None
187        }
188    }
189
190    /// Single-source convenience: returns the lone source
191    /// expression when the clause uses `ClauseSource::Single`.
192    /// `None` for parallel forms.
193    pub fn single_expr(&self) -> Option<&str> {
194        match &self.source {
195            ClauseSource::Single(s) => Some(s),
196            ClauseSource::Parallel { .. } => None,
197        }
198    }
199
200    /// True for parallel-iter clauses (Layer 7a).
201    pub fn is_parallel(&self) -> bool {
202        matches!(self.source, ClauseSource::Parallel { .. })
203    }
204
205    /// Backward-compat accessor: returns the first variable
206    /// name regardless of clause shape. Single-var clauses
207    /// have `vars.len() == 1`; parallel clauses have ≥ 2.
208    /// Most existing callers operate on single-var clauses
209    /// and treat parallel forms as either-or — those should
210    /// migrate to `single_var()` for explicit handling.
211    pub fn first_var(&self) -> &str {
212        &self.vars[0]
213    }
214
215    /// Convenience for single-var clauses (the historical
216    /// common case): the lone variable name. For parallel
217    /// clauses, returns the first variable's name. Most
218    /// existing call sites treat clauses as single-var; this
219    /// keeps them working with a one-line `c.var` →
220    /// `c.var()` migration.
221    pub fn var(&self) -> &str {
222        &self.vars[0]
223    }
224
225    /// Convenience for single-source clauses: the lone
226    /// source-expression text. For parallel clauses, returns
227    /// the first expression — single-var-assuming callers
228    /// see the same shape they did before Layer 7a.
229    pub fn expr(&self) -> &str {
230        match &self.source {
231            ClauseSource::Single(s) => s,
232            ClauseSource::Parallel { exprs, .. } => &exprs[0],
233        }
234    }
235
236    /// Flatten this clause to its scalar `(var, expr)` pairs.
237    ///
238    /// - **Single-var** (`vars = [v]`, `Single(s)`): returns
239    ///   `[(v, s)]`.
240    /// - **Parallel-iter** (`vars = [v0, v1, ...]`,
241    ///   `Parallel { exprs: [e0, e1, ...], .. }`): returns
242    ///   `[(v0, e0), (v1, e1), ...]`.
243    ///
244    /// One canonical place to expand the var↔expr mapping —
245    /// previously open-coded at three callers (synthesis
246    /// representative-vars expansion, runner canonical-input
247    /// declaration, runner param-ref scan).
248    pub fn scalar_bindings(&self) -> Vec<(&str, &str)> {
249        match &self.source {
250            ClauseSource::Single(s) => self.vars.iter().map(|v| (v.as_str(), s.as_str())).collect(),
251            ClauseSource::Parallel { exprs, .. } => self
252                .vars
253                .iter()
254                .zip(exprs.iter())
255                .map(|(v, e)| (v.as_str(), e.as_str()))
256                .collect(),
257        }
258    }
259}
260
261/// Canonical text rendering of a clause:
262/// - Single-var: `var in expr`.
263/// - Parallel-iter: `(a, b) in (e1, e2)` for [`ZipMode::Strict`],
264///   `zip_truncate(...)` / `zip_cycle(...)` for the other modes.
265///
266/// `parse_clause(&clause.to_string())` round-trips back to the
267/// same AST.
268impl fmt::Display for Clause {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match &self.source {
271            ClauseSource::Single(s) => write!(f, "{} in {}", self.vars[0], s),
272            ClauseSource::Parallel { mode, exprs } => {
273                write!(f, "({}) in ", self.vars.join(", "))?;
274                let inner = exprs.join(", ");
275                match mode {
276                    ZipMode::Strict => write!(f, "({inner})"),
277                    ZipMode::Truncate => write!(f, "zip_truncate({inner})"),
278                    ZipMode::Cycle => write!(f, "zip_cycle({inner})"),
279                }
280            }
281        }
282    }
283}
284
285/// How the clauses of a comprehension combine.
286///
287/// See the module-level doc for the detection rule and the
288/// motivating examples.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub enum ComprehensionMode {
291    /// One ordered list of clauses; iteration emits the
292    /// cross product. `Cartesian(vec![one_clause])` is the
293    /// degenerate single-variable form (`for_each var in expr`).
294    Cartesian(Vec<Clause>),
295    /// A list of sub-spaces. Each [`Subspace`] is one Cartesian
296    /// list of clauses; iteration emits each sub-space's product,
297    /// concatenated in declaration order. Variable names typically
298    /// repeat across sub-spaces so children see the same binding
299    /// shape regardless of which sub-space the current tuple came
300    /// from.
301    Union(Vec<Subspace>),
302}
303
304/// One sub-space of a [`ComprehensionMode::Union`]: an ordered
305/// list of clauses whose Cartesian product is one chunk of the
306/// emitted tuple stream.
307///
308/// Wrapper struct (rather than a bare `Vec<Clause>`) so future
309/// per-subspace metadata — sub-filters, labels, ordering hints —
310/// can land additively without breaking match sites.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct Subspace {
313    /// The clauses whose cross product is the subspace.
314    pub clauses: Vec<Clause>,
315}
316
317impl Subspace {
318    /// A subspace of the given clauses.
319    pub fn new(clauses: Vec<Clause>) -> Self {
320        Self { clauses }
321    }
322    /// Whether the subspace has no clause.
323    pub fn is_empty(&self) -> bool {
324        self.clauses.is_empty()
325    }
326    /// The number of clauses.
327    pub fn len(&self) -> usize {
328        self.clauses.len()
329    }
330    /// The clauses, in order.
331    pub fn iter(&self) -> std::slice::Iter<'_, Clause> {
332        self.clauses.iter()
333    }
334}
335
336impl<'a> IntoIterator for &'a Subspace {
337    type Item = &'a Clause;
338    type IntoIter = std::slice::Iter<'a, Clause>;
339    fn into_iter(self) -> Self::IntoIter {
340        self.clauses.iter()
341    }
342}
343
344impl From<Vec<Clause>> for Subspace {
345    fn from(clauses: Vec<Clause>) -> Self {
346        Self { clauses }
347    }
348}
349
350impl std::ops::Index<usize> for Subspace {
351    type Output = Clause;
352    fn index(&self, i: usize) -> &Clause {
353        &self.clauses[i]
354    }
355}
356
357impl std::ops::Deref for Subspace {
358    type Target = [Clause];
359    fn deref(&self) -> &[Clause] {
360        &self.clauses
361    }
362}
363
364/// Traversal order for emitted tuples. See SRD-18d.
365///
366/// Default emission is lexicographic with rightmost clause
367/// varying fastest — equivalent to `Lex { count: None }`. The
368/// `count` / `strata` / `depth` fields are the natural
369/// truncation parameter for each strategy and correspond to
370/// the `name/N` terse form in the text grammar.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(tag = "kind", rename_all = "snake_case")]
373pub enum TraversalOrder {
374    /// Lexicographic, rightmost varies fastest. Equivalent to
375    /// no `order` clause at all; included so consumers can
376    /// represent "explicitly default" if needed.
377    Lex {
378        #[serde(default, skip_serializing_if = "Option::is_none")]
379        /// Tuples to keep, from the first; all when absent.
380        count: Option<usize>,
381    },
382    /// Lexicographic, leftmost varies fastest.
383    ReverseLex {
384        #[serde(default, skip_serializing_if = "Option::is_none")]
385        /// Tuples to keep, from the first; all when absent.
386        count: Option<usize>,
387    },
388    /// Sort by sum-of-indices ascending; ties broken by lex.
389    Diagonal {
390        #[serde(default, skip_serializing_if = "Option::is_none")]
391        /// Tuples to keep, from the first; all when absent.
392        count: Option<usize>,
393    },
394    /// Sort by sum-of-indices descending.
395    Antidiagonal {
396        #[serde(default, skip_serializing_if = "Option::is_none")]
397        /// Tuples to keep, from the first; all when absent.
398        count: Option<usize>,
399    },
400    /// All-extrema first, stratified by interior count.
401    /// `strata = Some(N)` keeps the first N strata; `Some(1)` =
402    /// corners only.
403    Extrema {
404        #[serde(default, skip_serializing_if = "Option::is_none")]
405        /// Strata to keep, from the extrema inward; all when absent.
406        strata: Option<usize>,
407    },
408    /// Concentric L∞ shells from a chosen origin.
409    Shells {
410        #[serde(default)]
411        /// The shell origin.
412        origin: ShellOrigin,
413        #[serde(default, skip_serializing_if = "Option::is_none")]
414        /// Shells to keep, from the origin outward; all when absent.
415        depth: Option<usize>,
416    },
417    /// Halton low-discrepancy sequence.
418    Halton {
419        #[serde(default, skip_serializing_if = "Option::is_none")]
420        /// Tuples to keep, from the first; all when absent.
421        count: Option<usize>,
422    },
423    /// Sobol low-discrepancy sequence.
424    Sobol {
425        #[serde(default, skip_serializing_if = "Option::is_none")]
426        /// Tuples to keep, from the first; all when absent.
427        count: Option<usize>,
428    },
429    /// Latin Hypercube samples.
430    Lhs {
431        #[serde(default, skip_serializing_if = "Option::is_none")]
432        /// Tuples to keep, from the first; all when absent.
433        count: Option<usize>,
434        #[serde(default, skip_serializing_if = "Option::is_none")]
435        /// The sampling seed; a fixed default when absent.
436        seed: Option<u64>,
437    },
438    /// User-supplied Polydat function name. Function takes the tuple
439    /// list and returns a permutation/subset.
440    Custom {
441        /// The Polydat function that orders the tuples.
442        function: String,
443    },
444}
445
446/// Origin for shell stratification.
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
448#[serde(rename_all = "snake_case")]
449pub enum ShellOrigin {
450    /// Shells from the boundary inward. Shell 0 = boundary,
451    /// shell N = deepest interior.
452    #[default]
453    Outer,
454    /// Shells from the center outward. Shell 0 = center,
455    /// shell N = boundary.
456    Center,
457    /// Shells from the (0, …, 0) corner outward.
458    Corner,
459}
460
461/// The static shape of an iteration scope. See module doc.
462///
463/// `filter` is an optional Polydat predicate evaluated against each
464/// emitted tuple. Tuples for which the predicate evaluates to
465/// `Value::Bool(false)` are skipped — children don't run for
466/// them.
467///
468/// **Predicate syntax**: a string interpolation expression in
469/// the same shape as clause spec text — clause-bound names and
470/// inherited scope names appear as `{name}` placeholders, the
471/// rest is a const-evaluable expression. The evaluator
472/// interpolates `{var}` placeholders against the per-tuple
473/// kernel (which has every clause value installed and
474/// parent-scope wiring done), then runs `eval_const_expr` on
475/// the result. The expression must yield `Value::Bool`.
476///
477/// Examples:
478/// - `{k} * {limit} < 1000`
479/// - `{profile} == "ann"`
480/// - `{k} > {threshold}` (where `threshold` is an inherited name)
481///
482/// Cartesian and Union modes both honor the same single
483/// filter — it composes uniformly over the cross product (one
484/// predicate against each tuple) regardless of how the tuple
485/// space was assembled.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct Comprehension {
488    /// How the tuple space is assembled: one cross product, or a union of subspaces.
489    pub mode: ComprehensionMode,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    /// A predicate applied to every tuple, if any.
492    pub filter: Option<String>,
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    /// The traversal order, if one was written.
495    pub order: Option<TraversalOrder>,
496}
497
498impl Comprehension {
499    /// A cross product of the clauses with no filter or order.
500    pub fn cartesian(clauses: Vec<Clause>) -> Self {
501        Self {
502            mode: ComprehensionMode::Cartesian(clauses),
503            filter: None,
504            order: None,
505        }
506    }
507
508    /// A union of subspaces with no filter or order.
509    pub fn union(subspaces: Vec<Vec<Clause>>) -> Self {
510        Self {
511            mode: ComprehensionMode::Union(subspaces.into_iter().map(Subspace::new).collect()),
512            filter: None,
513            order: None,
514        }
515    }
516
517    /// Construct a Union from already-wrapped [`Subspace`]
518    /// values. Use this when subspaces carry metadata; the
519    /// `union(...)` shorthand wraps bare `Vec<Clause>` lists.
520    pub fn union_from(subspaces: Vec<Subspace>) -> Self {
521        Self {
522            mode: ComprehensionMode::Union(subspaces),
523            filter: None,
524            order: None,
525        }
526    }
527
528    /// Attach a filter predicate, returning `self` for builder
529    /// chaining. The predicate is a Polydat expression that must
530    /// evaluate to `Value::Bool` per tuple — anything else is a
531    /// runtime error from the comprehension's evaluator.
532    pub fn with_filter(mut self, predicate: impl Into<String>) -> Self {
533        self.filter = Some(predicate.into());
534        self
535    }
536
537    /// Attach a traversal order, returning `self` for builder
538    /// chaining. See [`TraversalOrder`] and SRD-18d.
539    pub fn with_order(mut self, order: TraversalOrder) -> Self {
540        self.order = Some(order);
541        self
542    }
543
544    /// Variable names this comprehension declares, in
545    /// declaration order, **deduplicated**. For Cartesian
546    /// mode that's exactly the LHS of each clause. For Union
547    /// mode the names typically repeat across sub-spaces;
548    /// dedup gives the operator-visible coordinate set
549    /// (children see one extern per name regardless of
550    /// sub-space count).
551    ///
552    /// Order is first-occurrence (preserves the user's
553    /// authored intent — `k` before `limit` in
554    /// `"k in …, limit in …"` stays in that order even
555    /// after dedup).
556    pub fn coordinate_names(&self) -> Vec<&str> {
557        let mut out: Vec<&str> = Vec::new();
558        for clause in self.flat_clauses() {
559            for v in &clause.vars {
560                if !out.contains(&v.as_str()) {
561                    out.push(v);
562                }
563            }
564        }
565        out
566    }
567
568    /// Every clause in the comprehension flattened into one
569    /// iterator, in declaration order. For Cartesian mode
570    /// that's the clause list directly; for Union mode it's
571    /// the concatenation of all sub-spaces' clauses
572    /// (preserving order, including any repeats — callers
573    /// that want unique names should use
574    /// [`Self::coordinate_names`]).
575    pub fn flat_clauses(&self) -> Vec<&Clause> {
576        match &self.mode {
577            ComprehensionMode::Cartesian(clauses) => clauses.iter().collect(),
578            ComprehensionMode::Union(subspaces) => {
579                let mut out = Vec::new();
580                for sub in subspaces {
581                    for clause in &sub.clauses {
582                        out.push(clause);
583                    }
584                }
585                out
586            }
587        }
588    }
589
590    /// Number of clauses across all sub-spaces (with
591    /// repetition for Union mode). For Cartesian mode this
592    /// is also the number of *coordinates*; for Union mode
593    /// see [`Self::coordinate_names`] for the deduplicated
594    /// count.
595    pub fn clause_count(&self) -> usize {
596        self.flat_clauses().len()
597    }
598
599    /// Whether the mode is one cross product.
600    pub fn is_cartesian(&self) -> bool {
601        matches!(self.mode, ComprehensionMode::Cartesian(_))
602    }
603
604    /// Whether the mode is a union of subspaces.
605    pub fn is_union(&self) -> bool {
606        matches!(self.mode, ComprehensionMode::Union(_))
607    }
608
609    /// Validate the comprehension's static structure. Returns
610    /// the empty `Ok(())` if every invariant holds; otherwise
611    /// `Err(messages)` where each message names one violation.
612    ///
613    /// This is the **single** entry point for AST-shape
614    /// invariants — `parse_comprehension_text`,
615    /// workload-load, dryrun, and any future linter all route
616    /// through here so the rule set lives in exactly one place.
617    ///
618    /// Checks performed:
619    ///
620    /// 1. **Non-empty clause set.** Cartesian must have ≥ 1
621    ///    clause; Union must have ≥ 1 sub-space, each with
622    ///    ≥ 1 clause.
623    /// 2. **No coordinate-name collisions in Cartesian mode.**
624    ///    Every variable must be unique across the clause list
625    ///    (Cartesian product of two clauses with the same name
626    ///    is undefined). Union mode permits repeated names —
627    ///    that's the structural Union signal.
628    /// 3. **Order/mode compatibility.** Index-space orderings
629    ///    (reverse_lex, diagonal, antidiagonal, extrema,
630    ///    shells, halton, sobol, lhs) require a single
631    ///    Cartesian lattice — they're rejected on Union mode
632    ///    where there is no such lattice. `lex` and `custom`
633    ///    are accepted on both modes.
634    ///
635    /// Filter / parallel-iter length checks happen at iteration
636    /// time (they require evaluation, not just structure).
637    pub fn validate(&self) -> Result<(), Vec<String>> {
638        let mut errors: Vec<String> = Vec::new();
639        match &self.mode {
640            ComprehensionMode::Cartesian(clauses) => {
641                if clauses.is_empty() {
642                    errors.push("Cartesian comprehension has no clauses".to_string());
643                }
644                let mut seen: Vec<&str> = Vec::new();
645                for clause in clauses {
646                    for v in &clause.vars {
647                        if seen.contains(&v.as_str()) {
648                            errors.push(format!(
649                                "Cartesian comprehension repeats variable name '{v}' \
650                                 — name collision across clauses (use Union mode for \
651                                 alternative sub-spaces with shared coordinate names)"
652                            ));
653                        } else {
654                            seen.push(v.as_str());
655                        }
656                    }
657                }
658            }
659            ComprehensionMode::Union(subspaces) => {
660                if subspaces.is_empty() {
661                    errors.push("Union comprehension has no sub-spaces".to_string());
662                }
663                for (i, sub) in subspaces.iter().enumerate() {
664                    if sub.is_empty() {
665                        errors.push(format!("Union sub-space #{i} has no clauses"));
666                    }
667                }
668            }
669        }
670        if let Err(e) = check_order_for_mode(&self.mode, &self.order) {
671            errors.push(e);
672        }
673        if errors.is_empty() {
674            Ok(())
675        } else {
676            Err(errors)
677        }
678    }
679}
680
681/// Order/mode compatibility check used by
682/// [`Comprehension::validate`]. SRD-18e §"Union mode +
683/// non-lex orderings" specifies the rule: every
684/// index-space strategy (`reverse_lex`, `diagonal`,
685/// `antidiagonal`, `extrema`, `shells`, `halton`, `sobol`,
686/// `lhs`) requires a single Cartesian lattice. `lex` (no
687/// geometric reasoning) and `custom` (the user's function
688/// decides) remain valid for Union mode.
689pub(crate) fn check_order_for_mode(
690    mode: &ComprehensionMode,
691    order: &Option<TraversalOrder>,
692) -> Result<(), String> {
693    let ComprehensionMode::Union(_) = mode else {
694        return Ok(());
695    };
696    let Some(order) = order else {
697        return Ok(());
698    };
699    let strategy_name = match order {
700        TraversalOrder::Lex { .. } => return Ok(()),
701        TraversalOrder::Custom { .. } => return Ok(()),
702        TraversalOrder::ReverseLex { .. } => "reverse_lex",
703        TraversalOrder::Diagonal { .. } => "diagonal",
704        TraversalOrder::Antidiagonal { .. } => "antidiagonal",
705        TraversalOrder::Extrema { .. } => "extrema",
706        TraversalOrder::Shells { .. } => "shells",
707        TraversalOrder::Halton { .. } => "halton",
708        TraversalOrder::Sobol { .. } => "sobol",
709        TraversalOrder::Lhs { .. } => "lhs",
710    };
711    Err(format!(
712        "ordering '{strategy_name}' has no defined behavior on Union mode \
713         (no single Cartesian lattice). Use Cartesian mode, or pick \
714         'lex' / 'custom' which are well-defined on Union."
715    ))
716}
717
718/// Canonical text rendering of a comprehension:
719/// `<clauses> [where <filter>] [order <spec>]`.
720///
721/// Cartesian: clauses are joined by `, `. Union: each
722/// sub-space is rendered as a parenthesised clause group
723/// joined by ` | ` to make the sub-space boundaries visible
724/// (the textual short-form parser detects Union via
725/// repeated-name signal, but the explicit form is what
726/// `Display` emits to keep the round-trip semantics-preserving
727/// regardless of sub-space layout).
728impl fmt::Display for Comprehension {
729    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
730        match &self.mode {
731            ComprehensionMode::Cartesian(clauses) => {
732                let parts: Vec<String> = clauses.iter().map(|c| c.to_string()).collect();
733                write!(f, "{}", parts.join(", "))?;
734            }
735            ComprehensionMode::Union(subspaces) => {
736                let parts: Vec<String> = subspaces
737                    .iter()
738                    .map(|sub| {
739                        let inner: Vec<String> =
740                            sub.clauses.iter().map(|c| c.to_string()).collect();
741                        inner.join(", ")
742                    })
743                    .collect();
744                write!(f, "{}", parts.join(" | "))?;
745            }
746        }
747        if let Some(predicate) = &self.filter {
748            write!(f, " where {predicate}")?;
749        }
750        if let Some(order) = &self.order {
751            write!(f, " order {}", format_order(order))?;
752        }
753        Ok(())
754    }
755}
756
757/// Render a [`TraversalOrder`] as text matching
758/// [`crate::comprehension::parse::parse_order_spec`]'s
759/// accepted forms.
760fn format_order(order: &TraversalOrder) -> String {
761    fn count_suffix(n: Option<usize>) -> String {
762        n.map(|n| format!("/{n}")).unwrap_or_default()
763    }
764    match order {
765        TraversalOrder::Lex { count } => format!("lex{}", count_suffix(*count)),
766        TraversalOrder::ReverseLex { count } => format!("reverse_lex{}", count_suffix(*count)),
767        TraversalOrder::Diagonal { count } => format!("diagonal{}", count_suffix(*count)),
768        TraversalOrder::Antidiagonal { count } => format!("antidiagonal{}", count_suffix(*count)),
769        TraversalOrder::Extrema { strata } => format!("extrema{}", count_suffix(*strata)),
770        TraversalOrder::Shells { origin, depth } => {
771            let origin_part = match origin {
772                ShellOrigin::Outer => "",
773                ShellOrigin::Center => "/center",
774                ShellOrigin::Corner => "/corner",
775            };
776            format!("shells{}{}", origin_part, count_suffix(*depth))
777        }
778        TraversalOrder::Halton { count } => format!("halton{}", count_suffix(*count)),
779        TraversalOrder::Sobol { count } => format!("sobol{}", count_suffix(*count)),
780        TraversalOrder::Lhs { count, seed } => {
781            let mut s = format!("lhs{}", count_suffix(*count));
782            if let Some(k) = seed {
783                s.push_str(&format!(" seed={k}"));
784            }
785            s
786        }
787        TraversalOrder::Custom { function } => format!("custom({function})"),
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn cartesian_coordinate_names_in_declaration_order() {
797        let c = Comprehension::cartesian(vec![
798            Clause::new("k", "{k_values}"),
799            Clause::new("limit", "{k_{k}_limits}"),
800        ]);
801        assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
802        assert!(c.is_cartesian());
803        assert_eq!(c.clause_count(), 2);
804    }
805
806    #[test]
807    fn union_dedupes_repeated_names_first_occurrence_wins() {
808        // Sub-space 1 binds k, limit. Sub-space 2 also binds
809        // k, limit. The dedup'd coordinate names are still
810        // `[k, limit]` in their first-occurrence order — not
811        // doubled.
812        let c = Comprehension::union(vec![
813            vec![Clause::new("k", "10"), Clause::new("limit", "10,20,30")],
814            vec![Clause::new("k", "100"), Clause::new("limit", "100,200,300")],
815        ]);
816        assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
817        assert!(c.is_union());
818        // Flat clauses preserve repetition (4 entries — two
819        // per sub-space).
820        assert_eq!(c.flat_clauses().len(), 4);
821        assert_eq!(c.clause_count(), 4);
822    }
823
824    #[test]
825    fn single_clause_cartesian_is_the_simple_form() {
826        let c = Comprehension::cartesian(vec![Clause::new(
827            "profile",
828            "matching_profiles('{dataset}', '{prefix}')",
829        )]);
830        assert_eq!(c.coordinate_names(), vec!["profile"]);
831        assert_eq!(c.clause_count(), 1);
832    }
833
834    #[test]
835    fn union_with_distinct_names_per_subspace_keeps_all_in_order() {
836        // Pathological case (probably not real-world): two
837        // sub-spaces each with their own distinct vars.
838        // First-occurrence ordering preserves authoring intent.
839        let c = Comprehension::union(vec![
840            vec![Clause::new("a", "1")],
841            vec![Clause::new("b", "2")],
842        ]);
843        assert_eq!(c.coordinate_names(), vec!["a", "b"]);
844    }
845
846    // ---- Display contract ------------------------------------
847
848    #[test]
849    fn display_single_var_clause() {
850        let c = Clause::new("k", "1..10");
851        assert_eq!(c.to_string(), "k in 1..10");
852    }
853
854    #[test]
855    fn display_parallel_clause_strict() {
856        let c = Clause::parallel(["x", "y"], ["fib(8)", "pow2(8)"]);
857        assert_eq!(c.to_string(), "(x, y) in (fib(8), pow2(8))");
858    }
859
860    #[test]
861    fn display_parallel_clause_truncate() {
862        let c = Clause::parallel_with_mode(ZipMode::Truncate, ["x", "y"], ["fib(8)", "pow2(4)"]);
863        assert_eq!(c.to_string(), "(x, y) in zip_truncate(fib(8), pow2(4))");
864    }
865
866    #[test]
867    fn display_parallel_clause_cycle() {
868        let c = Clause::parallel_with_mode(ZipMode::Cycle, ["x", "y"], ["1..4", "10..20..10"]);
869        assert_eq!(c.to_string(), "(x, y) in zip_cycle(1..4, 10..20..10)");
870    }
871
872    #[test]
873    fn display_cartesian_comprehension() {
874        let c = Comprehension::cartesian(vec![
875            Clause::new("k", "1..10"),
876            Clause::new("limit", "10,20,30"),
877        ]);
878        assert_eq!(c.to_string(), "k in 1..10, limit in 10,20,30");
879    }
880
881    #[test]
882    fn display_comprehension_with_filter_and_order() {
883        let c = Comprehension::cartesian(vec![Clause::new("k", "1..10")])
884            .with_filter("{k} > 3")
885            .with_order(TraversalOrder::Extrema { strata: Some(2) });
886        assert_eq!(c.to_string(), "k in 1..10 where {k} > 3 order extrema/2");
887    }
888
889    // ---- Validate contract -----------------------------------
890
891    #[test]
892    fn validate_accepts_valid_cartesian() {
893        let c = Comprehension::cartesian(vec![
894            Clause::new("k", "1..10"),
895            Clause::new("limit", "10,20,30"),
896        ]);
897        assert!(c.validate().is_ok());
898    }
899
900    #[test]
901    fn validate_accepts_valid_union() {
902        let c = Comprehension::union(vec![
903            vec![Clause::new("k", "10"), Clause::new("limit", "10,20")],
904            vec![Clause::new("k", "100"), Clause::new("limit", "100,200")],
905        ]);
906        assert!(c.validate().is_ok());
907    }
908
909    #[test]
910    fn validate_rejects_empty_cartesian() {
911        let c = Comprehension::cartesian(vec![]);
912        let errs = c.validate().unwrap_err();
913        assert!(
914            errs.iter().any(|e| e.contains("no clauses")),
915            "got: {errs:?}"
916        );
917    }
918
919    #[test]
920    fn validate_rejects_empty_union() {
921        let c = Comprehension::union(vec![]);
922        let errs = c.validate().unwrap_err();
923        assert!(
924            errs.iter().any(|e| e.contains("no sub-spaces")),
925            "got: {errs:?}"
926        );
927    }
928
929    #[test]
930    fn validate_rejects_empty_subspace_inside_union() {
931        let c = Comprehension::union(vec![
932            vec![Clause::new("k", "10")],
933            vec![], // empty sub-space
934        ]);
935        let errs = c.validate().unwrap_err();
936        assert!(
937            errs.iter()
938                .any(|e| e.contains("Union sub-space #1 has no clauses")),
939            "got: {errs:?}"
940        );
941    }
942
943    #[test]
944    fn validate_rejects_cartesian_name_collision() {
945        let c = Comprehension::cartesian(vec![
946            Clause::new("k", "10"),
947            Clause::new("k", "20"), // same name in Cartesian
948        ]);
949        let errs = c.validate().unwrap_err();
950        assert!(
951            errs.iter().any(|e| e.contains("repeats variable name 'k'")),
952            "got: {errs:?}"
953        );
954    }
955
956    #[test]
957    fn validate_rejects_cartesian_collision_with_parallel_clause() {
958        let c = Comprehension::cartesian(vec![
959            Clause::parallel(["x", "y"], ["1..10", "10..100..10"]),
960            Clause::new("y", "100"), // conflicts with parallel-group y
961        ]);
962        let errs = c.validate().unwrap_err();
963        assert!(
964            errs.iter().any(|e| e.contains("repeats variable name 'y'")),
965            "got: {errs:?}"
966        );
967    }
968
969    #[test]
970    fn validate_permits_repeated_names_across_union_subspaces() {
971        // Repeated `k` across sub-spaces is the structural Union
972        // signal — must NOT be rejected.
973        let c = Comprehension::union(vec![
974            vec![Clause::new("k", "10")],
975            vec![Clause::new("k", "100")],
976        ]);
977        assert!(c.validate().is_ok());
978    }
979
980    #[test]
981    fn display_union_uses_pipe_separator_per_subspace() {
982        let c = Comprehension::union(vec![
983            vec![Clause::new("k", "10"), Clause::new("limit", "10,20")],
984            vec![Clause::new("k", "100"), Clause::new("limit", "100,200")],
985        ]);
986        assert_eq!(
987            c.to_string(),
988            "k in 10, limit in 10,20 | k in 100, limit in 100,200"
989        );
990    }
991}