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}