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