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}