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}