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