Skip to main content

polydat_core/iteration/comprehension/surfaces/
compiled.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `CompiledComprehension` — the entry point for the three
5//! consumption surfaces.
6//!
7//! Holds an `Arc<Program>` (immutable IR per comprehension_forms.md
8//! §9.1). Each factory method on this handle (`coordinate_stream`,
9//! `scoped_kernel_stream`, `scope_once`) returns a fresh
10//! streamer with its own dispense state but shares the
11//! `Arc<Program>`, so siblings never recompile (§9.5.2's
12//! independence contract).
13
14use std::sync::Arc;
15
16use crate::iteration::comprehension::ast::Comprehension;
17use crate::iteration::comprehension::eval_source::{EvalClass, SourceEval};
18use crate::iteration::comprehension::flatten::flatten_static_sources;
19use crate::iteration::comprehension::ir::{Program, compile as compile_to_ir};
20use crate::iteration::comprehension::optimize::optimize;
21use crate::iteration::comprehension::predicate::recognizers::extract_coord_refs;
22use crate::iteration::comprehension::validate::{
23    Mode, Surface, ValidationError, ValidationReport, ValidationWarning, unresolved_names, validate,
24};
25
26use crate::kernel::interp::NoScope;
27
28use super::coord_stream::CoordinateStream;
29use super::instance::{KernelScope, ScopedKernelInstance};
30use super::scope_once::scope_once_with;
31use super::scoped_stream::ScopedKernelStream;
32
33/// A comprehension that has been compiled to immutable IR
34/// and is ready to dispense. The single source of truth for
35/// the underlying program across the consumption surfaces.
36///
37/// Construction is via [`from_ast`](Self::from_ast) (compiles once) or
38/// [`from_program`](Self::from_program) (when the IR was compiled elsewhere).
39/// Cloning a `CompiledComprehension` is cheap — just an
40/// `Arc::clone` on the program.
41#[derive(Debug, Clone)]
42pub struct CompiledComprehension {
43    program: Arc<Program>,
44}
45
46impl CompiledComprehension {
47    /// Compile an AST: validation (§5, V1–V9) first, then the §10
48    /// optimizer, then the AST → IR pass, once. Validation and
49    /// optimization are stages of this compile
50    /// (comprehension_forms.md §5, §9.4, §10.6): a tree that
51    /// violates a V-axiom is refused here, and the §9.3 resource
52    /// bounds hold for the program this returns.
53    pub fn from_ast(ast: &Comprehension) -> Result<Self, ValidationError> {
54        Self::from_ast_with(ast, Mode::Permissive).map(|(compiled, _)| compiled)
55    }
56
57    /// [`from_ast`](Self::from_ast) under a validation mode
58    /// (comprehension_forms.md §5.8), with the validator's report: in
59    /// `Permissive` mode a degenerate composition is a warning in the
60    /// report and the comprehension compiles; in `Strict` mode it is
61    /// the error. Context-free sources are flattened first
62    /// (§10.7.0): a generator that references no name is evaluated
63    /// here, in the empty scope, and validated as the literal of its
64    /// values.
65    ///
66    /// The comprehension is compiled with no enclosing scope, so a name
67    /// it reads and does not bind is resolved nowhere (V3): in
68    /// `Permissive` mode it is a `ValidationWarning::UnresolvedNames` in
69    /// the report and reads None, and in `Strict` mode it is
70    /// `ValidationError::V3UnresolvedNames`. [`from_ast_in`](Self::from_ast_in)
71    /// compiles one bound in a scope.
72    pub fn from_ast_with(
73        ast: &Comprehension,
74        mode: Mode,
75    ) -> Result<(Self, ValidationReport), ValidationError> {
76        Self::from_ast_in(ast, mode, &|_| false)
77    }
78
79    /// [`from_ast_with`](Self::from_ast_with) for a comprehension bound in
80    /// an enclosing scope, which has the names `in_scope` answers true
81    /// for, as a producer wire's comprehension is (comprehension_forms.md
82    /// §5 V3, §9.5.2).
83    ///
84    /// These surfaces supply no name. A name the enclosing scope does not
85    /// have either is resolved nowhere (V3): outside `Strict` mode it is
86    /// a `ValidationWarning::UnresolvedNames` in the report and reads
87    /// None, so a source reading one yields nothing and a predicate that
88    /// reads one for a tuple keeps it not; in `Strict` mode it is
89    /// `ValidationError::V3UnresolvedNames`. A name only that scope has
90    /// is one a `for` traversal captures when it opens and a stream cannot
91    /// see: a source reading one is `ValidationError::ContextRequired`, as
92    /// is a source reading an earlier axis, which only a traversal
93    /// evaluates, and a predicate reading one is
94    /// `ValidationError::PredicateContextRequired`.
95    pub fn from_ast_in(
96        ast: &Comprehension,
97        mode: Mode,
98        in_scope: &dyn Fn(&str) -> bool,
99    ) -> Result<(Self, ValidationReport), ValidationError> {
100        let ast = flatten_static_sources(ast, &NoScope::new());
101        // Resolved nowhere: neither bound nor a name a traversal of it in
102        // the enclosing scope would capture.
103        let unresolved = unresolved_names(&ast, Surface::Traversal(in_scope));
104        if mode == Mode::Strict && !unresolved.is_empty() {
105            return Err(ValidationError::V3UnresolvedNames { reads: unresolved });
106        }
107        if let Some((name, references)) = first_context_required(&ast, in_scope) {
108            return Err(ValidationError::ContextRequired { name, references });
109        }
110        if let Some((predicate, references)) = first_unbound_predicate(&ast, in_scope) {
111            return Err(ValidationError::PredicateContextRequired {
112                predicate,
113                references,
114            });
115        }
116        if let Some((name, message)) = first_failed_static(&ast) {
117            return Err(ValidationError::SourceFailed { name, message });
118        }
119        let mut report = validate(&ast, mode)?;
120        if !unresolved.is_empty() {
121            report
122                .warnings
123                .insert(0, ValidationWarning::UnresolvedNames { reads: unresolved });
124        }
125        Ok((
126            Self {
127                program: Arc::new(compile_to_ir(&optimize(ast))),
128            },
129            report,
130        ))
131    }
132
133    /// Wrap an already-compiled program (tests use this for
134    /// hand-built IR).
135    pub fn from_program(program: Arc<Program>) -> Self {
136        Self { program }
137    }
138
139    /// Access the underlying compiled program (immutable per
140    /// comprehension_forms.md §9.1).
141    pub fn program(&self) -> &Program {
142        &self.program
143    }
144
145    /// Clone the `Arc<Program>` for sharing with other
146    /// handles. Used internally by the streamer factories.
147    pub(crate) fn program_arc(&self) -> Arc<Program> {
148        Arc::clone(&self.program)
149    }
150
151    /// **First-order surface** (comprehension_forms.md §9.5).
152    ///
153    /// Return a fresh [`CoordinateStream`]. Each call
154    /// allocates new per-streamer state; siblings share the
155    /// underlying IR but dispense independently per §9.5.2's
156    /// independence contract.
157    pub fn coordinate_stream(&self) -> CoordinateStream {
158        CoordinateStream::new(self.program_arc())
159    }
160
161    /// **Second-order surface** (comprehension_forms.md §9.5).
162    ///
163    /// Return a fresh [`ScopedKernelStream`] wrapping the
164    /// supplied parent kernel. Each `advance()` pulls one
165    /// coord tuple from the underlying IR and applies
166    /// `parent.scope(&coords)` to produce a
167    /// [`ScopedKernelInstance`].
168    ///
169    /// Independence: pulling from this stream does NOT
170    /// advance any [`CoordinateStream`] obtained from the
171    /// same `CompiledComprehension`.
172    pub fn scoped_kernel_stream<K: KernelScope>(&self, parent: K) -> ScopedKernelStream<K> {
173        ScopedKernelStream::new(self.program_arc(), parent)
174    }
175
176    /// **One-shot surface** (comprehension_forms.md §9.5.3).
177    ///
178    /// Apply `parent.scope(coords)` directly, without
179    /// constructing any streamer. Pure function — no
180    /// cursor consulted, no dispense state advanced. Used
181    /// for replay, debugging, and point queries where a
182    /// specific coord tuple is already known.
183    pub fn scope_once<K: KernelScope>(
184        &self,
185        parent: &K,
186        coords: &crate::iteration::comprehension::strategies::Tuple,
187    ) -> ScopedKernelInstance<K::Scoped> {
188        scope_once_with(parent, coords)
189    }
190}
191
192/// The first clause of `ast` whose source needs a scope
193/// (comprehension_forms.md §10.7.0), with the names it reads
194/// ([`Source::names_read`](crate::iteration::comprehension::source::Source::names_read)):
195/// the scope-less surfaces refuse such a comprehension by name. A source
196/// that reads a name resolved nowhere, bound neither by an earlier axis
197/// nor in the enclosing scope (`in_scope`), reads None and yields
198/// nothing on every surface, so it needs no scope.
199fn first_context_required(
200    ast: &Comprehension,
201    in_scope: &dyn Fn(&str) -> bool,
202) -> Option<(String, Vec<String>)> {
203    fn walk(
204        c: &Comprehension,
205        in_scope: &dyn Fn(&str) -> bool,
206        before: &mut Vec<String>,
207    ) -> Option<(String, Vec<String>)> {
208        match c {
209            Comprehension::Clause { name, source } => {
210                let references = source.names_read();
211                let reads_none = references
212                    .iter()
213                    .any(|n| !before.contains(n) && !in_scope(n));
214                (source.eval_class() == EvalClass::ContextRequired && !reads_none)
215                    .then(|| (name.clone(), references.into_iter().collect()))
216            }
217            Comprehension::Cartesian { children } => {
218                let depth = before.len();
219                let mut found = None;
220                for child in children {
221                    found = walk(child, in_scope, before);
222                    if found.is_some() {
223                        break;
224                    }
225                    before.extend(child.coordinate_names());
226                }
227                before.truncate(depth);
228                found
229            }
230            Comprehension::Zip { children, .. } | Comprehension::Union { children } => children
231                .iter()
232                .find_map(|child| walk(child, in_scope, before)),
233            Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
234                walk(child, in_scope, before)
235            }
236        }
237    }
238    walk(ast, in_scope, &mut Vec::new())
239}
240
241/// The first filter of `ast` whose predicate names what its tuples do
242/// not bind and the enclosing scope has (`in_scope`), with those names:
243/// the scope-less surfaces evaluate a predicate in the empty scope, so
244/// they refuse it by name. A name the scope does not have either reads
245/// None on every surface.
246fn first_unbound_predicate(
247    ast: &Comprehension,
248    in_scope: &dyn Fn(&str) -> bool,
249) -> Option<(String, Vec<String>)> {
250    match ast {
251        Comprehension::Clause { .. } => None,
252        Comprehension::Cartesian { children }
253        | Comprehension::Zip { children, .. }
254        | Comprehension::Union { children } => children
255            .iter()
256            .find_map(|child| first_unbound_predicate(child, in_scope)),
257        Comprehension::Filter { child, predicate } => {
258            let bound = child.coordinate_names();
259            let unbound: Vec<String> = extract_coord_refs(predicate)
260                .into_iter()
261                .filter(|name| !bound.contains(name) && in_scope(name))
262                .collect();
263            if unbound.is_empty() {
264                first_unbound_predicate(child, in_scope)
265            } else {
266                Some((predicate.clone(), unbound))
267            }
268        }
269        Comprehension::Order { child, .. } => first_unbound_predicate(child, in_scope),
270    }
271}
272
273/// The first context-free generator the flatten could not evaluate,
274/// with the evaluator's message. After [`flatten_static_sources`] in
275/// the empty scope, a context-free generator that is still a call with
276/// no cardinality hint is one whose evaluation failed: an empty result
277/// carries a hint of 0 and a non-literal result its count. Nothing
278/// later evaluates it on the scope-less surfaces, so it is evaluated
279/// once more here for its message, and refused.
280fn first_failed_static(ast: &Comprehension) -> Option<(String, String)> {
281    use crate::iteration::comprehension::eval_source::EvalContext;
282    use crate::iteration::comprehension::source::Source;
283    match ast {
284        Comprehension::Clause { name, source } => match source {
285            Source::Generator {
286                cardinality_hint: None,
287                ..
288            } if source.eval_class() == EvalClass::Static => {
289                let scope = NoScope::new();
290                let ctx = EvalContext {
291                    var_name: name,
292                    scope: &scope,
293                    prefix: &[],
294                };
295                source
296                    .evaluate(Some(&ctx))
297                    .err()
298                    .map(|e| (name.clone(), e.to_string()))
299            }
300            _ => None,
301        },
302        Comprehension::Cartesian { children }
303        | Comprehension::Zip { children, .. }
304        | Comprehension::Union { children } => children.iter().find_map(first_failed_static),
305        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
306            first_failed_static(child)
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::iteration::comprehension::source::{LiteralValue, Source};
315
316    fn clause(name: &str, vs: &[i64]) -> Comprehension {
317        Comprehension::clause(
318            name,
319            Source::Literal {
320                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
321            },
322        )
323    }
324
325    #[test]
326    fn from_ast_compiles_once() {
327        let ast = clause("k", &[1, 2, 3]);
328        let compiled = CompiledComprehension::from_ast(&ast).unwrap();
329        assert!(!compiled.program().is_empty());
330    }
331
332    /// Optimization is mandatory before IR compilation
333    /// (comprehension_forms.md §9.4, §10.6): `from_ast` compiles the
334    /// optimized tree, so a program it returns is the program of the
335    /// optimized AST, and where a rule fires it is not the program of
336    /// the raw one.
337    #[test]
338    fn from_ast_compiles_the_optimized_tree() {
339        // A nested cartesian: R0b (A2) flattens it, so the raw and
340        // optimized trees compile to different programs.
341        let inner = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[3])]);
342        let ast = Comprehension::cartesian(vec![inner, clause("c", &[4])]);
343        let compiled = CompiledComprehension::from_ast(&ast).unwrap();
344        assert_eq!(*compiled.program(), compile_to_ir(&optimize(ast.clone())));
345        assert_ne!(*compiled.program(), compile_to_ir(&ast));
346    }
347
348    /// Validation is a stage of the compile (comprehension_forms.md
349    /// §5): a tree that violates a V-axiom is refused by `from_ast`
350    /// with the axiom's error, not compiled.
351    #[test]
352    fn from_ast_refuses_a_tree_that_violates_a_v_axiom() {
353        // V1: a cartesian whose children bind the same name.
354        let ast = Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("k", &[3, 4])]);
355        let err = CompiledComprehension::from_ast(&ast).unwrap_err();
356        assert!(
357            matches!(err, ValidationError::V1DuplicateName { ref name, .. } if name == "k"),
358            "{err}"
359        );
360        assert!(err.to_string().starts_with("V1:"), "{err}");
361    }
362
363    /// Validation modes (comprehension_forms.md §5.8): a degenerate
364    /// composition is a warning in the permissive report and the error
365    /// of a strict compile.
366    #[test]
367    fn from_ast_with_reports_or_refuses_a_degenerate_composition() {
368        use crate::iteration::comprehension::strategy::StrategyName;
369        use crate::iteration::comprehension::validate::ValidationWarning;
370        let ast = Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(1));
371        let (_, report) = CompiledComprehension::from_ast_with(&ast, Mode::Permissive).unwrap();
372        assert!(matches!(
373            report.warnings.as_slice(),
374            [ValidationWarning::DegenerateGeometric { .. }]
375        ));
376        let err = CompiledComprehension::from_ast_with(&ast, Mode::Strict).unwrap_err();
377        assert!(matches!(err, ValidationError::StrictWarning(_)), "{err}");
378        assert!(err.to_string().starts_with("strict mode:"), "{err}");
379    }
380
381    #[test]
382    fn cloning_compiled_shares_arc() {
383        let ast = clause("k", &[1, 2, 3]);
384        let a = CompiledComprehension::from_ast(&ast).unwrap();
385        let b = a.clone();
386        // Same Arc — strong_count goes up.
387        let count = Arc::strong_count(&a.program);
388        assert!(count >= 2, "expected shared Arc, count = {count}");
389        drop(b);
390    }
391
392    #[test]
393    fn two_coordinate_streams_share_program() {
394        let ast = clause("k", &[1, 2, 3]);
395        let compiled = CompiledComprehension::from_ast(&ast).unwrap();
396        let _s1 = compiled.coordinate_stream();
397        let _s2 = compiled.coordinate_stream();
398        // Both streams hold an Arc; count is at least 3 (compiled +
399        // two streamers, possibly more if internal clones happen).
400        let count = Arc::strong_count(&compiled.program);
401        assert!(
402            count >= 3,
403            "expected shared program across streamers, count = {count}"
404        );
405    }
406
407    /// A context-required source has no coordinate stream
408    /// (comprehension_forms.md §9.5.2, §10.7.0): compiled in a scope
409    /// that has the names it reads, the compile refuses it by name, with
410    /// the names it needs, instead of dispensing nothing. With no scope
411    /// those names resolve nowhere (V3): a permissive compile warns and
412    /// the source, reading None, yields nothing; a strict one refuses it.
413    #[test]
414    fn from_ast_refuses_a_context_required_source_by_name() {
415        let ast = Comprehension::cartesian(vec![
416            clause("k", &[1, 2]),
417            Comprehension::clause(
418                "j",
419                Source::Generator {
420                    expr: "pow2({n})".into(),
421                    cardinality_hint: None,
422                },
423            ),
424        ]);
425        let err =
426            CompiledComprehension::from_ast_in(&ast, Mode::Permissive, &|n| n == "n").unwrap_err();
427        assert!(
428            matches!(
429                err,
430                ValidationError::ContextRequired { ref name, ref references }
431                    if name == "j" && references == &["n".to_string()]
432            ),
433            "{err}"
434        );
435        assert!(err.to_string().contains("traverse it with `for`"), "{err}");
436        let (compiled, report) = CompiledComprehension::from_ast_with(&ast, Mode::Permissive)
437            .unwrap_or_else(|e| panic!("{e}"));
438        assert!(
439            matches!(report.warnings.as_slice(),
440                [ValidationWarning::UnresolvedNames { reads }] if reads.len() == 1),
441            "{:?}",
442            report.warnings
443        );
444        assert_eq!(compiled.coordinate_stream().count(), 0);
445        let err = CompiledComprehension::from_ast_with(&ast, Mode::Strict).unwrap_err();
446        assert!(
447            matches!(err, ValidationError::V3UnresolvedNames { ref reads } if reads.len() == 1),
448            "{err}"
449        );
450    }
451
452    /// A predicate naming what its tuples do not bind has no scope to
453    /// resolve in on a coordinate stream: compiled in a scope that has
454    /// the name, the compile refuses it by name. With no scope the name
455    /// resolves nowhere (V3): a permissive compile warns and the name
456    /// reads None, so only a tuple the predicate decides before reading
457    /// it is kept; a strict one refuses it.
458    #[test]
459    fn from_ast_refuses_a_predicate_that_needs_a_scope() {
460        let ast = Comprehension::filter(clause("k", &[1, 2, 3]), "{k} == 1 || {k} > {limit}");
461        let err = CompiledComprehension::from_ast_in(&ast, Mode::Permissive, &|n| n == "limit")
462            .unwrap_err();
463        assert!(
464            matches!(
465                err,
466                ValidationError::PredicateContextRequired { ref references, .. }
467                    if references == &["limit".to_string()]
468            ),
469            "{err}"
470        );
471        assert!(err.to_string().contains("traverse it with `for`"), "{err}");
472        let compiled = CompiledComprehension::from_ast(&ast).unwrap_or_else(|e| panic!("{e}"));
473        let kept: Vec<_> = compiled
474            .coordinate_stream()
475            .collect::<Result<_, _>>()
476            .unwrap();
477        assert_eq!(kept.len(), 1);
478        assert_eq!(
479            kept[0].bindings[0].1,
480            crate::iteration::comprehension::strategies::TupleValue::I64(1)
481        );
482        let err = CompiledComprehension::from_ast_with(&ast, Mode::Strict).unwrap_err();
483        assert!(
484            matches!(err, ValidationError::V3UnresolvedNames { .. }),
485            "{err}"
486        );
487        let bound = Comprehension::filter(clause("k", &[1, 2, 3]), "{k} > 1");
488        assert!(CompiledComprehension::from_ast(&bound).is_ok());
489    }
490}