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}