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