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    /// Gives a consumer a `[(var, spec_expr)]` list for type
154    /// detection, the spec-text shape the runtime's probe
155    /// pre-evaluation (`pre_evaluate_clause`) takes.
156    pub fn coordinate_specs(&self) -> Vec<(String, String)> {
157        let mut acc = Vec::new();
158        let mut seen = std::collections::HashSet::new();
159        self.collect_coordinate_specs(&mut acc, &mut seen);
160        acc
161    }
162
163    /// Grammar-based extraction of the free names referenced by
164    /// every source spec in this comprehension subtree —
165    /// workload params, outer iter-vars, and wires that a
166    /// `Generator` spec (`concat(foo)`, bare `eh_values`)
167    /// consumes. Each spec is parsed with the canonical Polydat
168    /// expression grammar (`crate::refs::referenced_names`)
169    /// rather than byte-scanned, so a bare source reference is
170    /// recognised exactly as the kernel compiler would resolve
171    /// it. `WorkloadParamList { name }` contributes `name`
172    /// directly; literals / ranges / intervals contribute
173    /// nothing. Used by the workload validator's
174    /// declared-but-unreferenced check.
175    pub fn referenced_source_names(&self) -> std::collections::BTreeSet<String> {
176        use super::source::Source;
177        let mut out = std::collections::BTreeSet::new();
178        self.walk_sources(&mut |source| match source {
179            Source::WorkloadParamList { name, .. } => {
180                out.insert(name.clone());
181            }
182            Source::Generator { expr, .. } => {
183                // A generator spec references names two ways: as
184                // parsed free identifiers (`concat(foo)`) and as
185                // `{name}` interpolation placeholders
186                // (`concat({foo_values})`, where the braces are
187                // string-interpolation, not expression syntax —
188                // so the expression parser alone wouldn't see
189                // them). Collect both.
190                out.extend(crate::refs::referenced_names(expr));
191                crate::refs::collect_string_interpolation_refs(expr, &mut out);
192            }
193            Source::Literal { .. }
194            | Source::IntRange { .. }
195            | Source::ContinuousInterval { .. }
196            | Source::Distribution { .. } => {}
197        });
198        out
199    }
200
201    /// Visit every leaf [`Source`] in this comprehension subtree.
202    fn walk_sources(&self, visit: &mut impl FnMut(&super::source::Source)) {
203        match self {
204            Comprehension::Clause { source, .. } => visit(source),
205            Comprehension::Cartesian { children }
206            | Comprehension::Zip { children, .. }
207            | Comprehension::Union { children } => {
208                for c in children {
209                    c.walk_sources(visit);
210                }
211            }
212            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
213                child.walk_sources(visit);
214            }
215        }
216    }
217
218    fn collect_coordinate_specs(
219        &self,
220        acc: &mut Vec<(String, String)>,
221        seen: &mut std::collections::HashSet<String>,
222    ) {
223        use super::source::Source;
224        match self {
225            Comprehension::Clause { name, source } => {
226                if seen.insert(name.clone()) {
227                    let spec_text = match source {
228                        Source::IntRange { lo, hi, step } => {
229                            if *step == 1 {
230                                format!("{lo}..{hi}")
231                            } else {
232                                format!("{lo}..{hi}..{step}")
233                            }
234                        }
235                        Source::Literal { values } if values.len() == 1 => {
236                            literal_value_text(&values[0])
237                        }
238                        Source::Literal { values } => values
239                            .iter()
240                            .map(literal_value_text)
241                            .collect::<Vec<_>>()
242                            .join(", "),
243                        Source::Generator { expr, .. } => expr.clone(),
244                        Source::WorkloadParamList { name, .. } => format!("{{{name}}}"),
245                        Source::ContinuousInterval { interval, .. } => {
246                            // `{:?}` (not `{}`) keeps the decimal point so the
247                            // endpoints re-parse as floats — `{}` on `1.0f64`
248                            // prints "1", round-tripping a continuous `[1.0,5.0)`
249                            // to "1..5", which `parse_source` would re-classify as
250                            // an INTEGER range (typing the iter-var `U64`).
251                            if interval.hi_open {
252                                format!("{:?}..{:?}", interval.lo, interval.hi)
253                            } else {
254                                format!("{:?}..={:?}", interval.lo, interval.hi)
255                            }
256                        }
257                        Source::Distribution { .. } => "<distribution>".to_string(),
258                    };
259                    acc.push((name.clone(), spec_text));
260                }
261            }
262            Comprehension::Cartesian { children } | Comprehension::Zip { children, .. } => {
263                for c in children {
264                    c.collect_coordinate_specs(acc, seen);
265                }
266            }
267            Comprehension::Union { children } => {
268                for c in children {
269                    c.collect_coordinate_specs(acc, seen);
270                }
271            }
272            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
273                child.collect_coordinate_specs(acc, seen);
274            }
275        }
276    }
277
278    fn collect_coordinate_names(&self, acc: &mut Vec<String>) {
279        match self {
280            Comprehension::Clause { name, .. } => {
281                if !acc.contains(name) {
282                    acc.push(name.clone());
283                }
284            }
285            Comprehension::Cartesian { children } | Comprehension::Zip { children, .. } => {
286                for c in children {
287                    c.collect_coordinate_names(acc);
288                }
289            }
290            Comprehension::Union { children } => {
291                // V2 requires identical shape; take the first
292                // child's coordinates as canonical.
293                if let Some(first) = children.first() {
294                    first.collect_coordinate_names(acc);
295                }
296            }
297            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
298                child.collect_coordinate_names(acc);
299            }
300        }
301    }
302
303    /// `true` if this node is a leaf clause.
304    pub fn is_clause(&self) -> bool {
305        matches!(self, Comprehension::Clause { .. })
306    }
307
308    /// `true` if this node is one of the three combinators.
309    pub fn is_combinator(&self) -> bool {
310        matches!(
311            self,
312            Comprehension::Cartesian { .. }
313                | Comprehension::Zip { .. }
314                | Comprehension::Union { .. }
315        )
316    }
317
318    /// `true` if this node is a modifier (`filter` or `order`).
319    pub fn is_modifier(&self) -> bool {
320        matches!(
321            self,
322            Comprehension::Filter { .. } | Comprehension::Order { .. }
323        )
324    }
325
326    /// Iterate this node's direct operand children. Returns
327    /// an empty iterator for leaf clauses.
328    pub fn children(&self) -> Box<dyn Iterator<Item = &Comprehension> + '_> {
329        match self {
330            Comprehension::Clause { .. } => Box::new(std::iter::empty()),
331            Comprehension::Cartesian { children }
332            | Comprehension::Zip { children, .. }
333            | Comprehension::Union { children } => Box::new(children.iter()),
334            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
335                Box::new(std::iter::once(child.as_ref()))
336            }
337        }
338    }
339
340    /// Count of nodes in the AST (this node + all descendants).
341    /// Used by the optimizer's well-founded measure for
342    /// termination (spec §10.6.3).
343    pub fn node_count(&self) -> usize {
344        1 + self.children().map(|c| c.node_count()).sum::<usize>()
345    }
346
347    /// Maximum depth of the AST. Constant for flat composition,
348    /// O(log N) for balanced trees. Bounds the operator stack
349    /// per spec §9.3.
350    pub fn depth(&self) -> usize {
351        1 + self.children().map(|c| c.depth()).max().unwrap_or(0)
352    }
353}
354
355/// Render a [`super::source::LiteralValue`] in legacy
356/// source-text form for [`Comprehension::coordinate_specs`].
357///
358/// Numeric / bool variants render bare; identifier-like
359/// strings render bare; strings with special characters
360/// render quoted with backslash escapes.
361fn literal_value_text(v: &super::source::LiteralValue) -> String {
362    use super::source::LiteralValue;
363    match v {
364        LiteralValue::Int(n) => n.to_string(),
365        LiteralValue::Float(f) => {
366            if f.fract() == 0.0 && f.is_finite() {
367                format!("{f:.1}")
368            } else {
369                format!("{f}")
370            }
371        }
372        LiteralValue::Bool(b) => b.to_string(),
373        LiteralValue::Json(j) => j.to_string(),
374        LiteralValue::String(s) => {
375            let bare_ok = !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_');
376            if bare_ok {
377                s.clone()
378            } else {
379                format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
380            }
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::comprehension::source::{LiteralValue, Source};
389
390    fn lit_int_clause(name: &str, values: &[i64]) -> Comprehension {
391        Comprehension::clause(
392            name,
393            Source::Literal {
394                values: values.iter().map(|n| LiteralValue::Int(*n)).collect(),
395            },
396        )
397    }
398
399    #[test]
400    fn clause_coordinates() {
401        let c = lit_int_clause("k", &[1, 2, 3]);
402        assert_eq!(c.coordinate_names(), vec!["k"]);
403        assert!(c.is_clause());
404        assert!(!c.is_combinator());
405        assert!(!c.is_modifier());
406    }
407
408    #[test]
409    fn continuous_interval_spec_text_round_trips_as_float() {
410        // A continuous `[1.0, 5.0)` must reconstruct to a spec the
411        // source parser re-classifies as CONTINUOUS, not an integer
412        // range. `{}` on `1.0f64` prints "1", so the reconstruction
413        // must use a float-preserving format ("1.0..5.0"), else a
414        // downstream type-probe re-parses "1..5" and types the
415        // iter-var `U64` — silently corrupting a float optimize axis.
416        use crate::comprehension::cardinality::{Interval, ProductMeasure};
417        let c = Comprehension::clause(
418            "ef",
419            Source::ContinuousInterval {
420                interval: Interval {
421                    lo: 1.0,
422                    hi: 5.0,
423                    lo_open: false,
424                    hi_open: true,
425                },
426                measure: ProductMeasure::Uniform,
427            },
428        );
429        let (var, spec_text) = c.coordinate_specs().into_iter().next().unwrap();
430        assert_eq!(var, "ef");
431        // Re-parsing the reconstructed text must yield a continuous
432        // interval again — the round-trip the kernel-type probe relies on.
433        let reparsed = crate::comprehension::spec::parse_source(&spec_text).unwrap();
434        assert!(
435            matches!(reparsed, Source::ContinuousInterval { .. }),
436            "reconstructed '{spec_text}' re-parsed to {reparsed:?}, expected ContinuousInterval"
437        );
438    }
439
440    #[test]
441    fn referenced_source_names_grammar_based() {
442        // `eh in eh_values` — a bare source reference parses to
443        // a Generator whose free name is the workload param.
444        let bare = Comprehension::clause(
445            "eh",
446            Source::Generator {
447                expr: "eh_values".into(),
448                cardinality_hint: None,
449            },
450        );
451        let got: Vec<String> = bare.referenced_source_names().into_iter().collect();
452        assert_eq!(got, vec!["eh_values"]);
453
454        // `(nbo) in (concat(nbo_v_values))` — the source is a
455        // function call; the callee `concat` is NOT a reference
456        // but its argument IS.
457        let call = Comprehension::clause(
458            "nbo",
459            Source::Generator {
460                expr: "concat(nbo_v_values)".into(),
461                cardinality_hint: None,
462            },
463        );
464        let got: Vec<String> = call.referenced_source_names().into_iter().collect();
465        assert_eq!(got, vec!["nbo_v_values"]);
466
467        // `{profiles}` — an explicit WorkloadParamList contributes
468        // its name directly.
469        let wpl = Comprehension::clause(
470            "p",
471            Source::WorkloadParamList {
472                name: "profiles".into(),
473                len_hint: None,
474            },
475        );
476        let got: Vec<String> = wpl.referenced_source_names().into_iter().collect();
477        assert_eq!(got, vec!["profiles"]);
478
479        // Literal sources contribute nothing.
480        let lit = lit_int_clause("k", &[1, 2, 3]);
481        assert!(lit.referenced_source_names().is_empty());
482
483        // Cartesian unions the per-clause references.
484        let cart = Comprehension::cartesian(vec![bare, call]);
485        let got: Vec<String> = cart.referenced_source_names().into_iter().collect();
486        assert_eq!(got, vec!["eh_values", "nbo_v_values"]);
487    }
488
489    #[test]
490    fn cartesian_coordinates_in_declaration_order() {
491        let c = Comprehension::cartesian(vec![
492            lit_int_clause("k", &[1, 2]),
493            lit_int_clause("limit", &[10, 20, 30]),
494        ]);
495        assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
496        assert!(c.is_combinator());
497    }
498
499    #[test]
500    fn zip_coordinates() {
501        let c = Comprehension::zip(
502            vec![
503                lit_int_clause("x", &[1, 2, 3]),
504                lit_int_clause("y", &[10, 20, 30]),
505            ],
506            ZipMode::Strict,
507        );
508        assert_eq!(c.coordinate_names(), vec!["x", "y"]);
509    }
510
511    #[test]
512    fn union_takes_first_childs_shape() {
513        let a = Comprehension::cartesian(vec![
514            lit_int_clause("k", &[10]),
515            lit_int_clause("limit", &[10, 20]),
516        ]);
517        let b = Comprehension::cartesian(vec![
518            lit_int_clause("k", &[100]),
519            lit_int_clause("limit", &[100, 200]),
520        ]);
521        let u = Comprehension::union(vec![a, b]);
522        assert_eq!(u.coordinate_names(), vec!["k", "limit"]);
523    }
524
525    #[test]
526    fn filter_and_order_pass_through_coordinates() {
527        let inner = Comprehension::cartesian(vec![
528            lit_int_clause("k", &[1, 2]),
529            lit_int_clause("limit", &[10]),
530        ]);
531        let filtered = Comprehension::filter(inner.clone(), "{k} > 0");
532        assert_eq!(filtered.coordinate_names(), vec!["k", "limit"]);
533        assert!(filtered.is_modifier());
534
535        let ordered = Comprehension::order(inner, StrategyName::Lex, Some(5));
536        assert_eq!(ordered.coordinate_names(), vec!["k", "limit"]);
537        assert!(ordered.is_modifier());
538    }
539
540    #[test]
541    fn node_count_and_depth() {
542        let inner = Comprehension::cartesian(vec![
543            lit_int_clause("k", &[1, 2]),
544            lit_int_clause("limit", &[10]),
545        ]);
546        // inner: 1 (cartesian) + 2 (clauses) = 3 nodes; depth 2
547        assert_eq!(inner.node_count(), 3);
548        assert_eq!(inner.depth(), 2);
549
550        let filtered = Comprehension::filter(inner, "{k} > 0");
551        // filtered: 1 (filter) + 3 = 4 nodes; depth 3
552        assert_eq!(filtered.node_count(), 4);
553        assert_eq!(filtered.depth(), 3);
554    }
555
556    #[test]
557    fn round_trip_serde() {
558        let c = Comprehension::order(
559            Comprehension::filter(
560                Comprehension::cartesian(vec![
561                    lit_int_clause("k", &[1, 2, 3]),
562                    lit_int_clause("limit", &[10, 20]),
563                ]),
564                "{k} * {limit} > 5",
565            ),
566            StrategyName::Halton,
567            Some(10),
568        );
569        let json = serde_json::to_string(&c).unwrap();
570        let back: Comprehension = serde_json::from_str(&json).unwrap();
571        assert_eq!(c, back);
572    }
573}