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