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 the names its expression
44//! reads ([`Source::names_read`]), 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::eval::{SpecError, spec_error};
72use crate::iteration::comprehension::metadata::IndexFn;
73use crate::iteration::comprehension::source::{LiteralValue, Source};
74use crate::kernel::interp::{Layered, Lookup};
75
76/// Result of evaluating one clause's source.
77///
78/// `values` carries the materialized stream (one [`Value`] per
79/// output position). `cardinality` is the count of values
80/// (`values.len() as u64`, equivalent to the `IndexFn`'s axis
81/// total for discrete sources; `0` for un-sampled continuous
82/// sources). `index_fn` is the addressing scheme the emitted
83/// values actually satisfy — derived from observed shape for
84/// `Generator` / `WorkloadParamList`, declared for static
85/// variants.
86#[derive(Debug, Clone)]
87pub struct EvaluatedSource {
88    /// The values, in dispense order.
89    pub values: Vec<Value>,
90    /// How many values; zero for an unsampled continuous source.
91    pub cardinality: u64,
92    /// The addressing scheme the values satisfy.
93    pub index_fn: IndexFn,
94}
95
96/// A name a source read that made it yield nothing
97/// (none_semantics.md Rule 1, comprehension_forms.md §5 V3): the name
98/// as read, after composition, so a composed `{k_{k}_limits}` read with
99/// `k = 3` is `k_3_limits`, and `all(<cursor>)` reads the cursor's
100/// extent outputs.
101#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
102pub enum NoneRead {
103    /// Nothing binds the name in the scope the source is evaluated in,
104    /// nor does an earlier axis.
105    Unbound(String),
106    /// The name is bound to None.
107    BoundNone(String),
108}
109
110impl NoneRead {
111    /// The name read.
112    pub fn name(&self) -> &str {
113        match self {
114            NoneRead::Unbound(name) | NoneRead::BoundNone(name) => name,
115        }
116    }
117}
118
119impl std::fmt::Display for NoneRead {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            NoneRead::Unbound(name) => write!(f, "`{name}` is not bound"),
123            NoneRead::BoundNone(name) => write!(f, "`{name}` is None"),
124        }
125    }
126}
127
128/// The eval-class partition of comprehension_forms.md §10.7.0.
129///
130/// Tells a caller whether a source can be materialized with
131/// `ctx = None`. The compile-time V4 check in `validate` works
132/// from AST metadata and does not consult this; V4 otherwise
133/// fires at strategy-invocation time.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum EvalClass {
136    /// Statically evaluable with no kernel / param context.
137    /// `evaluate(None)` returns a fully-populated
138    /// [`EvaluatedSource`].
139    Static,
140
141    /// Requires a kernel context to resolve interpolation
142    /// references or workload-param lookups.
143    /// `evaluate(None)` returns [`EvalError::NeedsContext`].
144    ContextRequired,
145
146    /// Continuous measure / distribution. `evaluate(None)`
147    /// succeeds but emits an empty `values` vector; the
148    /// `IndexFn` is `Continuous`. The enclosing sampling
149    /// `Order(_, strategy, Some(n))` materializes draws.
150    Distribution,
151}
152
153/// Errors returned by [`SourceEval::evaluate`].
154#[derive(Debug, Clone)]
155pub enum EvalError {
156    /// The source needs a kernel context that wasn't provided.
157    NeedsContext,
158
159    /// Evaluation against the supplied context failed. `var`
160    /// names the clause; `source` is the spec-text or
161    /// description; `message` carries the underlying reason.
162    EvalFailed {
163        /// The clause's element name.
164        var: String,
165        /// The source text or description.
166        source: String,
167        /// The underlying reason.
168        message: String,
169    },
170}
171
172impl std::fmt::Display for EvalError {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            EvalError::NeedsContext => f.write_str("source evaluation needs a kernel context"),
176            EvalError::EvalFailed {
177                var,
178                source,
179                message,
180            } => {
181                write!(f, "source '{var} in {source}': {message}")
182            }
183        }
184    }
185}
186
187impl std::error::Error for EvalError {}
188
189/// Per-evaluation context for context-required sources.
190///
191/// Carries the live kernel against which `Source::Generator`
192/// spec-text and `Source::WorkloadParamList` lookups resolve.
193/// `var_name` lets the source synthesise a useful error
194/// message; `prefix` is the prior-axis bindings the evaluator
195/// layers in front of `scope` (via `Layered`) so dependent
196/// sources see earlier-axis values.
197pub struct EvalContext<'a> {
198    /// The clause's element name, for messages.
199    pub var_name: &'a str,
200    /// Where the source's names resolve: the body's scope with the
201    /// parent's cascaded wires.
202    pub scope: &'a dyn Lookup,
203    /// The prior-axis bindings, in axis order.
204    pub prefix: &'a [(String, Value)],
205}
206
207/// The source-evaluation surface.
208///
209/// Each [`Source`] variant implements this. The trait is
210/// object-safe but typically called through the inherent
211/// [`Source`] methods below.
212pub trait SourceEval {
213    /// Classify this source for the IR planner per
214    /// comprehension_forms.md §10.7.0. See [`EvalClass`].
215    fn eval_class(&self) -> EvalClass;
216
217    /// Materialize this source.
218    ///
219    /// Literal / IntRange (`Static`), a context-free Generator
220    /// (`Static`, evaluated in the empty scope), and
221    /// ContinuousInterval / Distribution (`Distribution`) accept
222    /// `ctx = None`. A Generator that references a name and a
223    /// WorkloadParamList (`ContextRequired`) require `Some(ctx)` and
224    /// return [`EvalError::NeedsContext`] otherwise.
225    fn evaluate(&self, ctx: Option<&EvalContext<'_>>) -> Result<EvaluatedSource, EvalError>;
226}
227
228impl SourceEval for Source {
229    fn eval_class(&self) -> EvalClass {
230        match self {
231            Source::Literal { .. } | Source::IntRange { .. } => EvalClass::Static,
232            Source::ContinuousInterval { .. } | Source::Distribution { .. } => {
233                EvalClass::Distribution
234            }
235            // A generator's class is its expression's: context-free
236            // when it reads no name (comprehension_forms.md
237            // §10.7.0).
238            Source::Generator { .. } if self.names_read().is_empty() => EvalClass::Static,
239            Source::Generator { .. } => EvalClass::ContextRequired,
240            Source::WorkloadParamList { .. } => EvalClass::ContextRequired,
241        }
242    }
243
244    fn evaluate(&self, ctx: Option<&EvalContext<'_>>) -> Result<EvaluatedSource, EvalError> {
245        evaluate_reading(self, ctx).map(|(evaluated, _)| evaluated)
246    }
247}
248
249/// [`SourceEval::evaluate`], with the names whose reads made the source
250/// yield nothing: a source that reads, after composition, a name nothing
251/// binds or a name bound to None yields nothing (none_semantics.md
252/// Rule 1, comprehension_forms.md §5 V3), and those names are the second
253/// part, empty whenever the source yields.
254pub(crate) fn evaluate_reading(
255    source: &Source,
256    ctx: Option<&EvalContext<'_>>,
257) -> Result<(EvaluatedSource, Vec<NoneRead>), EvalError> {
258    let evaluated = match source {
259        Source::Generator { .. } | Source::WorkloadParamList { .. } => {
260            return evaluate_spec_source(source, ctx);
261        }
262        other => evaluate_static(other),
263    };
264    Ok((evaluated, Vec::new()))
265}
266
267/// Evaluate a `Generator` or `WorkloadParamList` source's spec text in
268/// the context's scope, with the prior-axis bindings in front.
269fn evaluate_spec_source(
270    source: &Source,
271    ctx: Option<&EvalContext<'_>>,
272) -> Result<(EvaluatedSource, Vec<NoneRead>), EvalError> {
273    let spec_text = match source {
274        Source::Generator { expr, .. } => expr.clone(),
275        Source::WorkloadParamList { name, .. } => format!("{{{name}}}"),
276        _ => unreachable!("only spec-text sources"),
277    };
278    // A context-free generator evaluates in the empty
279    // scope; anything that references a name needs the
280    // caller's.
281    let empty = crate::kernel::interp::NoScope::new();
282    let (var_name, scope): (&str, Layered<'_>) = match ctx {
283        Some(ctx) => (
284            ctx.var_name,
285            Layered {
286                prefix: ctx.prefix,
287                inner: ctx.scope,
288            },
289        ),
290        None if source.eval_class() == EvalClass::Static => (
291            "<context-free>",
292            Layered {
293                prefix: &[],
294                inner: &empty,
295            },
296        ),
297        None => return Err(EvalError::NeedsContext),
298    };
299    match crate::iteration::comprehension::eval::evaluate_spec_internal(&spec_text, &scope) {
300        Ok(vals) => {
301            let n = vals.len() as u64;
302            let index_fn = classify_observed_values(&vals);
303            Ok((
304                EvaluatedSource {
305                    values: vals,
306                    cardinality: n,
307                    index_fn,
308                },
309                Vec::new(),
310            ))
311        }
312        // A source over None yields nothing: the evaluation read, after
313        // composition, a name nothing binds or one bound to None.
314        Err(SpecError::ReadsNone { reads, .. }) => Ok((
315            EvaluatedSource {
316                values: Vec::new(),
317                cardinality: 0,
318                index_fn: IndexFn::Lattice {
319                    axis_sizes: vec![0],
320                },
321            },
322            reads,
323        )),
324        Err(SpecError::Failed(message)) => Err(EvalError::EvalFailed {
325            var: var_name.to_string(),
326            message: spec_error(&spec_text, message).to_string(),
327            source: spec_text,
328        }),
329    }
330}
331
332/// The evaluation of a source that reads no name: literals, ranges, and
333/// continuous measures.
334fn evaluate_static(source: &Source) -> EvaluatedSource {
335    match source {
336        Source::Literal { values } => {
337            let vals: Vec<Value> = values.iter().map(literal_to_value).collect();
338            let n = vals.len() as u64;
339            EvaluatedSource {
340                values: vals,
341                cardinality: n,
342                // Literal lists carry no shape claim other
343                // than length — call them a 1-axis Lattice
344                // of that length. Strategies that need
345                // arithmetic progression shape (e.g. Halton
346                // over a Lattice axis) still get useful
347                // behavior because the lookup is by index,
348                // not by value.
349                index_fn: IndexFn::Lattice {
350                    axis_sizes: vec![n],
351                },
352            }
353        }
354        Source::IntRange { lo, hi, step } => {
355            let step = (*step).max(1);
356            let mut vals = Vec::new();
357            let mut cur = *lo;
358            while cur < *hi {
359                vals.push(Value::U64(cur as u64));
360                cur += step;
361            }
362            let n = vals.len() as u64;
363            EvaluatedSource {
364                values: vals,
365                cardinality: n,
366                index_fn: IndexFn::Lattice {
367                    axis_sizes: vec![n],
368                },
369            }
370        }
371        Source::ContinuousInterval { interval, measure } => EvaluatedSource {
372            values: Vec::new(),
373            cardinality: 0,
374            index_fn: IndexFn::Continuous {
375                intervals: vec![interval.clone()],
376                measure: measure.clone(),
377            },
378        },
379        Source::Distribution {
380            distribution,
381            support,
382            ..
383        } => EvaluatedSource {
384            values: Vec::new(),
385            cardinality: 0,
386            // The parameters travel on the AST carrier; the
387            // runtime's sampler reads them there
388            // (comprehension_forms.md §10.7.6).
389            index_fn: IndexFn::Continuous {
390                intervals: vec![support.clone()],
391                measure: ProductMeasure::Named(*distribution),
392            },
393        },
394        Source::Generator { .. } | Source::WorkloadParamList { .. } => {
395            unreachable!("a spec-text source reads names")
396        }
397    }
398}
399
400/// Classify a materialized value list by observed shape.
401///
402/// The "expand-then-classify" stage of comprehension_forms.md
403/// §10.7.6 / §10.7.8: any list of `N` values is a one-axis
404/// `Lattice { axis_sizes: [N] }`, whose only shape claim is its
405/// length, since a strategy looks values up by position. The shape
406/// is read off the evaluated values, never declared from a
407/// generator's name.
408fn classify_observed_values(vals: &[Value]) -> IndexFn {
409    let n = vals.len() as u64;
410    IndexFn::Lattice {
411        axis_sizes: vec![n],
412    }
413}
414
415fn literal_to_value(lv: &LiteralValue) -> Value {
416    match lv {
417        LiteralValue::Int(n) => Value::U64(*n as u64),
418        LiteralValue::UInt(n) => Value::U64(*n),
419        LiteralValue::Float(f) => Value::F64(*f),
420        LiteralValue::String(s) => Value::Str(Arc::from(s.as_str())),
421        LiteralValue::Bool(b) => Value::Bool(*b),
422        LiteralValue::Json(j) => Value::Json(Arc::new(j.clone())),
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::iteration::comprehension::cardinality::{Interval, MeasureName, ProductMeasure};
430    use crate::iteration::comprehension::source::LiteralValue;
431
432    #[test]
433    fn literal_evaluates_without_context() {
434        let s = Source::Literal {
435            values: vec![
436                LiteralValue::Int(1),
437                LiteralValue::Int(2),
438                LiteralValue::Int(3),
439            ],
440        };
441        assert_eq!(s.eval_class(), EvalClass::Static);
442        let ev = s.evaluate(None).unwrap();
443        assert_eq!(ev.cardinality, 3);
444        assert_eq!(ev.values.len(), 3);
445        assert!(matches!(ev.index_fn, IndexFn::Lattice { axis_sizes: ref a } if a == &vec![3]));
446    }
447
448    #[test]
449    fn int_range_evaluates_without_context() {
450        let s = Source::IntRange {
451            lo: 0,
452            hi: 10,
453            step: 2,
454        };
455        assert_eq!(s.eval_class(), EvalClass::Static);
456        let ev = s.evaluate(None).unwrap();
457        // 0, 2, 4, 6, 8 = 5 values
458        assert_eq!(ev.cardinality, 5);
459        assert!(matches!(ev.index_fn, IndexFn::Lattice { axis_sizes: ref a } if a == &vec![5]));
460    }
461
462    #[test]
463    fn a_context_free_generator_evaluates_without_context() {
464        let s = Source::Generator {
465            expr: "fib(6)".into(),
466            cardinality_hint: None,
467        };
468        assert_eq!(s.eval_class(), EvalClass::Static);
469        let ev = s.evaluate(None).unwrap();
470        assert_eq!(ev.cardinality, 6);
471    }
472
473    #[test]
474    fn generator_without_context_errors() {
475        let s = Source::Generator {
476            expr: "range(0, {n})".into(),
477            cardinality_hint: Some(10),
478        };
479        assert_eq!(s.eval_class(), EvalClass::ContextRequired);
480        match s.evaluate(None) {
481            Err(EvalError::NeedsContext) => {}
482            other => panic!("expected NeedsContext, got {other:?}"),
483        }
484    }
485
486    #[test]
487    fn workload_param_list_without_context_errors() {
488        let s = Source::WorkloadParamList {
489            name: "k_values".into(),
490            len_hint: Some(5),
491        };
492        assert_eq!(s.eval_class(), EvalClass::ContextRequired);
493        assert!(matches!(s.evaluate(None), Err(EvalError::NeedsContext)));
494    }
495
496    #[test]
497    fn continuous_interval_yields_continuous_index_fn() {
498        let s = Source::ContinuousInterval {
499            interval: Interval::closed(0.0, 1.0),
500            measure: ProductMeasure::Uniform,
501        };
502        assert_eq!(s.eval_class(), EvalClass::Distribution);
503        let ev = s.evaluate(None).unwrap();
504        assert_eq!(ev.cardinality, 0);
505        assert!(ev.values.is_empty());
506        match ev.index_fn {
507            IndexFn::Continuous { intervals, .. } => assert_eq!(intervals.len(), 1),
508            other => panic!("expected Continuous, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn distribution_yields_continuous_index_fn() {
514        let s = Source::Distribution {
515            distribution: MeasureName::Normal,
516            support: Interval {
517                lo: f64::NEG_INFINITY,
518                hi: f64::INFINITY,
519                lo_open: true,
520                hi_open: true,
521            },
522            params: vec![0.0, 1.0],
523        };
524        assert_eq!(s.eval_class(), EvalClass::Distribution);
525        let ev = s.evaluate(None).unwrap();
526        assert_eq!(ev.cardinality, 0);
527        assert!(matches!(
528            ev.index_fn,
529            IndexFn::Continuous {
530                measure: ProductMeasure::Named(MeasureName::Normal),
531                ..
532            }
533        ));
534    }
535
536    #[test]
537    fn generator_with_context_evaluates_to_lattice() {
538        let canonical = Arc::new(crate::dsl::compile_polydat_interpreter("\n").unwrap());
539        let s = Source::Generator {
540            expr: "1, 2, 3, 4, 5".into(),
541            cardinality_hint: Some(5),
542        };
543        let ctx = EvalContext {
544            var_name: "k",
545            scope: &*canonical,
546            prefix: &[],
547        };
548        let ev = s.evaluate(Some(&ctx)).unwrap();
549        assert_eq!(ev.cardinality, 5);
550        assert!(matches!(ev.index_fn, IndexFn::Lattice { .. }));
551    }
552}