Skip to main content

polydat_core/iteration/comprehension/
runtime.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Runtime evaluator — walks an algebra [`Comprehension`] AST
5//! against a live parent kernel context to produce typed
6//! coordinate tuples.
7//!
8//! ## Why this is separate from the static IR interpreter
9//!
10//! The static IR interpreter (`super::ir::interpreter`) walks
11//! a compiled stack-machine program over fully-statically-
12//! resolvable `Source` variants (`IntRange`, `Literal`). It
13//! has no notion of a runtime parent kernel, which is correct
14//! for the spec §9.5 consumption surfaces it serves.
15//!
16//! Runtime comprehension evaluation is fundamentally different:
17//!
18//! - **Source-text evaluation requires the parent kernel.**
19//!   `Source::Generator { expr: "pre_{outer}" }` and
20//!   `Source::WorkloadParamList { name }` resolve against the
21//!   parent kernel's chain — `{outer}` substitution via
22//!   [`interpolate_via_kernel`], `kernel.lookup(name)` for
23//!   workload params.
24//! - **Cartesian is dependent-tuple, not independent.** Clause
25//!   N's spec text may reference iter-vars from clauses
26//!   1..N-1. Per-branch kernels (via
27//!   `PolydatKernel::materialize_subscope`) carry the prior
28//!   values forward so each clause evaluates against the
29//!   correct context. This is SRD-18b §"Dependent Tuple
30//!   Iteration".
31//! - **Filter predicates evaluate against per-tuple kernels.**
32//!   The predicate text is interpolated against a kernel with
33//!   every tuple value installed, then run through
34//!   `eval_const_expr`.
35//!
36//! All three depend on polydat-side primitives that exist
37//! today; this evaluator is the algebra-typed entry point for
38//! them.
39//!
40//! ## What this owns
41//!
42//! [`evaluate_for_iteration`] is the public surface:
43//! `(algebra AST + parent kernel + canonical kernel +
44//! workload params) → Vec<RuntimeTuple>`. The returned tuples
45//! carry polydat [`Value`]s ready for per-iteration kernel
46//! construction via [`PolydatKernel::for_iteration`](crate::kernel::PolydatKernel::for_iteration).
47//!
48//! Order modifiers route through the unified
49//! `Strategy::apply` (spec §10.7.8): each node returns its
50//! tuples paired with the [`IndexFn`] the materialized stream
51//! satisfies; the Order node assembles an [`EvaluatedInput`]
52//! and invokes the strategy. V4 fires at this site,
53//! definitively.
54//!
55//! ## What this does NOT own
56//!
57//! - Per-iteration kernel construction. The evaluator returns
58//!   tuples; the caller (executor or stream surface) builds
59//!   the per-iter kernel via `PolydatKernel::for_iteration`.
60//! - Empty-clause policy (strict / warn). The caller passes
61//!   an `on_empty` callback the same way `enumerate_tuples`
62//!   does today.
63
64use std::collections::HashMap;
65#[cfg(test)]
66use std::sync::Arc;
67
68use crate::ast::Value;
69use crate::dsl::compile::eval_const_expr;
70use crate::iteration::comprehension::ast::Comprehension;
71use crate::iteration::comprehension::eval_source::{EvalContext, SourceEval};
72use crate::iteration::comprehension::metadata::IndexFn;
73use crate::iteration::comprehension::source::Source;
74use crate::iteration::comprehension::strategies::{EvaluatedInput, Tuple, TupleValue};
75use crate::iteration::comprehension::strategy::StrategyName;
76#[cfg(test)]
77use crate::kernel::PolydatKernel;
78use crate::kernel::interp::{Layered, Lookup, interpolate_via_kernel};
79
80/// Runtime tuple type — polydat-Value-based to preserve Ext
81/// typing (Partition / Json / etc.) through the iteration
82/// pipeline. The algebra layer's [`Tuple`] uses
83/// [`TupleValue`] which is scalar-only; this `RuntimeTuple`
84/// is what the executor actually wants for per-iteration
85/// kernel binding via [`PolydatKernel::for_iteration`](crate::kernel::PolydatKernel::for_iteration).
86pub type RuntimeTuple = Vec<(String, Value)>;
87
88/// Per-node result of the runtime walker.
89///
90/// `tuples` is the materialized stream in source order
91/// (matches the runtime walker's natural enumeration — head
92/// axis varies slowest in cartesian, sequential in union,
93/// lockstep in zip). `index_fn` is the addressing scheme the
94/// stream satisfies; `None` when the stream is non-addressable
95/// (filter output, dependent cartesian over context-required
96/// sources whose actual shapes don't combine cleanly).
97struct EvaluatedNode {
98    tuples: Vec<RuntimeTuple>,
99    index_fn: Option<IndexFn>,
100}
101
102/// Reason a clause produced no values, for the caller's
103/// empty-clause policy callback.
104#[derive(Debug)]
105pub struct EmptyClause<'a> {
106    /// The clause's element name.
107    pub var: &'a str,
108    /// The clause's source text, if any.
109    pub spec_expr: Option<&'a str>,
110}
111
112/// Errors the runtime evaluator surfaces.
113#[derive(Debug, Clone)]
114pub enum RuntimeError {
115    /// Source evaluation failed (interpolation error,
116    /// eval_const_expr error, unsupported source shape, etc.).
117    SourceEval {
118        /// The clause's element name.
119        var: String,
120        /// The source text.
121        source: String,
122        /// The underlying reason.
123        message: String,
124    },
125    /// Filter predicate evaluation failed.
126    FilterEval {
127        /// The predicate text.
128        predicate: String,
129        /// The underlying reason.
130        message: String,
131    },
132    /// Strategy application failed.
133    OrderEval {
134        /// The strategy applied.
135        strategy: StrategyName,
136        /// The underlying reason.
137        message: String,
138    },
139    /// V4 (spec §5) violation — strategy rejects the input's
140    /// addressing shape at invocation time (spec §10.7.8).
141    StrategyRejectsInput {
142        /// The strategy applied.
143        strategy: StrategyName,
144        /// The input's addressing scheme, if one was claimed.
145        index_fn: Option<IndexFn>,
146    },
147    /// The runtime evaluator encountered an algebra-AST shape
148    /// it doesn't support (e.g., nested Filter under Order).
149    UnsupportedShape(String),
150    /// Caller's `on_empty` callback returned an error.
151    EmptyPolicy(String),
152}
153
154impl std::fmt::Display for RuntimeError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            RuntimeError::SourceEval {
158                var,
159                source,
160                message,
161            } => {
162                write!(f, "for_each clause '{var} in {source}': {message}")
163            }
164            RuntimeError::FilterEval { predicate, message } => {
165                write!(f, "comprehension filter '{predicate}': {message}")
166            }
167            RuntimeError::OrderEval { strategy, message } => {
168                write!(f, "order strategy {strategy:?}: {message}")
169            }
170            RuntimeError::StrategyRejectsInput { strategy, index_fn } => write!(
171                f,
172                "order strategy {strategy:?} rejects input shape {index_fn:?} \
173                 (V4: per-strategy IndexFn contract; see spec §3.6's strategy table)"
174            ),
175            RuntimeError::UnsupportedShape(msg) => write!(f, "{msg}"),
176            RuntimeError::EmptyPolicy(msg) => write!(f, "{msg}"),
177        }
178    }
179}
180
181impl std::error::Error for RuntimeError {}
182
183/// Evaluate a comprehension against the parent kernel and
184/// produce the typed coordinate-tuple list.
185///
186/// `canonical` is the comprehension scope's kernel program
187/// (used for per-branch `materialize_subscope` calls).
188/// `parent` is the runtime parent — the kernel chain root for
189/// all source evaluation and shadow resolution.
190/// `workload_params` provides the fallback for
191/// `Source::WorkloadParamList` names not yet promoted into
192/// the kernel chain.
193///
194/// `on_empty` is called with each empty clause (zero values
195/// after evaluation). Caller decides whether to abort (strict)
196/// or warn-and-skip (relaxed) — same shape as
197/// `enumerate_tuples`'s callback.
198pub fn evaluate_for_iteration<F>(
199    comp: &Comprehension,
200    scope: &dyn Lookup,
201    workload_params: &HashMap<String, String>,
202    on_empty: F,
203) -> Result<Vec<RuntimeTuple>, RuntimeError>
204where
205    F: FnMut(EmptyClause<'_>) -> Result<(), String>,
206{
207    let mut state = EvalState {
208        scope,
209        workload_params,
210        on_empty,
211    };
212    state.evaluate_node(comp, &[]).map(|n| n.tuples)
213}
214
215/// Evaluate a predicate in the comprehension grammar against a tuple
216/// without a kernel. Returns `None` when the predicate uses anything
217/// outside that grammar, or references a name the tuple does not bind,
218/// so the caller can fall back to kernel interpolation.
219fn fast_predicate(predicate: &str, tuple: &RuntimeTuple) -> Option<bool> {
220    let p = predicate.trim();
221    if p.eq_ignore_ascii_case("true") {
222        return Some(true);
223    }
224    if p.eq_ignore_ascii_case("false") {
225        return Some(false);
226    }
227    if let Some(inner) = p.strip_prefix('!') {
228        return fast_predicate(inner, tuple).map(|b| !b);
229    }
230    if let Some(parts) = split_top(p, "||") {
231        let mut any = false;
232        for part in parts {
233            any |= fast_predicate(&part, tuple)?;
234        }
235        return Some(any);
236    }
237    if let Some(parts) = split_top(p, "&&") {
238        let mut all = true;
239        for part in parts {
240            all &= fast_predicate(&part, tuple)?;
241        }
242        return Some(all);
243    }
244    if let Some(pos) = p.find(" in ") {
245        let name = curly(p[..pos].trim())?;
246        let list = p[pos + 4..].trim().strip_prefix('[')?.strip_suffix(']')?;
247        let needle = tuple_scalar(tuple, &name)?;
248        let mut hit = false;
249        for item in list.split(',') {
250            let lit = literal(item.trim())?;
251            hit |= scalar_eq(&needle, &lit);
252        }
253        return Some(hit);
254    }
255    for op in ["==", "!=", "<=", ">=", "<", ">"] {
256        if let Some((lhs, rhs)) = split_op(p, op) {
257            let lhs = lhs.trim();
258            let rhs = rhs.trim();
259            let a = operand(tuple, lhs)?;
260            let b = operand(tuple, rhs)?;
261            return Some(match op {
262                "==" => scalar_eq(&a, &b),
263                "!=" => !scalar_eq(&a, &b),
264                "<" => scalar_cmp(&a, &b)? == std::cmp::Ordering::Less,
265                ">" => scalar_cmp(&a, &b)? == std::cmp::Ordering::Greater,
266                "<=" => scalar_cmp(&a, &b)? != std::cmp::Ordering::Greater,
267                _ => scalar_cmp(&a, &b)? != std::cmp::Ordering::Less,
268            });
269        }
270    }
271    None
272}
273
274#[derive(Debug, Clone, PartialEq)]
275enum Scalar {
276    Int(i128),
277    Float(f64),
278    Str(String),
279    Bool(bool),
280}
281
282fn operand(tuple: &RuntimeTuple, text: &str) -> Option<Scalar> {
283    match curly(text) {
284        Some(name) => tuple_scalar(tuple, &name),
285        None => literal(text),
286    }
287}
288
289fn tuple_scalar(tuple: &RuntimeTuple, name: &str) -> Option<Scalar> {
290    let (_, v) = tuple.iter().find(|(n, _)| n == name)?;
291    match v {
292        Value::U64(n) => Some(Scalar::Int(*n as i128)),
293        Value::F64(f) => Some(Scalar::Float(*f)),
294        Value::Str(s) => Some(Scalar::Str(s.to_string())),
295        Value::Bool(b) => Some(Scalar::Bool(*b)),
296        // A JSON list's item compares as the scalar it carries.
297        Value::Json(j) => match j.as_ref() {
298            serde_json::Value::Number(n) if n.is_i64() => Some(Scalar::Int(n.as_i64()? as i128)),
299            serde_json::Value::Number(n) if n.is_u64() => Some(Scalar::Int(n.as_u64()? as i128)),
300            serde_json::Value::Number(n) => Some(Scalar::Float(n.as_f64()?)),
301            serde_json::Value::String(s) => Some(Scalar::Str(s.clone())),
302            serde_json::Value::Bool(b) => Some(Scalar::Bool(*b)),
303            _ => None,
304        },
305        _ => None,
306    }
307}
308
309fn literal(text: &str) -> Option<Scalar> {
310    if let Some(s) = text.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
311        return Some(Scalar::Str(s.to_string()));
312    }
313    if let Some(s) = text.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
314        return Some(Scalar::Str(s.to_string()));
315    }
316    match text {
317        "true" => return Some(Scalar::Bool(true)),
318        "false" => return Some(Scalar::Bool(false)),
319        _ => {}
320    }
321    if let Ok(i) = text.parse::<i128>() {
322        return Some(Scalar::Int(i));
323    }
324    if let Ok(f) = text.parse::<f64>() {
325        return Some(Scalar::Float(f));
326    }
327    // A bare word compares as text, matching the interpolated form
328    // `load == load` a kernel evaluation would see for string elements.
329    if !text.is_empty()
330        && text
331            .chars()
332            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
333    {
334        return Some(Scalar::Str(text.to_string()));
335    }
336    None
337}
338
339fn scalar_eq(a: &Scalar, b: &Scalar) -> bool {
340    match (a, b) {
341        (Scalar::Int(x), Scalar::Float(y)) | (Scalar::Float(y), Scalar::Int(x)) => {
342            (*x as f64) == *y
343        }
344        _ => a == b,
345    }
346}
347
348fn scalar_cmp(a: &Scalar, b: &Scalar) -> Option<std::cmp::Ordering> {
349    match (a, b) {
350        (Scalar::Int(x), Scalar::Int(y)) => Some(x.cmp(y)),
351        (Scalar::Float(x), Scalar::Float(y)) => x.partial_cmp(y),
352        (Scalar::Int(x), Scalar::Float(y)) => (*x as f64).partial_cmp(y),
353        (Scalar::Float(x), Scalar::Int(y)) => x.partial_cmp(&(*y as f64)),
354        (Scalar::Str(x), Scalar::Str(y)) => Some(x.cmp(y)),
355        _ => None,
356    }
357}
358
359fn curly(text: &str) -> Option<String> {
360    let inner = text.strip_prefix('{')?.strip_suffix('}')?;
361    (!inner.is_empty() && inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'))
362        .then(|| inner.to_string())
363}
364
365/// Split at a top-level binary token, respecting brackets and quotes.
366fn split_top(s: &str, sep: &str) -> Option<Vec<String>> {
367    let mut parts = Vec::new();
368    let mut depth = 0i32;
369    let mut quote: Option<char> = None;
370    let mut start = 0;
371    let bytes: Vec<char> = s.chars().collect();
372    let sepc: Vec<char> = sep.chars().collect();
373    let mut i = 0;
374    while i < bytes.len() {
375        let c = bytes[i];
376        if let Some(q) = quote {
377            if c == q {
378                quote = None;
379            }
380        } else {
381            match c {
382                '"' | '\'' => quote = Some(c),
383                '(' | '[' | '{' => depth += 1,
384                ')' | ']' | '}' => depth -= 1,
385                _ => {}
386            }
387            if depth == 0 && bytes[i..].starts_with(&sepc) {
388                parts.push(bytes[start..i].iter().collect::<String>());
389                i += sepc.len();
390                start = i;
391                continue;
392            }
393        }
394        i += 1;
395    }
396    if parts.is_empty() {
397        return None;
398    }
399    parts.push(bytes[start..].iter().collect::<String>());
400    Some(parts)
401}
402
403fn split_op<'a>(s: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
404    let mut depth = 0i32;
405    let mut quote: Option<char> = None;
406    let chars: Vec<(usize, char)> = s.char_indices().collect();
407    for (k, &(idx, c)) in chars.iter().enumerate() {
408        if let Some(q) = quote {
409            if c == q {
410                quote = None;
411            }
412            continue;
413        }
414        match c {
415            '"' | '\'' => {
416                quote = Some(c);
417                continue;
418            }
419            '(' | '[' | '{' => depth += 1,
420            ')' | ']' | '}' => depth -= 1,
421            _ => {}
422        }
423        if depth == 0 && s[idx..].starts_with(op) {
424            // Longest-match: do not split `<=` at `<`, or `!=`/`==` at `=`.
425            let next = chars.get(k + op.len()).map(|(_, c)| *c);
426            if (op == "<" || op == ">") && next == Some('=') {
427                continue;
428            }
429            let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
430            if (op == "<" || op == ">") && matches!(prev, Some('<') | Some('>')) {
431                continue;
432            }
433            return Some((&s[..idx], &s[idx + op.len()..]));
434        }
435    }
436    None
437}
438
439/// Internal walker state — bundles the closures and shared
440/// references so the recursive walker doesn't have to thread
441/// them through every call.
442struct EvalState<'a, F> {
443    /// Where names resolve: the body's kernel with the parent's cascaded
444    /// wires bound; a tuple's own bindings are layered in front per use.
445    scope: &'a dyn Lookup,
446    /// Workload-param fallback. The polydat-owned
447    /// `evaluate_spec` already routes through the parent
448    /// kernel chain for shadow-aware resolution (SRD-21),
449    /// so this is unused at the runtime evaluator level —
450    /// kept on the surface for symmetry with the legacy
451    /// `iterate_scope` signature in case some future
452    /// `Source` variant needs param-aware evaluation that
453    /// can't go through the kernel.
454    #[allow(dead_code)]
455    workload_params: &'a HashMap<String, String>,
456    on_empty: F,
457}
458
459impl<F> EvalState<'_, F>
460where
461    F: FnMut(EmptyClause<'_>) -> Result<(), String>,
462{
463    fn evaluate_node(
464        &mut self,
465        node: &Comprehension,
466        prefix: &[(String, Value)],
467    ) -> Result<EvaluatedNode, RuntimeError> {
468        match node {
469            Comprehension::Clause { name, source } => self.evaluate_clause(name, source, prefix),
470            Comprehension::Cartesian { children } => self.evaluate_cartesian(children, prefix),
471            Comprehension::Zip { children, mode } => self.evaluate_zip(children, *mode, prefix),
472            Comprehension::Union { children } => self.evaluate_union(children, prefix),
473            Comprehension::Filter { child, predicate } => {
474                let inner = self.evaluate_node(child, prefix)?;
475                self.apply_filter(inner, predicate)
476            }
477            Comprehension::Order {
478                child,
479                strategy,
480                truncation,
481            } => {
482                let inner = self.evaluate_node(child, prefix)?;
483                // A continuous source has no tuples of its own; an order
484                // strategy with a truncation samples that many points
485                // from its intervals (spec §10.7.8, continuous inputs).
486                if inner.tuples.is_empty()
487                    && let Some(intervals) = continuous_axes(child)
488                {
489                    let names = child.coordinate_names();
490                    return Self::sample_continuous(&names, &intervals, *strategy, *truncation);
491                }
492                self.apply_order(inner, *strategy, *truncation)
493            }
494        }
495    }
496
497    fn evaluate_clause(
498        &mut self,
499        name: &str,
500        source: &Source,
501        prefix: &[(String, Value)],
502    ) -> Result<EvaluatedNode, RuntimeError> {
503        let ctx = EvalContext {
504            var_name: name,
505            scope: self.scope,
506            prefix,
507        };
508        let evaluated = source.evaluate(Some(&ctx)).map_err(|e| match e {
509            crate::iteration::comprehension::eval_source::EvalError::EvalFailed {
510                var,
511                source,
512                message,
513            } => RuntimeError::SourceEval {
514                var,
515                source,
516                message,
517            },
518            crate::iteration::comprehension::eval_source::EvalError::NeedsContext => {
519                RuntimeError::UnsupportedShape(format!(
520                    "clause '{name}': source requires kernel context but evaluator \
521                             provided none — internal bug in runtime walker"
522                ))
523            }
524        })?;
525
526        if evaluated.values.is_empty() {
527            let spec_text = source_display_text(source);
528            (self.on_empty)(EmptyClause {
529                var: name,
530                spec_expr: spec_text.as_deref(),
531            })
532            .map_err(RuntimeError::EmptyPolicy)?;
533            return Ok(EvaluatedNode {
534                tuples: Vec::new(),
535                index_fn: Some(evaluated.index_fn),
536            });
537        }
538        let tuples: Vec<RuntimeTuple> = evaluated
539            .values
540            .into_iter()
541            .map(|v| vec![(name.to_string(), v)])
542            .collect();
543        Ok(EvaluatedNode {
544            tuples,
545            index_fn: Some(evaluated.index_fn),
546        })
547    }
548
549    fn evaluate_cartesian(
550        &mut self,
551        children: &[Comprehension],
552        prefix: &[(String, Value)],
553    ) -> Result<EvaluatedNode, RuntimeError> {
554        if children.is_empty() {
555            return Ok(EvaluatedNode {
556                tuples: vec![Vec::new()],
557                index_fn: Some(IndexFn::Lattice {
558                    axis_sizes: vec![1],
559                }),
560            });
561        }
562        let mut child_index_fns: Vec<Option<IndexFn>> = Vec::with_capacity(children.len());
563        let mut dependent_observed = false;
564        let result_tuples = self.evaluate_cartesian_rec(
565            children,
566            prefix,
567            &mut child_index_fns,
568            &mut dependent_observed,
569        )?;
570
571        // Combined Lattice from observed per-clause cardinalities.
572        // Dependent cartesians produce children whose
573        // per-clause cardinality varies with the prefix — we
574        // can't claim a clean Lattice in that case, so the
575        // combined index_fn is None.
576        let combined = if dependent_observed {
577            None
578        } else {
579            combine_cartesian_index_fn(&child_index_fns)
580        };
581        Ok(EvaluatedNode {
582            tuples: result_tuples,
583            index_fn: combined,
584        })
585    }
586
587    fn evaluate_cartesian_rec(
588        &mut self,
589        children: &[Comprehension],
590        prefix: &[(String, Value)],
591        child_index_fns: &mut Vec<Option<IndexFn>>,
592        dependent_observed: &mut bool,
593    ) -> Result<Vec<RuntimeTuple>, RuntimeError> {
594        if children.is_empty() {
595            return Ok(vec![Vec::new()]);
596        }
597        let (head, tail) = children.split_first().unwrap();
598        let head_eval = self.evaluate_node(head, prefix)?;
599        let head_axis_len = head_eval.tuples.len() as u64;
600        // First time through, record the head's index_fn.
601        if child_index_fns.len() <= prefix_depth(prefix, child_index_fns) {
602            child_index_fns.push(head_eval.index_fn.clone());
603        } else if let Some(prev) = child_index_fns
604            .get(prefix_depth(prefix, child_index_fns))
605            .cloned()
606            .flatten()
607        {
608            // Subsequent prefix iterations of a dependent
609            // cartesian: if the per-prefix child cardinality
610            // differs from the first prefix's, mark dependent.
611            if axis_size_of(&prev) != Some(head_axis_len) {
612                *dependent_observed = true;
613            }
614        }
615
616        if tail.is_empty() {
617            return Ok(head_eval.tuples);
618        }
619        let mut out = Vec::new();
620        for head_tuple in head_eval.tuples {
621            let mut extended_prefix: Vec<(String, Value)> = prefix.to_vec();
622            extended_prefix.extend(head_tuple.iter().cloned());
623            let tail_tuples = self.evaluate_cartesian_rec(
624                tail,
625                &extended_prefix,
626                child_index_fns,
627                dependent_observed,
628            )?;
629            for tail_tuple in tail_tuples {
630                let mut merged = head_tuple.clone();
631                merged.extend(tail_tuple);
632                out.push(merged);
633            }
634        }
635        Ok(out)
636    }
637
638    fn evaluate_zip(
639        &mut self,
640        children: &[Comprehension],
641        mode: crate::iteration::comprehension::strategy::ZipMode,
642        prefix: &[(String, Value)],
643    ) -> Result<EvaluatedNode, RuntimeError> {
644        use crate::iteration::comprehension::strategy::ZipMode;
645        if children.is_empty() {
646            return Ok(EvaluatedNode {
647                tuples: vec![Vec::new()],
648                index_fn: Some(IndexFn::Lockstep { length: 1 }),
649            });
650        }
651        let per_child: Vec<EvaluatedNode> = children
652            .iter()
653            .map(|c| self.evaluate_node(c, prefix))
654            .collect::<Result<_, _>>()?;
655        let lengths: Vec<usize> = per_child.iter().map(|n| n.tuples.len()).collect();
656        let iter_count = match mode {
657            ZipMode::Strict => {
658                let first = lengths.first().copied().unwrap_or(0);
659                if lengths.iter().any(|&n| n != first) {
660                    return Err(RuntimeError::UnsupportedShape(format!(
661                        "zip strict: child lengths differ ({lengths:?})"
662                    )));
663                }
664                first
665            }
666            ZipMode::Truncate => lengths.iter().copied().min().unwrap_or(0),
667            ZipMode::Cycle => lengths.iter().copied().max().unwrap_or(0),
668        };
669        let mut tuples = Vec::with_capacity(iter_count);
670        for i in 0..iter_count {
671            let mut bindings: RuntimeTuple = Vec::new();
672            for (child, &len) in per_child.iter().zip(lengths.iter()) {
673                if len == 0 {
674                    continue;
675                }
676                let idx = match mode {
677                    ZipMode::Cycle => i % len,
678                    _ => i,
679                };
680                bindings.extend(child.tuples[idx].iter().cloned());
681            }
682            tuples.push(bindings);
683        }
684        let index_fn = match mode {
685            ZipMode::Strict | ZipMode::Truncate => Some(IndexFn::Lockstep {
686                length: iter_count as u64,
687            }),
688            ZipMode::Cycle => Some(IndexFn::Modular {
689                axis_sizes: lengths.iter().map(|n| *n as u64).collect(),
690            }),
691        };
692        Ok(EvaluatedNode { tuples, index_fn })
693    }
694
695    fn evaluate_union(
696        &mut self,
697        children: &[Comprehension],
698        prefix: &[(String, Value)],
699    ) -> Result<EvaluatedNode, RuntimeError> {
700        let mut tuples = Vec::new();
701        let mut segment_sizes = Vec::with_capacity(children.len());
702        let mut all_segments_addressable = true;
703        for child in children {
704            let sub = self.evaluate_node(child, prefix)?;
705            segment_sizes.push(sub.tuples.len() as u64);
706            if sub.index_fn.is_none() {
707                all_segments_addressable = false;
708            }
709            tuples.extend(sub.tuples);
710        }
711        let index_fn = if all_segments_addressable {
712            Some(IndexFn::Concatenation { segment_sizes })
713        } else {
714            None
715        };
716        Ok(EvaluatedNode { tuples, index_fn })
717    }
718
719    fn apply_filter(
720        &mut self,
721        input: EvaluatedNode,
722        predicate: &str,
723    ) -> Result<EvaluatedNode, RuntimeError> {
724        let mut out = Vec::with_capacity(input.tuples.len());
725        for tuple in input.tuples {
726            // Fast path: the comprehension predicate grammar (`{name}`
727            // compared to a literal or another `{name}`, joined by `&&`,
728            // `||`, `!`, or `in [...]`) evaluates directly against the
729            // tuple, without a kernel and without compiling (SRD 113
730            // §5.2). Anything richer takes the kernel path below.
731            if let Some(keep) = fast_predicate(predicate, &tuple) {
732                if keep {
733                    out.push(tuple);
734                }
735                continue;
736            }
737            let scope = Layered {
738                prefix: &tuple,
739                inner: self.scope,
740            };
741            let interpolated = interpolate_via_kernel(predicate, &scope).map_err(|e| {
742                RuntimeError::FilterEval {
743                    predicate: predicate.to_string(),
744                    message: e.to_string(),
745                }
746            })?;
747            let result = eval_const_expr(&interpolated).map_err(|e| RuntimeError::FilterEval {
748                predicate: predicate.to_string(),
749                message: e.to_string(),
750            })?;
751            let keep = match result {
752                Value::Bool(b) => b,
753                Value::U64(n) => n != 0,
754                Value::F64(n) => n != 0.0,
755                other => {
756                    return Err(RuntimeError::FilterEval {
757                        predicate: predicate.to_string(),
758                        message: format!("expected bool/u64/f64, got {other:?}"),
759                    });
760                }
761            };
762            if keep {
763                out.push(tuple);
764            }
765        }
766        // Filter destroys the bijection per spec §10.7.2.
767        Ok(EvaluatedNode {
768            tuples: out,
769            index_fn: None,
770        })
771    }
772
773    /// Sample `truncation` points from continuous intervals with a
774    /// space-filling strategy. The strategies encode a continuous
775    /// coordinate as a 53-bit fraction of the unit interval; each is
776    /// mapped onto its axis's interval and bound to the axis's name.
777    fn sample_continuous(
778        names: &[String],
779        intervals: &[crate::iteration::comprehension::cardinality::Interval],
780        strategy: StrategyName,
781        truncation: Option<u64>,
782    ) -> Result<EvaluatedNode, RuntimeError> {
783        use crate::iteration::comprehension::strategies::{
784            halton::halton_multi_indices, lhs::lhs_multi_indices, shuffle::shuffle_multi_indices,
785            sobol::sobol_multi_indices,
786        };
787        let Some(n) = truncation else {
788            return Err(RuntimeError::OrderEval {
789                strategy,
790                message: "a continuous source has no finite tuple set; give the order a count, as in `order halton/16`".into(),
791            });
792        };
793        let index_fn = IndexFn::Continuous {
794            intervals: intervals.to_vec(),
795            measure: crate::iteration::comprehension::cardinality::ProductMeasure::Uniform,
796        };
797        let points = match strategy {
798            StrategyName::Halton => halton_multi_indices(&index_fn, Some(n)),
799            StrategyName::Sobol => sobol_multi_indices(&index_fn, Some(n)),
800            StrategyName::Lhs => lhs_multi_indices(&index_fn, Some(n)),
801            StrategyName::Shuffle => shuffle_multi_indices(&index_fn, Some(n)),
802            other => return Err(RuntimeError::OrderEval {
803                strategy: other,
804                message:
805                    "a continuous source needs a sampling strategy: halton, sobol, lhs, or shuffle"
806                        .into(),
807            }),
808        };
809        let scale = (1u64 << 53) as f64;
810        let tuples = points
811            .into_iter()
812            .map(|mi| {
813                mi.iter()
814                    .enumerate()
815                    .map(|(axis, u)| {
816                        let iv = &intervals[axis.min(intervals.len().saturating_sub(1))];
817                        let frac = (*u as f64) / scale;
818                        let x = iv.lo + frac * (iv.hi - iv.lo);
819                        let name = names
820                            .get(axis)
821                            .cloned()
822                            .unwrap_or_else(|| format!("axis{axis}"));
823                        (name, Value::F64(x))
824                    })
825                    .collect::<RuntimeTuple>()
826            })
827            .collect();
828        Ok(EvaluatedNode {
829            tuples,
830            index_fn: None,
831        })
832    }
833
834    fn apply_order(
835        &mut self,
836        input: EvaluatedNode,
837        strategy: StrategyName,
838        truncation: Option<u64>,
839    ) -> Result<EvaluatedNode, RuntimeError> {
840        use crate::iteration::comprehension::strategies::{
841            Strategy, antidiagonal::Antidiagonal, diagonal::Diagonal, extrema::Extrema,
842            halton::Halton, lex::Lex, lhs::Lhs, reverse_lex::ReverseLex, shells::Shells,
843            shuffle::Shuffle, sobol::Sobol,
844        };
845        use crate::iteration::comprehension::surfaces::polydat_value_to_tuple_value;
846
847        let dispatch: Box<dyn Strategy> = match strategy {
848            StrategyName::Lex => Box::new(Lex),
849            StrategyName::ReverseLex => Box::new(ReverseLex),
850            StrategyName::Diagonal => Box::new(Diagonal),
851            StrategyName::Antidiagonal => Box::new(Antidiagonal),
852            StrategyName::Extrema => Box::new(Extrema),
853            StrategyName::Shells => Box::new(Shells),
854            StrategyName::Halton => Box::new(Halton),
855            StrategyName::Sobol => Box::new(Sobol),
856            StrategyName::Lhs => Box::new(Lhs),
857            StrategyName::Shuffle => Box::new(Shuffle),
858        };
859
860        // V4 fire at strategy-invocation time (spec §10.7.8).
861        if !dispatch.accepts_input(input.index_fn.as_ref()) {
862            return Err(RuntimeError::StrategyRejectsInput {
863                strategy,
864                index_fn: input.index_fn.clone(),
865            });
866        }
867
868        // Build algebra tuples for the strategy in parallel
869        // with the runtime tuples. Conversion preserves the
870        // input's index order: post-apply we recover the
871        // chosen runtime tuples via algebra-Tuple PartialEq
872        // with a consumed-index bitmap so duplicate-valued
873        // tuples preserve original ordering.
874        let algebra_tuples: Vec<Tuple> = input
875            .tuples
876            .iter()
877            .map(|rt| Tuple {
878                bindings: rt
879                    .iter()
880                    .map(|(n, v)| {
881                        let tv = polydat_value_to_tuple_value(v)
882                            .unwrap_or(TupleValue::Str(v.to_display_string()));
883                        (n.clone(), tv)
884                    })
885                    .collect(),
886            })
887            .collect();
888
889        // Strategy needs SOME IndexFn to operate; if the
890        // upstream walker couldn't claim one (filter / dependent
891        // cartesian without combine), fall back to a 1-D
892        // Lattice of the observed length. The strategy's
893        // accepts_input still gated this via V4 above; Lex
894        // accepts None and reaches here; every other strategy
895        // requires Some(_) and reached here only because the
896        // walker provided one.
897        let index_fn = input.index_fn.clone().unwrap_or(IndexFn::Lattice {
898            axis_sizes: vec![algebra_tuples.len() as u64],
899        });
900        let cardinality = algebra_tuples.len() as u64;
901        let evaluated_input = EvaluatedInput {
902            tuples: algebra_tuples.clone(),
903            cardinality,
904            index_fn,
905        };
906
907        let ordered = dispatch.apply(&evaluated_input, truncation);
908
909        // Map ordered algebra tuples back to runtime tuples via
910        // PartialEq + consumed-index bitmap.
911        let mut consumed = vec![false; algebra_tuples.len()];
912        let mut out = Vec::with_capacity(ordered.len());
913        for ordered_tuple in &ordered {
914            let idx = algebra_tuples
915                .iter()
916                .enumerate()
917                .find(|(i, at)| !consumed[*i] && *at == ordered_tuple)
918                .map(|(i, _)| i)
919                .ok_or_else(|| RuntimeError::OrderEval {
920                    strategy,
921                    message: "ordered tuple lost reference to runtime source — \
922                              Strategy::apply must return tuples drawn from \
923                              EvaluatedInput.tuples (per spec §10.7.8)"
924                        .into(),
925                })?;
926            consumed[idx] = true;
927            out.push(input.tuples[idx].clone());
928        }
929        // Order may produce a different index_fn (e.g., Lex
930        // preserves; non-Lex destroys), but downstream
931        // consumers of evaluate_for_iteration only read tuples.
932        Ok(EvaluatedNode {
933            tuples: out,
934            index_fn: None,
935        })
936    }
937}
938
939/// The intervals of a comprehension whose every clause is continuous,
940/// in coordinate order; `None` when any clause is discrete or the shape
941/// is not a plain product of clauses.
942pub(crate) fn continuous_axes(
943    c: &Comprehension,
944) -> Option<Vec<crate::iteration::comprehension::cardinality::Interval>> {
945    match c {
946        Comprehension::Clause {
947            source: Source::ContinuousInterval { interval, .. },
948            ..
949        } => Some(vec![interval.clone()]),
950        Comprehension::Clause {
951            source: Source::Distribution { support, .. },
952            ..
953        } => Some(vec![support.clone()]),
954        Comprehension::Clause { .. } => None,
955        Comprehension::Cartesian { children } => {
956            let mut out = Vec::new();
957            for ch in children {
958                out.extend(continuous_axes(ch)?);
959            }
960            Some(out)
961        }
962        Comprehension::Filter { child, .. } => continuous_axes(child),
963        Comprehension::Zip { .. } | Comprehension::Union { .. } | Comprehension::Order { .. } => {
964            None
965        }
966    }
967}
968
969/// Helper: prefix depth into the child_index_fns recording.
970/// At runtime, each clause is evaluated against a prefix; the
971/// first prefix slot per clause records its index_fn. This
972/// function returns the prefix depth count = number of named
973/// bindings in `prefix` that originate from the current
974/// cartesian sequence — which for the simple recursive walker
975/// equals the prefix length minus any names we've already
976/// recorded. Conservatively returns prefix.len().
977fn prefix_depth(prefix: &[(String, Value)], _recorded: &[Option<IndexFn>]) -> usize {
978    prefix.len()
979}
980
981fn combine_cartesian_index_fn(children: &[Option<IndexFn>]) -> Option<IndexFn> {
982    let mut axis_sizes = Vec::new();
983    for opt in children {
984        match opt {
985            Some(IndexFn::Lattice { axis_sizes: a }) => axis_sizes.extend(a.iter().copied()),
986            Some(IndexFn::Lockstep { length }) => axis_sizes.push(*length),
987            // Other shapes don't combine as cartesian axes
988            // cleanly — fall back to None.
989            _ => return None,
990        }
991    }
992    Some(IndexFn::Lattice { axis_sizes })
993}
994
995fn axis_size_of(idx: &IndexFn) -> Option<u64> {
996    match idx {
997        IndexFn::Lattice { axis_sizes } if axis_sizes.len() == 1 => Some(axis_sizes[0]),
998        IndexFn::Lockstep { length } => Some(*length),
999        _ => None,
1000    }
1001}
1002
1003fn source_display_text(source: &Source) -> Option<String> {
1004    match source {
1005        Source::Generator { expr, .. } => Some(expr.clone()),
1006        Source::WorkloadParamList { name, .. } => Some(format!("{{{name}}}")),
1007        _ => None,
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use crate::iteration::comprehension::source::LiteralValue;
1015
1016    fn empty_kernel() -> Arc<PolydatKernel> {
1017        Arc::new(crate::dsl::compile_polydat("\n").unwrap())
1018    }
1019
1020    /// Canonical kernel with `extern k: u64` so the runtime
1021    /// evaluator can install per-clause `k` values via
1022    /// materialize_subscope — matches the shape
1023    /// `build_for_each_scope_kernel` produces in production.
1024    fn canonical_with_k() -> Arc<PolydatKernel> {
1025        Arc::new(crate::dsl::compile_polydat("extern k: u64\n").unwrap())
1026    }
1027
1028    #[test]
1029    fn int_range_yields_values() {
1030        let comp = Comprehension::Clause {
1031            name: "k".into(),
1032            source: Source::IntRange {
1033                lo: 1,
1034                hi: 5,
1035                step: 1,
1036            },
1037        };
1038        let canonical = empty_kernel();
1039        let params = HashMap::new();
1040        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1041        assert_eq!(tuples.len(), 4);
1042        assert_eq!(tuples[0][0].1, Value::U64(1));
1043        assert_eq!(tuples[3][0].1, Value::U64(4));
1044    }
1045
1046    #[test]
1047    fn literal_list_yields_values() {
1048        let comp = Comprehension::Clause {
1049            name: "x".into(),
1050            source: Source::Literal {
1051                values: vec![LiteralValue::Int(10), LiteralValue::Int(20)],
1052            },
1053        };
1054        let canonical = empty_kernel();
1055        let params = HashMap::new();
1056        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1057        assert_eq!(tuples.len(), 2);
1058    }
1059
1060    #[test]
1061    fn cartesian_produces_product() {
1062        let comp = Comprehension::cartesian(vec![
1063            Comprehension::Clause {
1064                name: "x".into(),
1065                source: Source::IntRange {
1066                    lo: 1,
1067                    hi: 3,
1068                    step: 1,
1069                },
1070            },
1071            Comprehension::Clause {
1072                name: "y".into(),
1073                source: Source::IntRange {
1074                    lo: 10,
1075                    hi: 30,
1076                    step: 10,
1077                },
1078            },
1079        ]);
1080        let canonical = empty_kernel();
1081        let params = HashMap::new();
1082        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1083        // 2 × 2 = 4
1084        assert_eq!(tuples.len(), 4);
1085    }
1086
1087    #[test]
1088    fn union_produces_concatenation() {
1089        let comp = Comprehension::union(vec![
1090            Comprehension::Clause {
1091                name: "k".into(),
1092                source: Source::Literal {
1093                    values: vec![LiteralValue::Int(1)],
1094                },
1095            },
1096            Comprehension::Clause {
1097                name: "k".into(),
1098                source: Source::Literal {
1099                    values: vec![LiteralValue::Int(10), LiteralValue::Int(20)],
1100                },
1101            },
1102        ]);
1103        let canonical = empty_kernel();
1104        let params = HashMap::new();
1105        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1106        assert_eq!(tuples.len(), 3);
1107    }
1108
1109    #[test]
1110    fn filter_drops_non_matching() {
1111        let comp = Comprehension::filter(
1112            Comprehension::Clause {
1113                name: "k".into(),
1114                source: Source::IntRange {
1115                    lo: 1,
1116                    hi: 6,
1117                    step: 1,
1118                },
1119            },
1120            "{k} > 3",
1121        );
1122        let canonical = canonical_with_k();
1123        let params = HashMap::new();
1124        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1125        // 1..6 = [1,2,3,4,5]; filter > 3 keeps [4, 5]
1126        assert_eq!(tuples.len(), 2);
1127    }
1128
1129    #[test]
1130    fn order_lex_truncate() {
1131        let comp = Comprehension::order(
1132            Comprehension::Clause {
1133                name: "k".into(),
1134                source: Source::IntRange {
1135                    lo: 1,
1136                    hi: 100,
1137                    step: 1,
1138                },
1139            },
1140            StrategyName::Lex,
1141            Some(5),
1142        );
1143        let canonical = empty_kernel();
1144        let params = HashMap::new();
1145        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1146        assert_eq!(tuples.len(), 5);
1147    }
1148
1149    /// PR α bug regression: a Generator-evaluated source can
1150    /// now claim an IndexFn::Lattice via SourceEval, so
1151    /// Extrema's indexed path fires and the 2-D Lattice case
1152    /// (cartesian of two clauses) gives the 2x2 corners, not
1153    /// just first/last of the cartesian product.
1154    #[test]
1155    fn extrema_over_cartesian_uses_indexed_form() {
1156        let comp = Comprehension::order(
1157            Comprehension::cartesian(vec![
1158                Comprehension::Clause {
1159                    name: "k".into(),
1160                    source: Source::Literal {
1161                        values: vec![
1162                            LiteralValue::Int(1),
1163                            LiteralValue::Int(2),
1164                            LiteralValue::Int(3),
1165                        ],
1166                    },
1167                },
1168                Comprehension::Clause {
1169                    name: "limit".into(),
1170                    source: Source::Literal {
1171                        values: vec![
1172                            LiteralValue::Int(10),
1173                            LiteralValue::Int(20),
1174                            LiteralValue::Int(30),
1175                        ],
1176                    },
1177                },
1178            ]),
1179            StrategyName::Extrema,
1180            // SRD-18d §214: `extrema/1` = the corner stratum. (Bare
1181            // `extrema`/`None` is now the full 9-tuple space reordered
1182            // corners-first; `/1` selects just the corners.)
1183            Some(1),
1184        );
1185        let canonical = empty_kernel();
1186        let params = HashMap::new();
1187        let tuples = evaluate_for_iteration(&comp, &*canonical, &params, |_| Ok(())).unwrap();
1188        // 3x3 lattice → 4 corners (interior count 0) via the indexed form.
1189        assert_eq!(tuples.len(), 4);
1190        // Each corner pairs an extreme k with an extreme limit.
1191        for t in &tuples {
1192            assert_eq!(t.len(), 2);
1193            let k = match &t[0].1 {
1194                Value::U64(n) => *n,
1195                other => panic!("expected u64 k, got {other:?}"),
1196            };
1197            let lim = match &t[1].1 {
1198                Value::U64(n) => *n,
1199                other => panic!("expected u64 limit, got {other:?}"),
1200            };
1201            assert!(k == 1 || k == 3, "expected extreme k, got {k}");
1202            assert!(lim == 10 || lim == 30, "expected extreme limit, got {lim}");
1203        }
1204    }
1205}