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}