Skip to main content

polydat_grammar/comprehension/spec/
legacy_convert.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Legacy → algebra AST converter.
5//!
6//! Reuses the existing legacy parsers
7//! (`polydat::iteration::comprehension::parse::*`) for structural shape
8//! recognition, then converts the legacy `Comprehension`
9//! flat-struct AST to the new algebra-layer operator-tree
10//! [`crate::comprehension::ast::Comprehension`].
11//!
12//! Source-string typing is handled by
13//! [`super::source_parser::parse_source`] — the legacy AST
14//! carries source expressions as raw strings; the algebra
15//! layer requires typed [`crate::comprehension::source::Source`]
16//! values at AST construction time so the validator and
17//! metadata propagator can do their work statically.
18//!
19//! This converter is the "single bridge" the audit calls for:
20//! every legacy AST funnels through here on the way to the
21//! algebra layer. nb-workload's parser remains responsible for
22//! turning YAML / text into legacy ASTs; polydat owns the
23//! conversion onward.
24
25use crate::comprehension::ast::Comprehension as AlgebraAst;
26use crate::comprehension::ast_legacy::{
27    Clause as LegacyClause, ClauseSource as LegacyClauseSource, Comprehension as LegacyAst,
28    ComprehensionMode as LegacyMode, Subspace as LegacySubspace, TraversalOrder as LegacyOrder,
29    ZipMode as LegacyZipMode,
30};
31use crate::comprehension::strategy::{StrategyName, ZipMode as AlgebraZipMode};
32
33use super::source_parser::{SourceParseError, parse_source};
34
35/// Errors produced when converting a legacy AST to algebra.
36#[derive(Debug, Clone, PartialEq)]
37pub enum ConvertError {
38    /// A clause's source string didn't parse to a typed `Source`.
39    SourceParse {
40        /// The clause's element name.
41        clause_var: String,
42        /// The source text.
43        source: String,
44        /// Why it did not parse.
45        cause: SourceParseError,
46    },
47    /// An empty cartesian or empty union mode.
48    EmptyComprehension,
49    /// A union sub-space was empty.
50    EmptyUnionSubspace,
51    /// A parallel clause's vars and exprs had mismatched lengths
52    /// (should be caught by the parser, but defensive here).
53    ParallelArityMismatch {
54        /// Names bound.
55        vars: usize,
56        /// Expressions given.
57        exprs: usize,
58    },
59    /// Custom traversal order encountered — removed from the
60    /// algebra per spec §3.6.
61    CustomOrderingRemoved {
62        /// The function the order named.
63        function: String,
64    },
65}
66
67impl std::fmt::Display for ConvertError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            ConvertError::SourceParse {
71                clause_var,
72                source,
73                cause,
74            } => write!(
75                f,
76                "clause {clause_var:?} source {source:?} failed to parse: {cause}"
77            ),
78            ConvertError::EmptyComprehension => f.write_str("comprehension has no clauses"),
79            ConvertError::EmptyUnionSubspace => f.write_str("union has empty sub-space"),
80            ConvertError::ParallelArityMismatch { vars, exprs } => {
81                write!(f, "parallel clause vars={vars} != exprs={exprs}")
82            }
83            ConvertError::CustomOrderingRemoved { function } => write!(
84                f,
85                "custom ordering {function:?} is no longer supported (spec §3.6)"
86            ),
87        }
88    }
89}
90
91impl std::error::Error for ConvertError {}
92
93/// Convert a legacy [`LegacyAst`] to the algebra-layer
94/// [`AlgebraAst`].
95///
96/// Handles:
97/// - `mode` → cartesian / union
98/// - `filter` → wrapping `Filter` node
99/// - `order` → wrapping `Order` node (with `Custom` removed
100///   per spec §3.6)
101/// - `Clause::Single` source → typed `Source` via
102///   [`parse_source`]
103/// - `Clause::Parallel` source → algebra `Zip` of single-var
104///   clauses (the algebra layer represents parallel iteration
105///   as zip; the legacy parallel-clause shape is an inline
106///   form of the same thing)
107// The algebra → legacy bridge (`algebra_to_legacy_iter_inputs`,
108// `algebra_union_subspaces`, `LegacyIterInputs` + their
109// private helpers) was retired in 9c-4b phase 2 — the
110// executor consumes the algebra runtime evaluator directly,
111// and test fixtures walk the algebra AST natively via
112// `Comprehension::coordinate_specs` etc. What remains here
113// is the forward direction (`legacy_to_algebra`) used by
114// `ComprehensionSpec::into_algebra` to convert parser output
115// to algebra shape.
116pub fn legacy_to_algebra(legacy: &LegacyAst) -> Result<AlgebraAst, ConvertError> {
117    let body = match &legacy.mode {
118        LegacyMode::Cartesian(clauses) => convert_cartesian(clauses)?,
119        LegacyMode::Union(subspaces) => convert_union(subspaces)?,
120    };
121
122    let with_filter = if let Some(pred) = &legacy.filter {
123        AlgebraAst::filter(body, pred.clone())
124    } else {
125        body
126    };
127
128    let with_order = if let Some(order) = &legacy.order {
129        let (strategy, truncation) = convert_order(order)?;
130        AlgebraAst::order(with_filter, strategy, truncation)
131    } else {
132        with_filter
133    };
134
135    Ok(with_order)
136}
137
138fn convert_cartesian(clauses: &[LegacyClause]) -> Result<AlgebraAst, ConvertError> {
139    if clauses.is_empty() {
140        return Err(ConvertError::EmptyComprehension);
141    }
142    let algebra_children: Vec<AlgebraAst> = clauses
143        .iter()
144        .map(convert_clause)
145        .collect::<Result<_, _>>()?;
146    if algebra_children.len() == 1 {
147        // Single clause = the clause itself (R0a I2 would
148        // eliminate the singleton cartesian anyway; produce
149        // the canonical form upfront).
150        Ok(algebra_children.into_iter().next().unwrap())
151    } else {
152        Ok(AlgebraAst::cartesian(algebra_children))
153    }
154}
155
156fn convert_union(subspaces: &[LegacySubspace]) -> Result<AlgebraAst, ConvertError> {
157    if subspaces.is_empty() {
158        return Err(ConvertError::EmptyComprehension);
159    }
160    let algebra_children: Vec<AlgebraAst> = subspaces
161        .iter()
162        .map(|s| {
163            if s.is_empty() {
164                Err(ConvertError::EmptyUnionSubspace)
165            } else {
166                convert_cartesian(&s.clauses)
167            }
168        })
169        .collect::<Result<_, _>>()?;
170    if algebra_children.len() == 1 {
171        Ok(algebra_children.into_iter().next().unwrap())
172    } else {
173        Ok(AlgebraAst::union(algebra_children))
174    }
175}
176
177fn convert_clause(clause: &LegacyClause) -> Result<AlgebraAst, ConvertError> {
178    match &clause.source {
179        LegacyClauseSource::Single(source_str) => {
180            let var = clause
181                .single_var()
182                .unwrap_or_else(|| clause.first_var())
183                .to_string();
184            let source = parse_source(source_str).map_err(|cause| ConvertError::SourceParse {
185                clause_var: var.clone(),
186                source: source_str.clone(),
187                cause,
188            })?;
189            Ok(AlgebraAst::clause(var, source))
190        }
191        LegacyClauseSource::Parallel { mode, exprs } => {
192            if clause.vars.len() != exprs.len() {
193                return Err(ConvertError::ParallelArityMismatch {
194                    vars: clause.vars.len(),
195                    exprs: exprs.len(),
196                });
197            }
198            // Parallel iteration in legacy = zip in algebra.
199            // Build a single-var clause per (var, expr) pair,
200            // wrap in a Zip with the converted mode.
201            let mut children = Vec::with_capacity(clause.vars.len());
202            for (var, expr) in clause.vars.iter().zip(exprs.iter()) {
203                let source = parse_source(expr).map_err(|cause| ConvertError::SourceParse {
204                    clause_var: var.clone(),
205                    source: expr.clone(),
206                    cause,
207                })?;
208                children.push(AlgebraAst::clause(var.clone(), source));
209            }
210            let zip_mode = convert_zip_mode(*mode);
211            Ok(AlgebraAst::zip(children, zip_mode))
212        }
213    }
214}
215
216fn convert_zip_mode(legacy: LegacyZipMode) -> AlgebraZipMode {
217    match legacy {
218        LegacyZipMode::Strict => AlgebraZipMode::Strict,
219        LegacyZipMode::Truncate => AlgebraZipMode::Truncate,
220        LegacyZipMode::Cycle => AlgebraZipMode::Cycle,
221    }
222}
223
224/// Convert a legacy [`LegacyOrder`] into the algebra's
225/// `(StrategyName, Option<u64>)` pair.
226///
227/// The legacy `Custom { function }` form is rejected — per
228/// spec §3.6, custom orderings are no longer supported.
229fn convert_order(order: &LegacyOrder) -> Result<(StrategyName, Option<u64>), ConvertError> {
230    let pair = match order {
231        LegacyOrder::Lex { count } => (StrategyName::Lex, count.map(|n| n as u64)),
232        LegacyOrder::ReverseLex { count } => (StrategyName::ReverseLex, count.map(|n| n as u64)),
233        LegacyOrder::Diagonal { count } => (StrategyName::Diagonal, count.map(|n| n as u64)),
234        LegacyOrder::Antidiagonal { count } => {
235            (StrategyName::Antidiagonal, count.map(|n| n as u64))
236        }
237        LegacyOrder::Extrema { strata } => (StrategyName::Extrema, strata.map(|n| n as u64)),
238        LegacyOrder::Shells { depth, .. } => (StrategyName::Shells, depth.map(|n| n as u64)),
239        LegacyOrder::Halton { count } => (StrategyName::Halton, count.map(|n| n as u64)),
240        LegacyOrder::Sobol { count } => (StrategyName::Sobol, count.map(|n| n as u64)),
241        LegacyOrder::Lhs { count, .. } => (StrategyName::Lhs, count.map(|n| n as u64)),
242        LegacyOrder::Custom { function } => {
243            return Err(ConvertError::CustomOrderingRemoved {
244                function: function.clone(),
245            });
246        }
247    };
248    Ok(pair)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::comprehension::source::{LiteralValue, Source};
255
256    fn legacy_clause(var: &str, source: &str) -> LegacyClause {
257        LegacyClause::new(var, source)
258    }
259
260    #[test]
261    fn cartesian_single_clause_collapses_to_clause() {
262        let legacy = LegacyAst {
263            mode: LegacyMode::Cartesian(vec![legacy_clause("k", "1..10")]),
264            filter: None,
265            order: None,
266        };
267        let algebra = legacy_to_algebra(&legacy).unwrap();
268        match algebra {
269            AlgebraAst::Clause { name, source } => {
270                assert_eq!(name, "k");
271                assert!(matches!(
272                    source,
273                    Source::IntRange {
274                        lo: 1,
275                        hi: 10,
276                        step: 1
277                    }
278                ));
279            }
280            other => panic!("expected Clause, got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn multi_clause_cartesian_becomes_algebra_cartesian() {
286        let legacy = LegacyAst {
287            mode: LegacyMode::Cartesian(vec![
288                legacy_clause("k", "1..10"),
289                legacy_clause("limit", "[10, 100, 1000]"),
290            ]),
291            filter: None,
292            order: None,
293        };
294        let algebra = legacy_to_algebra(&legacy).unwrap();
295        match algebra {
296            AlgebraAst::Cartesian { children } => {
297                assert_eq!(children.len(), 2);
298                // First clause: int range
299                match &children[0] {
300                    AlgebraAst::Clause { name, source } => {
301                        assert_eq!(name, "k");
302                        assert!(matches!(
303                            source,
304                            Source::IntRange {
305                                lo: 1,
306                                hi: 10,
307                                step: 1
308                            }
309                        ));
310                    }
311                    other => panic!("expected Clause, got {other:?}"),
312                }
313                // Second clause: literal list
314                match &children[1] {
315                    AlgebraAst::Clause { name, source } => {
316                        assert_eq!(name, "limit");
317                        match source {
318                            Source::Literal { values } => {
319                                assert_eq!(values.len(), 3);
320                                assert_eq!(values[0], LiteralValue::Int(10));
321                            }
322                            other => panic!("expected Literal, got {other:?}"),
323                        }
324                    }
325                    other => panic!("expected Clause, got {other:?}"),
326                }
327            }
328            other => panic!("expected Cartesian, got {other:?}"),
329        }
330    }
331
332    #[test]
333    fn filter_wraps_body() {
334        let legacy = LegacyAst {
335            mode: LegacyMode::Cartesian(vec![legacy_clause("k", "1..10")]),
336            filter: Some("{k} > 5".to_string()),
337            order: None,
338        };
339        let algebra = legacy_to_algebra(&legacy).unwrap();
340        assert!(matches!(algebra, AlgebraAst::Filter { .. }));
341    }
342
343    #[test]
344    fn order_lex_with_count_round_trips() {
345        let legacy = LegacyAst {
346            mode: LegacyMode::Cartesian(vec![legacy_clause("k", "1..10")]),
347            filter: None,
348            order: Some(LegacyOrder::Lex { count: Some(5) }),
349        };
350        let algebra = legacy_to_algebra(&legacy).unwrap();
351        match algebra {
352            AlgebraAst::Order {
353                strategy: StrategyName::Lex,
354                truncation: Some(5),
355                ..
356            } => {}
357            other => panic!("expected Order(Lex, Some(5)), got {other:?}"),
358        }
359    }
360
361    #[test]
362    fn order_halton_with_count() {
363        let legacy = LegacyAst {
364            mode: LegacyMode::Cartesian(vec![
365                legacy_clause("k", "1..10"),
366                legacy_clause("limit", "1..100"),
367            ]),
368            filter: None,
369            order: Some(LegacyOrder::Halton { count: Some(20) }),
370        };
371        let algebra = legacy_to_algebra(&legacy).unwrap();
372        match algebra {
373            AlgebraAst::Order {
374                strategy: StrategyName::Halton,
375                truncation: Some(20),
376                ..
377            } => {}
378            other => panic!("expected Order(Halton, Some(20)), got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn custom_ordering_rejected() {
384        let legacy = LegacyAst {
385            mode: LegacyMode::Cartesian(vec![legacy_clause("k", "1..10")]),
386            filter: None,
387            order: Some(LegacyOrder::Custom {
388                function: "my_fn".to_string(),
389            }),
390        };
391        let err = legacy_to_algebra(&legacy).unwrap_err();
392        assert!(matches!(err, ConvertError::CustomOrderingRemoved { .. }));
393    }
394
395    #[test]
396    fn union_of_subspaces() {
397        let legacy = LegacyAst {
398            mode: LegacyMode::Union(vec![
399                LegacySubspace::new(vec![
400                    legacy_clause("k", "10"),
401                    legacy_clause("limit", "[1, 2, 3]"),
402                ]),
403                LegacySubspace::new(vec![
404                    legacy_clause("k", "100"),
405                    legacy_clause("limit", "[10, 20, 30]"),
406                ]),
407            ]),
408            filter: None,
409            order: None,
410        };
411        let algebra = legacy_to_algebra(&legacy).unwrap();
412        match algebra {
413            AlgebraAst::Union { children } => assert_eq!(children.len(), 2),
414            other => panic!("expected Union, got {other:?}"),
415        }
416    }
417
418    #[test]
419    fn parallel_clause_becomes_zip() {
420        let parallel = LegacyClause::parallel(["x", "y"], ["1..3", "10..30"]);
421        let legacy = LegacyAst {
422            mode: LegacyMode::Cartesian(vec![parallel]),
423            filter: None,
424            order: None,
425        };
426        let algebra = legacy_to_algebra(&legacy).unwrap();
427        // After the singleton-cartesian elide, the Zip
428        // surfaces at the top level.
429        match algebra {
430            AlgebraAst::Zip {
431                children,
432                mode: AlgebraZipMode::Strict,
433            } => {
434                assert_eq!(children.len(), 2);
435            }
436            other => panic!("expected Zip, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn unparseable_source_falls_back_to_generator() {
442        // parse_source now treats unrecognized text as a
443        // Generator expression (runtime evaluates). So
444        // "totally nonsense" round-trips through algebra as
445        // a Source::Generator. No conversion error.
446        let legacy = LegacyAst {
447            mode: LegacyMode::Cartesian(vec![legacy_clause("k", "totally nonsense")]),
448            filter: None,
449            order: None,
450        };
451        let algebra = legacy_to_algebra(&legacy).unwrap();
452        match algebra {
453            AlgebraAst::Clause { source, .. } => match source {
454                crate::comprehension::source::Source::Generator { expr, .. } => {
455                    assert_eq!(expr, "totally nonsense");
456                }
457                other => panic!("expected Generator, got {other:?}"),
458            },
459            other => panic!("expected Clause, got {other:?}"),
460        }
461    }
462
463    // (algebra → legacy back-converter tests retired with the
464    // bridge in 9c-4b phase 2. The forward direction
465    // (`legacy_to_algebra`) tests above remain.)
466}