Skip to main content

polydat_grammar/comprehension/
ast.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Operator-tree comprehension AST — spec §3.
5//!
6//! Six constructors closed under composition: one source
7//! (`clause`), three combinators (`cartesian`, `zip`, `union`),
8//! two modifiers (`filter`, `order`). Every comprehension AST is
9//! a tree whose nodes are one of these six variants.
10//!
11//! Closure (spec §4.1):
12//!
13//! - C1 — every constructor returns and consumes
14//!   `Comprehension`. There is no auxiliary value type at the
15//!   AST level.
16//! - C2 — well-formedness is decidable in one bottom-up pass
17//!   (the `validate` module of the runtime
18//!   implements the check).
19
20use serde::{Deserialize, Serialize};
21
22use super::source::Source;
23use super::strategy::{StrategyName, ZipMode};
24
25/// The six-variant operator-tree comprehension.
26///
27/// Closure under composition (spec §4.1 C1): every variant
28/// holds one or more `Comprehension` operands plus
29/// constructor-specific scalar parameters (predicate, strategy,
30/// truncation, zip mode, source).
31///
32/// `Box<Comprehension>` appears wherever a variant needs a
33/// single child operand; `Vec<Comprehension>` wherever a
34/// constructor takes N children (cartesian, zip, union).
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(tag = "op", rename_all = "snake_case")]
37pub enum Comprehension {
38    /// Leaf source per spec §3.1. Binds `name` to one value
39    /// per dispense, drawn from `source`.
40    Clause {
41        /// The name bound.
42        name: String,
43        /// Where the values come from.
44        source: Source,
45    },
46
47    /// Cross-product combinator per spec §3.2. Children must
48    /// have disjoint name sets (V1).
49    Cartesian {
50        /// The factors.
51        children: Vec<Comprehension>,
52    },
53
54    /// Lockstep combinator per spec §3.3. Children must be
55    /// discrete (V7) and have disjoint name sets (V1).
56    Zip {
57        /// The streams zipped.
58        children: Vec<Comprehension>,
59        /// The length policy.
60        mode: ZipMode,
61    },
62
63    /// Concatenation combinator per spec §3.4. Children must
64    /// share an identical tuple shape (V2) and all be discrete
65    /// (V9).
66    Union {
67        /// The streams concatenated, in order.
68        children: Vec<Comprehension>,
69    },
70
71    /// Selection modifier per spec §3.5. Predicate is a GK
72    /// boolean expression; names must close over the child's
73    /// coordinates plus the parent scope (V3).
74    Filter {
75        /// The stream filtered.
76        child: Box<Comprehension>,
77        /// The predicate, a boolean expression over the tuple and the parent scope.
78        predicate: String,
79    },
80
81    /// Permutation modifier per spec §3.6. `strategy` must
82    /// accept the child's IndexFn (V4); `truncation` limits the
83    /// dispensed count.
84    Order {
85        /// The stream ordered.
86        child: Box<Comprehension>,
87        /// The strategy applied.
88        strategy: StrategyName,
89        /// The dispensed count cap, if any.
90        truncation: Option<u64>,
91    },
92}
93
94impl Comprehension {
95    /// Construct a leaf clause.
96    pub fn clause<S: Into<String>>(name: S, source: Source) -> Self {
97        Comprehension::Clause {
98            name: name.into(),
99            source,
100        }
101    }
102
103    /// Construct a cartesian over the supplied children.
104    pub fn cartesian(children: Vec<Comprehension>) -> Self {
105        Comprehension::Cartesian { children }
106    }
107
108    /// Construct a zip over the supplied children with the
109    /// given mode.
110    pub fn zip(children: Vec<Comprehension>, mode: ZipMode) -> Self {
111        Comprehension::Zip { children, mode }
112    }
113
114    /// Construct a union over the supplied children.
115    pub fn union(children: Vec<Comprehension>) -> Self {
116        Comprehension::Union { children }
117    }
118
119    /// Construct a filter wrapping `child` with `predicate`.
120    pub fn filter<S: Into<String>>(child: Comprehension, predicate: S) -> Self {
121        Comprehension::Filter {
122            child: Box::new(child),
123            predicate: predicate.into(),
124        }
125    }
126
127    /// Construct an order node wrapping `child`.
128    pub fn order(child: Comprehension, strategy: StrategyName, truncation: Option<u64>) -> Self {
129        Comprehension::Order {
130            child: Box::new(child),
131            strategy,
132            truncation,
133        }
134    }
135
136    /// Compute the comprehension's coordinate name set,
137    /// recursively. The result preserves declaration order
138    /// (per spec §3.2 + §3.4's "in declaration order" tuple
139    /// shape rules). Used by V1, V2, V3, and the predicate
140    /// analyzer's coord-set input.
141    pub fn coordinate_names(&self) -> Vec<String> {
142        let mut acc = Vec::new();
143        self.collect_coordinate_names(&mut acc);
144        acc
145    }
146
147    /// Compute `(coordinate_name, source_text)` pairs in
148    /// declaration order, deduplicated by name (first
149    /// occurrence wins). Source text is the round-trip-to-
150    /// legacy form — `IntRange { 1, 10, 1 }` → `"1..10"`,
151    /// `Literal { [10, 100] }` → `"10, 100"`, etc.
152    ///
153    /// Used by the runtime's per-iter scope-kernel synthesis
154    /// to construct a `[(var, spec_expr)]` list for type
155    /// detection (per `build_for_each_scope_kernel`'s probe
156    /// pre-evaluation).
157    pub fn coordinate_specs(&self) -> Vec<(String, String)> {
158        let mut acc = Vec::new();
159        let mut seen = std::collections::HashSet::new();
160        self.collect_coordinate_specs(&mut acc, &mut seen);
161        acc
162    }
163
164    /// Grammar-based extraction of the free names referenced by
165    /// every source spec in this comprehension subtree —
166    /// workload params, outer iter-vars, and wires that a
167    /// `Generator` spec (`concat(foo)`, bare `eh_values`)
168    /// consumes. Each spec is parsed with the canonical Polydat
169    /// expression grammar (`crate::refs::referenced_names`)
170    /// rather than byte-scanned, so a bare source reference is
171    /// recognised exactly as the kernel compiler would resolve
172    /// it. `WorkloadParamList { name }` contributes `name`
173    /// directly; literals / ranges / intervals contribute
174    /// nothing. Used by the workload validator's
175    /// declared-but-unreferenced check.
176    pub fn referenced_source_names(&self) -> std::collections::BTreeSet<String> {
177        use super::source::Source;
178        let mut out = std::collections::BTreeSet::new();
179        self.walk_sources(&mut |source| match source {
180            Source::WorkloadParamList { name, .. } => {
181                out.insert(name.clone());
182            }
183            Source::Generator { expr, .. } => {
184                // A generator spec references names two ways: as
185                // parsed free identifiers (`concat(foo)`) and as
186                // `{name}` interpolation placeholders
187                // (`concat({foo_values})`, where the braces are
188                // string-interpolation, not expression syntax —
189                // so the expression parser alone wouldn't see
190                // them). Collect both.
191                out.extend(crate::refs::referenced_names(expr));
192                crate::refs::collect_string_interpolation_refs(expr, &mut out);
193            }
194            Source::Literal { .. }
195            | Source::IntRange { .. }
196            | Source::ContinuousInterval { .. }
197            | Source::Distribution { .. } => {}
198        });
199        out
200    }
201
202    /// Visit every leaf [`Source`] in this comprehension subtree.
203    fn walk_sources(&self, visit: &mut impl FnMut(&super::source::Source)) {
204        match self {
205            Comprehension::Clause { source, .. } => visit(source),
206            Comprehension::Cartesian { children }
207            | Comprehension::Zip { children, .. }
208            | Comprehension::Union { children } => {
209                for c in children {
210                    c.walk_sources(visit);
211                }
212            }
213            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
214                child.walk_sources(visit);
215            }
216        }
217    }
218
219    fn collect_coordinate_specs(
220        &self,
221        acc: &mut Vec<(String, String)>,
222        seen: &mut std::collections::HashSet<String>,
223    ) {
224        use super::source::Source;
225        match self {
226            Comprehension::Clause { name, source } => {
227                if seen.insert(name.clone()) {
228                    let spec_text = match source {
229                        Source::IntRange { lo, hi, step } => {
230                            if *step == 1 {
231                                format!("{lo}..{hi}")
232                            } else {
233                                format!("{lo}..{hi}..{step}")
234                            }
235                        }
236                        Source::Literal { values } if values.len() == 1 => {
237                            literal_value_text(&values[0])
238                        }
239                        Source::Literal { values } => values
240                            .iter()
241                            .map(literal_value_text)
242                            .collect::<Vec<_>>()
243                            .join(", "),
244                        Source::Generator { expr, .. } => expr.clone(),
245                        Source::WorkloadParamList { name, .. } => format!("{{{name}}}"),
246                        Source::ContinuousInterval { interval, .. } => {
247                            // `{:?}` (not `{}`) keeps the decimal point so the
248                            // endpoints re-parse as floats — `{}` on `1.0f64`
249                            // prints "1", round-tripping a continuous `[1.0,5.0)`
250                            // to "1..5", which `parse_source` would re-classify as
251                            // an INTEGER range (typing the iter-var `U64`).
252                            if interval.hi_open {
253                                format!("{:?}..{:?}", interval.lo, interval.hi)
254                            } else {
255                                format!("{:?}..={:?}", interval.lo, interval.hi)
256                            }
257                        }
258                        Source::Distribution { .. } => "<distribution>".to_string(),
259                    };
260                    acc.push((name.clone(), spec_text));
261                }
262            }
263            Comprehension::Cartesian { children } | Comprehension::Zip { children, .. } => {
264                for c in children {
265                    c.collect_coordinate_specs(acc, seen);
266                }
267            }
268            Comprehension::Union { children } => {
269                for c in children {
270                    c.collect_coordinate_specs(acc, seen);
271                }
272            }
273            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
274                child.collect_coordinate_specs(acc, seen);
275            }
276        }
277    }
278
279    fn collect_coordinate_names(&self, acc: &mut Vec<String>) {
280        match self {
281            Comprehension::Clause { name, .. } => {
282                if !acc.contains(name) {
283                    acc.push(name.clone());
284                }
285            }
286            Comprehension::Cartesian { children } | Comprehension::Zip { children, .. } => {
287                for c in children {
288                    c.collect_coordinate_names(acc);
289                }
290            }
291            Comprehension::Union { children } => {
292                // V2 requires identical shape; take the first
293                // child's coordinates as canonical.
294                if let Some(first) = children.first() {
295                    first.collect_coordinate_names(acc);
296                }
297            }
298            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
299                child.collect_coordinate_names(acc);
300            }
301        }
302    }
303
304    /// `true` if this node is a leaf clause.
305    pub fn is_clause(&self) -> bool {
306        matches!(self, Comprehension::Clause { .. })
307    }
308
309    /// `true` if this node is one of the three combinators.
310    pub fn is_combinator(&self) -> bool {
311        matches!(
312            self,
313            Comprehension::Cartesian { .. }
314                | Comprehension::Zip { .. }
315                | Comprehension::Union { .. }
316        )
317    }
318
319    /// `true` if this node is a modifier (`filter` or `order`).
320    pub fn is_modifier(&self) -> bool {
321        matches!(
322            self,
323            Comprehension::Filter { .. } | Comprehension::Order { .. }
324        )
325    }
326
327    /// Iterate this node's direct operand children. Returns
328    /// an empty iterator for leaf clauses.
329    pub fn children(&self) -> Box<dyn Iterator<Item = &Comprehension> + '_> {
330        match self {
331            Comprehension::Clause { .. } => Box::new(std::iter::empty()),
332            Comprehension::Cartesian { children }
333            | Comprehension::Zip { children, .. }
334            | Comprehension::Union { children } => Box::new(children.iter()),
335            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
336                Box::new(std::iter::once(child.as_ref()))
337            }
338        }
339    }
340
341    /// Count of nodes in the AST (this node + all descendants).
342    /// Used by the optimizer's well-founded measure for
343    /// termination (spec §10.6.3).
344    pub fn node_count(&self) -> usize {
345        1 + self.children().map(|c| c.node_count()).sum::<usize>()
346    }
347
348    /// Maximum depth of the AST. Constant for flat composition,
349    /// O(log N) for balanced trees. Bounds the operator stack
350    /// per spec §9.3.
351    pub fn depth(&self) -> usize {
352        1 + self.children().map(|c| c.depth()).max().unwrap_or(0)
353    }
354}
355
356/// Render a [`super::source::LiteralValue`] in legacy
357/// source-text form for [`Comprehension::coordinate_specs`].
358///
359/// Numeric / bool variants render bare; identifier-like
360/// strings render bare; strings with special characters
361/// render quoted with backslash escapes. Matches the
362/// round-trip rendering in `algebra::spec::legacy_convert`'s
363/// `literal_value_to_legacy_text`.
364fn literal_value_text(v: &super::source::LiteralValue) -> String {
365    use super::source::LiteralValue;
366    match v {
367        LiteralValue::Int(n) => n.to_string(),
368        LiteralValue::Float(f) => {
369            if f.fract() == 0.0 && f.is_finite() {
370                format!("{f:.1}")
371            } else {
372                format!("{f}")
373            }
374        }
375        LiteralValue::Bool(b) => b.to_string(),
376        LiteralValue::Json(j) => j.to_string(),
377        LiteralValue::String(s) => {
378            let bare_ok = !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_');
379            if bare_ok {
380                s.clone()
381            } else {
382                format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
383            }
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::comprehension::source::{LiteralValue, Source};
392
393    fn lit_int_clause(name: &str, values: &[i64]) -> Comprehension {
394        Comprehension::clause(
395            name,
396            Source::Literal {
397                values: values.iter().map(|n| LiteralValue::Int(*n)).collect(),
398            },
399        )
400    }
401
402    #[test]
403    fn clause_coordinates() {
404        let c = lit_int_clause("k", &[1, 2, 3]);
405        assert_eq!(c.coordinate_names(), vec!["k"]);
406        assert!(c.is_clause());
407        assert!(!c.is_combinator());
408        assert!(!c.is_modifier());
409    }
410
411    #[test]
412    fn continuous_interval_spec_text_round_trips_as_float() {
413        // A continuous `[1.0, 5.0)` must reconstruct to a spec the
414        // source parser re-classifies as CONTINUOUS, not an integer
415        // range. `{}` on `1.0f64` prints "1", so the reconstruction
416        // must use a float-preserving format ("1.0..5.0"), else a
417        // downstream type-probe re-parses "1..5" and types the
418        // iter-var `U64` — silently corrupting a float optimize axis.
419        use crate::comprehension::cardinality::{Interval, ProductMeasure};
420        let c = Comprehension::clause(
421            "ef",
422            Source::ContinuousInterval {
423                interval: Interval {
424                    lo: 1.0,
425                    hi: 5.0,
426                    lo_open: false,
427                    hi_open: true,
428                },
429                measure: ProductMeasure::Uniform,
430            },
431        );
432        let (var, spec_text) = c.coordinate_specs().into_iter().next().unwrap();
433        assert_eq!(var, "ef");
434        // Re-parsing the reconstructed text must yield a continuous
435        // interval again — the round-trip the kernel-type probe relies on.
436        let reparsed = crate::comprehension::spec::parse_source(&spec_text).unwrap();
437        assert!(
438            matches!(reparsed, Source::ContinuousInterval { .. }),
439            "reconstructed '{spec_text}' re-parsed to {reparsed:?}, expected ContinuousInterval"
440        );
441    }
442
443    #[test]
444    fn referenced_source_names_grammar_based() {
445        // `eh in eh_values` — a bare source reference parses to
446        // a Generator whose free name is the workload param.
447        let bare = Comprehension::clause(
448            "eh",
449            Source::Generator {
450                expr: "eh_values".into(),
451                cardinality_hint: None,
452            },
453        );
454        let got: Vec<String> = bare.referenced_source_names().into_iter().collect();
455        assert_eq!(got, vec!["eh_values"]);
456
457        // `(nbo) in (concat(nbo_v_values))` — the source is a
458        // function call; the callee `concat` is NOT a reference
459        // but its argument IS.
460        let call = Comprehension::clause(
461            "nbo",
462            Source::Generator {
463                expr: "concat(nbo_v_values)".into(),
464                cardinality_hint: None,
465            },
466        );
467        let got: Vec<String> = call.referenced_source_names().into_iter().collect();
468        assert_eq!(got, vec!["nbo_v_values"]);
469
470        // `{profiles}` — an explicit WorkloadParamList contributes
471        // its name directly.
472        let wpl = Comprehension::clause(
473            "p",
474            Source::WorkloadParamList {
475                name: "profiles".into(),
476                len_hint: None,
477            },
478        );
479        let got: Vec<String> = wpl.referenced_source_names().into_iter().collect();
480        assert_eq!(got, vec!["profiles"]);
481
482        // Literal sources contribute nothing.
483        let lit = lit_int_clause("k", &[1, 2, 3]);
484        assert!(lit.referenced_source_names().is_empty());
485
486        // Cartesian unions the per-clause references.
487        let cart = Comprehension::cartesian(vec![bare, call]);
488        let got: Vec<String> = cart.referenced_source_names().into_iter().collect();
489        assert_eq!(got, vec!["eh_values", "nbo_v_values"]);
490    }
491
492    #[test]
493    fn cartesian_coordinates_in_declaration_order() {
494        let c = Comprehension::cartesian(vec![
495            lit_int_clause("k", &[1, 2]),
496            lit_int_clause("limit", &[10, 20, 30]),
497        ]);
498        assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
499        assert!(c.is_combinator());
500    }
501
502    #[test]
503    fn zip_coordinates() {
504        let c = Comprehension::zip(
505            vec![
506                lit_int_clause("x", &[1, 2, 3]),
507                lit_int_clause("y", &[10, 20, 30]),
508            ],
509            ZipMode::Strict,
510        );
511        assert_eq!(c.coordinate_names(), vec!["x", "y"]);
512    }
513
514    #[test]
515    fn union_takes_first_childs_shape() {
516        let a = Comprehension::cartesian(vec![
517            lit_int_clause("k", &[10]),
518            lit_int_clause("limit", &[10, 20]),
519        ]);
520        let b = Comprehension::cartesian(vec![
521            lit_int_clause("k", &[100]),
522            lit_int_clause("limit", &[100, 200]),
523        ]);
524        let u = Comprehension::union(vec![a, b]);
525        assert_eq!(u.coordinate_names(), vec!["k", "limit"]);
526    }
527
528    #[test]
529    fn filter_and_order_pass_through_coordinates() {
530        let inner = Comprehension::cartesian(vec![
531            lit_int_clause("k", &[1, 2]),
532            lit_int_clause("limit", &[10]),
533        ]);
534        let filtered = Comprehension::filter(inner.clone(), "{k} > 0");
535        assert_eq!(filtered.coordinate_names(), vec!["k", "limit"]);
536        assert!(filtered.is_modifier());
537
538        let ordered = Comprehension::order(inner, StrategyName::Lex, Some(5));
539        assert_eq!(ordered.coordinate_names(), vec!["k", "limit"]);
540        assert!(ordered.is_modifier());
541    }
542
543    #[test]
544    fn node_count_and_depth() {
545        let inner = Comprehension::cartesian(vec![
546            lit_int_clause("k", &[1, 2]),
547            lit_int_clause("limit", &[10]),
548        ]);
549        // inner: 1 (cartesian) + 2 (clauses) = 3 nodes; depth 2
550        assert_eq!(inner.node_count(), 3);
551        assert_eq!(inner.depth(), 2);
552
553        let filtered = Comprehension::filter(inner, "{k} > 0");
554        // filtered: 1 (filter) + 3 = 4 nodes; depth 3
555        assert_eq!(filtered.node_count(), 4);
556        assert_eq!(filtered.depth(), 3);
557    }
558
559    #[test]
560    fn round_trip_serde() {
561        let c = Comprehension::order(
562            Comprehension::filter(
563                Comprehension::cartesian(vec![
564                    lit_int_clause("k", &[1, 2, 3]),
565                    lit_int_clause("limit", &[10, 20]),
566                ]),
567                "{k} * {limit} > 5",
568            ),
569            StrategyName::Halton,
570            Some(10),
571        );
572        let json = serde_json::to_string(&c).unwrap();
573        let back: Comprehension = serde_json::from_str(&json).unwrap();
574        assert_eq!(c, back);
575    }
576}