Skip to main content

polydat_core/iteration/comprehension/optimize/
mod.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Post-parse optimizer — spec §10.
5//!
6//! Required pass upstream of compilation. Takes an AST and
7//! produces a canonical, push-down form with these properties
8//! (per spec §10.6):
9//!
10//! 1. **Semantic-preserving.** Output produces the same
11//!    dispense sequence (per §9.2).
12//! 2. **Idempotent.** `optimize(optimize(C)) == optimize(C)`.
13//! 3. **Decidable termination.** Each rewrite strictly
14//!    decreases a metadata-derived measure or leaves the AST
15//!    unchanged.
16//! 4. **Bounds-improving.** Peak memory never grows.
17//! 5. **No rejections.** Validity is decided pre-optimizer.
18//!
19//! ## R-rule catalog
20//!
21//! Priority order: R0a → R0b → R1 → R2 → R3 → R4 → R5 → R6 →
22//! R7 (spec §10.10.5).
23//!
24//! - **R0a — identity elimination** (I1–I5): singleton
25//!   combinators, trivially-true filter, `order(Lex, None)`.
26//! - **R0b — associativity flattening** (A1, A2): nested
27//!   union / cartesian collapse to n-ary form.
28//! - **R1 — `order(Lex)` → `ORDER_STREAMING`**: metadata-
29//!   driven. The IR compiler (Phase 7) reads
30//!   `metadata.materialization == Streaming` for
31//!   `order(Lex, _)` and emits `ORDER_STREAMING`. Not an AST
32//!   rewrite; recorded in the reducibility catalog as an
33//!   IR-compilation eligibility.
34//! - **R2 — `order(c, strategy, Some(n))` → `indexed_order`**:
35//!   metadata-driven. Working set already shrunk via
36//!   `strategy_working_set` in `metadata.rs`'s propagation
37//!   rule. IR compiler emits `ORDER_MATERIALIZE` with the
38//!   indexed variant.
39//! - **R3 — `order(filter, Lex, None)` → `filter(order, Lex, None)`**:
40//!   AST rewrite. Commute when un-truncated.
41//! - **R4 — `filter(union(...), p)` → `union(filter(...))`**:
42//!   AST rewrite. Distribute filter into each union child.
43//! - **R5 — per-axis filter pushdown**: AST rewrite. Consults
44//!   the predicate analyzer (§10.9) for factorization; when
45//!   `factorization = PerAxis`, splits the filter into
46//!   per-axis filters wrapping each cartesian child.
47//! - **R6 — chained filter folding** (F1): AST rewrite.
48//!   `filter(filter(c, p), q)` → `filter(c, p && q)`.
49//! - **R7 — order chain folding** (O1): AST rewrite.
50//!   `order(order(c, s1, None), s2, t)` → `order(c, s2, t)`.
51//!
52//! ## Module layout
53//!
54//! - [`finding`] — `ReducibilityFinding`, `Reduction`,
55//!   `ComplexityDelta`.
56//! - [`r0a_identity`] — I1–I5 elimination.
57//! - [`r0b_flatten`] — A1, A2 flattening.
58//! - [`r3_commute`] — Lex/filter commute.
59//! - [`r4_distribute`] — filter over union.
60//! - [`r5_factorize`] — per-axis filter pushdown.
61//! - [`r6_filter_fold`] — chained filter folding.
62//! - [`r7_order_fold`] — order chain folding.
63
64use super::ast::Comprehension;
65use super::predicate::CoordSet;
66use crate::iteration::comprehension::metadata::Metadata;
67
68pub mod finding;
69pub mod r0a_identity;
70pub mod r0b_flatten;
71pub mod r3_commute;
72pub mod r4_distribute;
73pub mod r5_factorize;
74pub mod r6_filter_fold;
75pub mod r7_order_fold;
76
77pub use finding::{
78    ComplexityDelta, Ordering as ComplexityOrdering, ReducibilityFinding, Reduction, RuleId,
79};
80
81/// Top-level optimizer entry. Applies the R-rule catalog to a
82/// fixed point and returns the optimized AST.
83///
84/// Per spec §10.6 the function is total — it never rejects.
85/// Validation (V1–V9) must run before this; the optimizer
86/// assumes its input is well-formed.
87///
88/// The optimizer is a thin loop over the reducibility analyzer
89/// (§10.10): ask `analyze_reducibility` for a finding; apply
90/// its witness if non-empty; repeat. The empty finding ends
91/// the loop.
92pub fn optimize(ast: Comprehension) -> Comprehension {
93    let mut current = ast;
94    let mut steps_remaining = max_steps(&current);
95    while steps_remaining > 0 {
96        match analyze_reducibility(&current) {
97            ReducibilityFinding {
98                reduction: Some(Reduction::Rewrite { witness, .. }),
99                ..
100            } => {
101                current = witness;
102            }
103            ReducibilityFinding {
104                reduction: Some(Reduction::Replace { with }),
105                ..
106            } => {
107                current = with;
108            }
109            _ => break,
110        }
111        steps_remaining -= 1;
112    }
113    current
114}
115
116/// Reducibility analyzer entry — spec §10.10.
117///
118/// Walks the AST bottom-up trying each R-rule in priority
119/// order. Returns the first non-empty finding; returns
120/// the empty finding when no rule fires.
121pub fn analyze_reducibility(ast: &Comprehension) -> ReducibilityFinding {
122    // Bottom-up: try to rewrite each child first.
123    // Rewriting a child returns a new parent that wraps the
124    // rewritten child; subsequent rule attempts then see the
125    // updated subtree on the next outer-loop iteration.
126    if let Some(finding) = try_rewrite_child_first(ast) {
127        return finding;
128    }
129    // No rewrite in a child — try rules at this node.
130    try_rules_at_node(ast)
131}
132
133/// Attempt to rewrite a child; return a finding that wraps
134/// the rewritten subtree in this node's variant.
135fn try_rewrite_child_first(ast: &Comprehension) -> Option<ReducibilityFinding> {
136    let children: Vec<Comprehension> = ast.children().cloned().collect();
137    for (i, child) in children.iter().enumerate() {
138        let child_finding = analyze_reducibility(child);
139        let rewritten = match child_finding.reduction {
140            Some(Reduction::Rewrite { witness, .. }) => witness,
141            Some(Reduction::Replace { with }) => with,
142            None => continue,
143        };
144        // Re-build this node with the rewritten child at position i.
145        let new_ast = replace_child_at(ast, i, rewritten);
146        return Some(ReducibilityFinding {
147            reduction: Some(Reduction::Rewrite {
148                rule: child_finding.rule.unwrap_or(RuleId::R0a),
149                witness: new_ast,
150            }),
151            rule: child_finding.rule,
152            improvement: child_finding.improvement,
153        });
154    }
155    None
156}
157
158/// Try every R-rule at this node in priority order.
159/// First fire wins.
160fn try_rules_at_node(ast: &Comprehension) -> ReducibilityFinding {
161    // R0a — identity elimination
162    if let Some(witness) = r0a_identity::apply(ast) {
163        return ReducibilityFinding {
164            reduction: Some(Reduction::Rewrite {
165                rule: RuleId::R0a,
166                witness,
167            }),
168            rule: Some(RuleId::R0a),
169            improvement: ComplexityDelta::less_compute(),
170        };
171    }
172    // R0b — associativity flattening
173    if let Some(witness) = r0b_flatten::apply(ast) {
174        return ReducibilityFinding {
175            reduction: Some(Reduction::Rewrite {
176                rule: RuleId::R0b,
177                witness,
178            }),
179            rule: Some(RuleId::R0b),
180            improvement: ComplexityDelta::less_compute(),
181        };
182    }
183    // R3 — Lex/filter commute
184    if let Some(witness) = r3_commute::apply(ast) {
185        return ReducibilityFinding {
186            reduction: Some(Reduction::Rewrite {
187                rule: RuleId::R3,
188                witness,
189            }),
190            rule: Some(RuleId::R3),
191            improvement: ComplexityDelta::less_memory(),
192        };
193    }
194    // R4 — filter distributes over union
195    if let Some(witness) = r4_distribute::apply(ast) {
196        return ReducibilityFinding {
197            reduction: Some(Reduction::Rewrite {
198                rule: RuleId::R4,
199                witness,
200            }),
201            rule: Some(RuleId::R4),
202            improvement: ComplexityDelta::less_memory(),
203        };
204    }
205    // R5 — per-axis filter pushdown
206    if let Some(witness) = r5_factorize::apply(ast, &|p, c| super::predicate::analyze(p, c)) {
207        return ReducibilityFinding {
208            reduction: Some(Reduction::Rewrite {
209                rule: RuleId::R5,
210                witness,
211            }),
212            rule: Some(RuleId::R5),
213            improvement: ComplexityDelta::less_both(),
214        };
215    }
216    // R6 — chained filter folding
217    if let Some(witness) = r6_filter_fold::apply(ast) {
218        return ReducibilityFinding {
219            reduction: Some(Reduction::Rewrite {
220                rule: RuleId::R6,
221                witness,
222            }),
223            rule: Some(RuleId::R6),
224            improvement: ComplexityDelta::less_compute(),
225        };
226    }
227    // R7 — order chain folding
228    if let Some(witness) = r7_order_fold::apply(ast) {
229        return ReducibilityFinding {
230            reduction: Some(Reduction::Rewrite {
231                rule: RuleId::R7,
232                witness,
233            }),
234            rule: Some(RuleId::R7),
235            improvement: ComplexityDelta::less_both(),
236        };
237    }
238    // No rule fires.
239    ReducibilityFinding {
240        reduction: None,
241        rule: None,
242        improvement: ComplexityDelta::equal(),
243    }
244}
245
246/// Replace the i-th child of `ast` with `replacement`. Used by
247/// the bottom-up walker to plumb child rewrites back into the
248/// parent node.
249fn replace_child_at(ast: &Comprehension, i: usize, replacement: Comprehension) -> Comprehension {
250    match ast {
251        Comprehension::Clause { .. } => unreachable!("clause has no children"),
252        Comprehension::Cartesian { children } => {
253            let mut new_children = children.clone();
254            new_children[i] = replacement;
255            Comprehension::Cartesian {
256                children: new_children,
257            }
258        }
259        Comprehension::Zip { children, mode } => {
260            let mut new_children = children.clone();
261            new_children[i] = replacement;
262            Comprehension::Zip {
263                children: new_children,
264                mode: *mode,
265            }
266        }
267        Comprehension::Union { children } => {
268            let mut new_children = children.clone();
269            new_children[i] = replacement;
270            Comprehension::Union {
271                children: new_children,
272            }
273        }
274        Comprehension::Filter { predicate, .. } => Comprehension::Filter {
275            child: Box::new(replacement),
276            predicate: predicate.clone(),
277        },
278        Comprehension::Order {
279            strategy,
280            truncation,
281            ..
282        } => Comprehension::Order {
283            child: Box::new(replacement),
284            strategy: *strategy,
285            truncation: *truncation,
286        },
287    }
288}
289
290/// Bound on optimizer iterations. Per spec §10.6.3 the
291/// optimizer halts because each rewrite strictly decreases a
292/// well-founded measure. We bound iterations defensively as
293/// `node_count^2` to guard against any bug in a rule that
294/// would otherwise loop.
295fn max_steps(ast: &Comprehension) -> usize {
296    let n = ast.node_count();
297    n.saturating_mul(n).saturating_add(16)
298}
299
300/// Convenience: build a `CoordSet` from a comprehension's
301/// coordinate names and its computed metadata. R5 uses this
302/// when invoking the predicate analyzer.
303pub fn coord_set_for(ast: &Comprehension) -> CoordSet {
304    let names = ast.coordinate_names();
305    let metadata = ast.metadata();
306    coord_set_from(&names, &metadata)
307}
308
309fn coord_set_from(names: &[String], metadata: &Metadata) -> CoordSet {
310    CoordSet::from_metadata(names, metadata)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::iteration::comprehension::source::{LiteralValue, Source};
317    use crate::iteration::comprehension::strategy::StrategyName;
318
319    fn clause(name: &str, vs: &[i64]) -> Comprehension {
320        Comprehension::clause(
321            name,
322            Source::Literal {
323                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
324            },
325        )
326    }
327
328    #[test]
329    fn optimize_well_formed_ast_does_not_panic() {
330        let ast = Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
331        let _ = optimize(ast);
332    }
333
334    #[test]
335    fn optimize_singleton_cartesian_eliminates() {
336        // R0a I2: singleton cartesian → its only child.
337        let ast = Comprehension::cartesian(vec![clause("k", &[1, 2, 3])]);
338        let optimized = optimize(ast);
339        assert!(matches!(optimized, Comprehension::Clause { .. }));
340    }
341
342    #[test]
343    fn optimize_lex_none_eliminates() {
344        // R0a I5: order(c, Lex, None) → c.
345        let inner = clause("k", &[1, 2, 3]);
346        let ast = Comprehension::order(inner.clone(), StrategyName::Lex, None);
347        let optimized = optimize(ast);
348        assert_eq!(optimized, inner);
349    }
350
351    #[test]
352    fn optimize_is_idempotent() {
353        let ast = Comprehension::cartesian(vec![
354            Comprehension::cartesian(vec![clause("a", &[1])]),
355            clause("b", &[2]),
356        ]);
357        let once = optimize(ast.clone());
358        let twice = optimize(once.clone());
359        assert_eq!(once, twice);
360    }
361}