Skip to main content

polydat_core/iteration/comprehension/
eval_source.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Source evaluation — comprehension_forms.md §10.7.0, §10.7.6,
5//! §10.7.8.
6//!
7//! Makes [`IndexFn`] a contextual query as well as a static AST
8//! property: every [`Source`] variant answers
9//! `evaluate(ctx) -> EvaluatedSource` carrying its materialized
10//! values, observed cardinality, and the index function the
11//! emitted values actually satisfy.
12//!
13//! ## Why this layer exists
14//!
15//! [`crate::iteration::comprehension::metadata`] computes `IndexFn`
16//! at AST-construction time from static source attributes
17//! (`cardinality_hint`, declared step, etc.). Two classes of sources
18//! cannot claim their real `IndexFn` that way:
19//!
20//! - **`Source::Generator { expr }`** — the source text resolves
21//!   to a list whose shape is only known after evaluation. The
22//!   static path declares `Lattice { axis_sizes: [N] }` from
23//!   `cardinality_hint` (or `Unbounded` without it).
24//! - **`Source::WorkloadParamList { name }`** — the parameter's
25//!   list contents are unknown until kernel evaluation.
26//!
27//! Non-`Lex` strategies (Diagonal / Extrema / Shells / Halton /
28//! Sobol / Lhs) need the input's real `IndexFn` shape to
29//! check V4 and dispatch their indexed-form algorithms, so V4
30//! fires at strategy invocation against the evaluated shape.
31//!
32//! ## Eval classes
33//!
34//! Per comprehension_forms.md §10.7.0, sources partition into three
35//! eval classes:
36//!
37//! | Class | Variants | `evaluate(None)` works? |
38//! |---|---|---|
39//! | [`EvalClass::Static`] | `Literal`, `IntRange`, a `Generator` whose expression references no name | yes |
40//! | [`EvalClass::ContextRequired`] | `WorkloadParamList`, a `Generator` whose expression references a name | no — needs `&Context` |
41//! | [`EvalClass::Distribution`] | `ContinuousInterval`, `Distribution` (in their "not yet sampled" state) | yes, but `values` is empty — enclosing `Order(_, sampling-strategy, Some(n))` materializes |
42//!
43//! The class of a generator is decided by its expression's free
44//! names ([`Source::referenced_names`]), never by a table of
45//! generator names: a context-free call evaluates in the empty
46//! scope ([`crate::kernel::interp::NoScope`]), and the compile
47//! flattens it into a literal of its values
48//! (`comprehension::flatten`).
49//!
50//! [`SourceEval::eval_class`] classifies a source for callers
51//! that want to know whether `evaluate(None)` will succeed; the
52//! compile-time V4 check in `validate` works from AST metadata
53//! and does not consult it. V4 fires again at
54//! strategy-invocation time (comprehension_forms.md §10.7.8).
55//!
56//! ## What this module DOES NOT own
57//!
58//! - The runtime walker that combines per-clause
59//!   `EvaluatedSource`s into the cartesian / zip / union views
60//!   strategies actually consume — that lives in
61//!   [`crate::iteration::comprehension::runtime`].
62//! - The strategy invocation itself — see
63//!   [`crate::iteration::comprehension::strategies::Strategy::apply`].
64//! - The compile-time V4 fire — see
65//!   [`mod@crate::iteration::comprehension::validate`].
66
67use std::sync::Arc;
68
69use crate::ast::Value;
70use crate::iteration::comprehension::cardinality::ProductMeasure;
71use crate::iteration::comprehension::metadata::IndexFn;
72use crate::iteration::comprehension::source::{LiteralValue, Source};
73use crate::kernel::interp::{Layered, Lookup};
74
75/// Result of evaluating one clause's source.
76///
77/// `values` carries the materialized stream (one [`Value`] per
78/// output position). `cardinality` is the count of values
79/// (`values.len() as u64`, equivalent to the `IndexFn`'s axis
80/// total for discrete sources; `0` for un-sampled continuous
81/// sources). `index_fn` is the addressing scheme the emitted
82/// values actually satisfy — derived from observed shape for
83/// `Generator` / `WorkloadParamList`, declared for static
84/// variants.
85#[derive(Debug, Clone)]
86pub struct EvaluatedSource {
87    /// The values, in dispense order.
88    pub values: Vec<Value>,
89    /// How many values; zero for an unsampled continuous source.
90    pub cardinality: u64,
91    /// The addressing scheme the values satisfy.
92    pub index_fn: IndexFn,
93}
94
95/// The eval-class partition of comprehension_forms.md §10.7.0.
96///
97/// Tells a caller whether a source can be materialized with
98/// `ctx = None`. The compile-time V4 check in `validate` works
99/// from AST metadata and does not consult this; V4 otherwise
100/// fires at strategy-invocation time.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum EvalClass {
103    /// Statically evaluable with no kernel / param context.
104    /// `evaluate(None)` returns a fully-populated
105    /// [`EvaluatedSource`].
106    Static,
107
108    /// Requires a kernel context to resolve interpolation
109    /// references or workload-param lookups.
110    /// `evaluate(None)` returns [`EvalError::NeedsContext`].
111    ContextRequired,
112
113    /// Continuous measure / distribution. `evaluate(None)`
114    /// succeeds but emits an empty `values` vector; the
115    /// `IndexFn` is `Continuous`. The enclosing sampling
116    /// `Order(_, strategy, Some(n))` materializes draws.
117    Distribution,
118}
119
120/// Errors returned by [`SourceEval::evaluate`].
121#[derive(Debug, Clone)]
122pub enum EvalError {
123    /// The source needs a kernel context that wasn't provided.
124    NeedsContext,
125
126    /// Evaluation against the supplied context failed. `var`
127    /// names the clause; `source` is the spec-text or
128    /// description; `message` carries the underlying reason.
129    EvalFailed {
130        /// The clause's element name.
131        var: String,
132        /// The source text or description.
133        source: String,
134        /// The underlying reason.
135        message: String,
136    },
137}
138
139impl std::fmt::Display for EvalError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            EvalError::NeedsContext => f.write_str("source evaluation needs a kernel context"),
143            EvalError::EvalFailed {
144                var,
145                source,
146                message,
147            } => {
148                write!(f, "source '{var} in {source}': {message}")
149            }
150        }
151    }
152}
153
154impl std::error::Error for EvalError {}
155
156/// Per-evaluation context for context-required sources.
157///
158/// Carries the live kernel against which `Source::Generator`
159/// spec-text and `Source::WorkloadParamList` lookups resolve.
160/// `var_name` lets the source synthesise a useful error
161/// message; `prefix` is the prior-axis bindings the evaluator
162/// layers in front of `scope` (via `Layered`) so dependent
163/// sources see earlier-axis values.
164pub struct EvalContext<'a> {
165    /// The clause's element name, for messages.
166    pub var_name: &'a str,
167    /// Where the source's names resolve: the body's scope with the
168    /// parent's cascaded wires.
169    pub scope: &'a dyn Lookup,
170    /// The prior-axis bindings, in axis order.
171    pub prefix: &'a [(String, Value)],
172}
173
174/// The source-evaluation surface.
175///
176/// Each [`Source`] variant implements this. The trait is
177/// object-safe but typically called through the inherent
178/// [`Source`] methods below.
179pub trait SourceEval {
180    /// Classify this source for the IR planner per
181    /// comprehension_forms.md §10.7.0. See [`EvalClass`].
182    fn eval_class(&self) -> EvalClass;
183
184    /// Materialize this source.
185    ///
186    /// Literal / IntRange (`Static`), a context-free Generator
187    /// (`Static`, evaluated in the empty scope), and
188    /// ContinuousInterval / Distribution (`Distribution`) accept
189    /// `ctx = None`. A Generator that references a name and a
190    /// WorkloadParamList (`ContextRequired`) require `Some(ctx)` and
191    /// return [`EvalError::NeedsContext`] otherwise.
192    fn evaluate(&self, ctx: Option<&EvalContext<'_>>) -> Result<EvaluatedSource, EvalError>;
193}
194
195impl SourceEval for Source {
196    fn eval_class(&self) -> EvalClass {
197        match self {
198            Source::Literal { .. } | Source::IntRange { .. } => EvalClass::Static,
199            Source::ContinuousInterval { .. } | Source::Distribution { .. } => {
200                EvalClass::Distribution
201            }
202            // A generator's class is its expression's: context-free
203            // when it references no name (comprehension_forms.md
204            // §10.7.0).
205            Source::Generator { .. } if self.referenced_names().is_empty() => EvalClass::Static,
206            Source::Generator { .. } => EvalClass::ContextRequired,
207            Source::WorkloadParamList { .. } => EvalClass::ContextRequired,
208        }
209    }
210
211    fn evaluate(&self, ctx: Option<&EvalContext<'_>>) -> Result<EvaluatedSource, EvalError> {
212        match self {
213            Source::Literal { values } => {
214                let vals: Vec<Value> = values.iter().map(literal_to_value).collect();
215                let n = vals.len() as u64;
216                Ok(EvaluatedSource {
217                    values: vals,
218                    cardinality: n,
219                    // Literal lists carry no shape claim other
220                    // than length — call them a 1-axis Lattice
221                    // of that length. Strategies that need
222                    // arithmetic progression shape (e.g. Halton
223                    // over a Lattice axis) still get useful
224                    // behavior because the lookup is by index,
225                    // not by value.
226                    index_fn: IndexFn::Lattice {
227                        axis_sizes: vec![n],
228                    },
229                })
230            }
231            Source::IntRange { lo, hi, step } => {
232                let step = (*step).max(1);
233                let mut vals = Vec::new();
234                let mut cur = *lo;
235                while cur < *hi {
236                    vals.push(Value::U64(cur as u64));
237                    cur += step;
238                }
239                let n = vals.len() as u64;
240                Ok(EvaluatedSource {
241                    values: vals,
242                    cardinality: n,
243                    index_fn: IndexFn::Lattice {
244                        axis_sizes: vec![n],
245                    },
246                })
247            }
248            Source::Generator { .. } | Source::WorkloadParamList { .. } => {
249                let spec_text = match self {
250                    Source::Generator { expr, .. } => expr.clone(),
251                    Source::WorkloadParamList { name, .. } => format!("{{{name}}}"),
252                    _ => unreachable!(),
253                };
254                // A context-free generator evaluates in the empty
255                // scope; anything that references a name needs the
256                // caller's.
257                let empty = crate::kernel::interp::NoScope::new();
258                let (var_name, scope): (&str, Layered<'_>) = match ctx {
259                    Some(ctx) => (
260                        ctx.var_name,
261                        Layered {
262                            prefix: ctx.prefix,
263                            inner: ctx.scope,
264                        },
265                    ),
266                    None if self.eval_class() == EvalClass::Static => (
267                        "<context-free>",
268                        Layered {
269                            prefix: &[],
270                            inner: &empty,
271                        },
272                    ),
273                    None => return Err(EvalError::NeedsContext),
274                };
275                // A source over None yields nothing (none_semantics.md
276                // Rule 1, comprehension_forms.md §5 V3): a name it reads
277                // that nothing binds, or that is bound to None, reads None.
278                let reads_none = self
279                    .referenced_names()
280                    .iter()
281                    .any(|name| matches!(scope.lookup(name), None | Some(Value::None)));
282                if reads_none {
283                    return Ok(EvaluatedSource {
284                        values: Vec::new(),
285                        cardinality: 0,
286                        index_fn: IndexFn::Lattice {
287                            axis_sizes: vec![0],
288                        },
289                    });
290                }
291                let vals = crate::iteration::comprehension::eval::evaluate_spec(&spec_text, &scope)
292                    .map_err(|e| EvalError::EvalFailed {
293                        var: var_name.to_string(),
294                        source: spec_text,
295                        message: e.to_string(),
296                    })?;
297                let n = vals.len() as u64;
298                let index_fn = classify_observed_values(&vals);
299                Ok(EvaluatedSource {
300                    values: vals,
301                    cardinality: n,
302                    index_fn,
303                })
304            }
305            Source::ContinuousInterval { interval, measure } => Ok(EvaluatedSource {
306                values: Vec::new(),
307                cardinality: 0,
308                index_fn: IndexFn::Continuous {
309                    intervals: vec![interval.clone()],
310                    measure: measure.clone(),
311                },
312            }),
313            Source::Distribution {
314                distribution,
315                support,
316                ..
317            } => Ok(EvaluatedSource {
318                values: Vec::new(),
319                cardinality: 0,
320                // The parameters travel on the AST carrier; the
321                // runtime's sampler reads them there
322                // (comprehension_forms.md §10.7.6).
323                index_fn: IndexFn::Continuous {
324                    intervals: vec![support.clone()],
325                    measure: ProductMeasure::Named(*distribution),
326                },
327            }),
328        }
329    }
330}
331
332/// Classify a materialized value list by observed shape.
333///
334/// The "expand-then-classify" stage of comprehension_forms.md
335/// §10.7.6 / §10.7.8: any list of `N` values is a one-axis
336/// `Lattice { axis_sizes: [N] }`, whose only shape claim is its
337/// length, since a strategy looks values up by position. The shape
338/// is read off the evaluated values, never declared from a
339/// generator's name.
340fn classify_observed_values(vals: &[Value]) -> IndexFn {
341    let n = vals.len() as u64;
342    IndexFn::Lattice {
343        axis_sizes: vec![n],
344    }
345}
346
347fn literal_to_value(lv: &LiteralValue) -> Value {
348    match lv {
349        LiteralValue::Int(n) => Value::U64(*n as u64),
350        LiteralValue::UInt(n) => Value::U64(*n),
351        LiteralValue::Float(f) => Value::F64(*f),
352        LiteralValue::String(s) => Value::Str(Arc::from(s.as_str())),
353        LiteralValue::Bool(b) => Value::Bool(*b),
354        LiteralValue::Json(j) => Value::Json(Arc::new(j.clone())),
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::iteration::comprehension::cardinality::{Interval, MeasureName, ProductMeasure};
362    use crate::iteration::comprehension::source::LiteralValue;
363
364    #[test]
365    fn literal_evaluates_without_context() {
366        let s = Source::Literal {
367            values: vec![
368                LiteralValue::Int(1),
369                LiteralValue::Int(2),
370                LiteralValue::Int(3),
371            ],
372        };
373        assert_eq!(s.eval_class(), EvalClass::Static);
374        let ev = s.evaluate(None).unwrap();
375        assert_eq!(ev.cardinality, 3);
376        assert_eq!(ev.values.len(), 3);
377        assert!(matches!(ev.index_fn, IndexFn::Lattice { axis_sizes: ref a } if a == &vec![3]));
378    }
379
380    #[test]
381    fn int_range_evaluates_without_context() {
382        let s = Source::IntRange {
383            lo: 0,
384            hi: 10,
385            step: 2,
386        };
387        assert_eq!(s.eval_class(), EvalClass::Static);
388        let ev = s.evaluate(None).unwrap();
389        // 0, 2, 4, 6, 8 = 5 values
390        assert_eq!(ev.cardinality, 5);
391        assert!(matches!(ev.index_fn, IndexFn::Lattice { axis_sizes: ref a } if a == &vec![5]));
392    }
393
394    #[test]
395    fn a_context_free_generator_evaluates_without_context() {
396        let s = Source::Generator {
397            expr: "fib(6)".into(),
398            cardinality_hint: None,
399        };
400        assert_eq!(s.eval_class(), EvalClass::Static);
401        let ev = s.evaluate(None).unwrap();
402        assert_eq!(ev.cardinality, 6);
403    }
404
405    #[test]
406    fn generator_without_context_errors() {
407        let s = Source::Generator {
408            expr: "range(0, {n})".into(),
409            cardinality_hint: Some(10),
410        };
411        assert_eq!(s.eval_class(), EvalClass::ContextRequired);
412        match s.evaluate(None) {
413            Err(EvalError::NeedsContext) => {}
414            other => panic!("expected NeedsContext, got {other:?}"),
415        }
416    }
417
418    #[test]
419    fn workload_param_list_without_context_errors() {
420        let s = Source::WorkloadParamList {
421            name: "k_values".into(),
422            len_hint: Some(5),
423        };
424        assert_eq!(s.eval_class(), EvalClass::ContextRequired);
425        assert!(matches!(s.evaluate(None), Err(EvalError::NeedsContext)));
426    }
427
428    #[test]
429    fn continuous_interval_yields_continuous_index_fn() {
430        let s = Source::ContinuousInterval {
431            interval: Interval::closed(0.0, 1.0),
432            measure: ProductMeasure::Uniform,
433        };
434        assert_eq!(s.eval_class(), EvalClass::Distribution);
435        let ev = s.evaluate(None).unwrap();
436        assert_eq!(ev.cardinality, 0);
437        assert!(ev.values.is_empty());
438        match ev.index_fn {
439            IndexFn::Continuous { intervals, .. } => assert_eq!(intervals.len(), 1),
440            other => panic!("expected Continuous, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn distribution_yields_continuous_index_fn() {
446        let s = Source::Distribution {
447            distribution: MeasureName::Normal,
448            support: Interval {
449                lo: f64::NEG_INFINITY,
450                hi: f64::INFINITY,
451                lo_open: true,
452                hi_open: true,
453            },
454            params: vec![0.0, 1.0],
455        };
456        assert_eq!(s.eval_class(), EvalClass::Distribution);
457        let ev = s.evaluate(None).unwrap();
458        assert_eq!(ev.cardinality, 0);
459        assert!(matches!(
460            ev.index_fn,
461            IndexFn::Continuous {
462                measure: ProductMeasure::Named(MeasureName::Normal),
463                ..
464            }
465        ));
466    }
467
468    #[test]
469    fn generator_with_context_evaluates_to_lattice() {
470        let canonical = Arc::new(crate::dsl::compile_polydat_interpreter("\n").unwrap());
471        let s = Source::Generator {
472            expr: "1, 2, 3, 4, 5".into(),
473            cardinality_hint: Some(5),
474        };
475        let ctx = EvalContext {
476            var_name: "k",
477            scope: &*canonical,
478            prefix: &[],
479        };
480        let ev = s.evaluate(Some(&ctx)).unwrap();
481        assert_eq!(ev.cardinality, 5);
482        assert!(matches!(ev.index_fn, IndexFn::Lattice { .. }));
483    }
484}