polydat_grammar/comprehension/source.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Clause source values (comprehension_forms.md §3.1).
5//!
6//! A `clause(name, source)` binds a name to the values
7//! produced by its source. Sources split into two families:
8//!
9//! - **Discrete stream producers** — literal lists, integer
10//! ranges, generator functions, workload-param references.
11//! Cardinality is `Bounded`, `BoundedAtMost`, or `Unbounded`.
12//! - **Continuous measures** — real intervals with an
13//! integrable measure (uniform on bounded intervals; named
14//! probability distributions like Normal / Exponential).
15//! Cardinality is `Continuous`; V8 requires an enclosing
16//! sampling `order(_, strategy, Some(n))` before dispense.
17//!
18//! Sources are stream producers — they do not pre-materialize
19//! into `Vec<Value>`. This is the load-bearing model property
20//! of comprehension_forms.md §3.1 and §6.2.
21
22use serde::{Deserialize, Serialize};
23
24use super::cardinality::{CardinalityClass, Interval, MeasureName, ProductMeasure};
25
26/// A clause's source of values.
27///
28/// Discrete variants produce a stream of `Value` via the
29/// runtime evaluator; continuous variants describe a measure
30/// that a downstream sampling strategy will draw from.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum Source {
34 /// Literal comma list (e.g., `[1, 2, 4, 8]`). Stream
35 /// producer over the list contents.
36 Literal {
37 /// The values, in order.
38 values: Vec<LiteralValue>,
39 },
40
41 /// Integer half-open range `lo..hi` with optional step.
42 /// Default step is 1.
43 IntRange {
44 /// The first value.
45 lo: i64,
46 /// One past the last.
47 hi: i64,
48 /// The step between values, 1 by default.
49 step: i64,
50 },
51
52 /// Generator function call expressed as a Polydat source string.
53 /// Its eval class follows its free names (comprehension_forms.md §10.7.0): an
54 /// expression that references no name is context-free and is
55 /// evaluated at compile, so a clause over it becomes a literal of
56 /// its values; one that references a name (an outer coordinate,
57 /// a parameter, a wire) is evaluated at traversal.
58 Generator {
59 /// The generator call, as Polydat source.
60 expr: String,
61 /// How many values it yields, when known: the count the
62 /// compile established by evaluating a context-free call
63 /// whose values are not literal-representable (a partition
64 /// list, for one). `None` for a call evaluated at traversal,
65 /// whose cardinality is `Unbounded` until then.
66 cardinality_hint: Option<u64>,
67 },
68
69 /// Reference to a workload-level parameter that resolves to
70 /// a list of values. Cardinality is the parameter's
71 /// declared list length.
72 WorkloadParamList {
73 /// The parameter's name.
74 name: String,
75 /// The list's length, when known.
76 len_hint: Option<u64>,
77 },
78
79 /// Real interval (continuous source). Combined with a
80 /// `measure` to form a `Continuous` cardinality.
81 /// Integrability is checked at parse via V8.
82 ContinuousInterval {
83 /// The interval.
84 interval: Interval,
85 /// The measure drawn from.
86 measure: ProductMeasure,
87 },
88
89 /// Named continuous distribution. The distribution carries
90 /// its own support; the `support` field records the
91 /// effective interval for V8's check.
92 Distribution {
93 /// The distribution.
94 distribution: MeasureName,
95 /// Its effective support.
96 support: Interval,
97 /// Its parameters, in the order of
98 /// [`MeasureName::parameter_names`]; empty for the standard
99 /// parameters ([`MeasureName::default_params`]).
100 params: Vec<f64>,
101 },
102}
103
104/// A literal value carried in a `Source::Literal`. Subset of
105/// the polydat `Value` type — the kinds clauses can directly
106/// bind. Extension to richer value types lives in the source
107/// evaluator, not the AST.
108///
109/// Serialized untagged because the variants are primitives;
110/// the JSON/YAML representation is just the bare value
111/// (`1` / `"x"` / `true` / `1.5`).
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113#[serde(untagged)]
114pub enum LiteralValue {
115 /// An integer.
116 Int(i64),
117 /// An integer above `i64::MAX`, which only a `u64` holds. An
118 /// integer that fits `i64` is `Int`, so every integer has one
119 /// form ([`LiteralValue::unsigned`]). After `Int`, so an untagged
120 /// read takes an integer as `Int` when it fits.
121 UInt(u64),
122 /// A float.
123 Float(f64),
124 /// A string.
125 String(String),
126 /// A boolean.
127 Bool(bool),
128 /// A JSON value carrying its own kind: an item of a JSON list a
129 /// generator supplied at run time, bound where the element is
130 /// declared `json`. Last, so an untagged read tries the scalar
131 /// forms first.
132 Json(serde_json::Value),
133}
134
135impl LiteralValue {
136 /// The literal of an unsigned integer: `Int` when it fits `i64`,
137 /// `UInt` above.
138 pub fn unsigned(n: u64) -> Self {
139 match i64::try_from(n) {
140 Ok(n) => LiteralValue::Int(n),
141 Err(_) => LiteralValue::UInt(n),
142 }
143 }
144}
145
146impl Source {
147 /// The names this source references (comprehension_forms.md §10.7.0): a generator
148 /// expression's parsed free identifiers (`concat(foo)`) and its
149 /// `{name}` interpolation placeholders, and a workload parameter
150 /// list's own name. Literals, ranges, and intervals reference
151 /// nothing. A source with no references is context-free.
152 pub fn referenced_names(&self) -> std::collections::BTreeSet<String> {
153 let mut out = std::collections::BTreeSet::new();
154 match self {
155 Source::WorkloadParamList { name, .. } => {
156 out.insert(name.clone());
157 }
158 Source::Generator { expr, .. } => {
159 out.extend(crate::refs::referenced_names(expr));
160 crate::refs::collect_string_interpolation_refs(expr, &mut out);
161 }
162 Source::Literal { .. }
163 | Source::IntRange { .. }
164 | Source::ContinuousInterval { .. }
165 | Source::Distribution { .. } => {}
166 }
167 out
168 }
169
170 /// Declare this source's cardinality class for use by
171 /// `clause` metadata propagation.
172 pub fn cardinality(&self) -> CardinalityClass {
173 match self {
174 Source::Literal { values } => CardinalityClass::Bounded(values.len() as u64),
175 Source::IntRange { lo, hi, step } => {
176 let step = (*step).max(1).unsigned_abs();
177 if hi <= lo {
178 CardinalityClass::Bounded(0)
179 } else {
180 let span = (hi - lo) as u64;
181 let n = span.div_ceil(step);
182 CardinalityClass::Bounded(n)
183 }
184 }
185 Source::Generator {
186 cardinality_hint, ..
187 } => match cardinality_hint {
188 Some(n) => CardinalityClass::Bounded(*n),
189 None => CardinalityClass::Unbounded,
190 },
191 Source::WorkloadParamList { len_hint, .. } => match len_hint {
192 Some(n) => CardinalityClass::Bounded(*n),
193 None => CardinalityClass::Unbounded,
194 },
195 Source::ContinuousInterval { interval, measure } => CardinalityClass::Continuous {
196 intervals: vec![interval.clone()],
197 measure: measure.clone(),
198 },
199 Source::Distribution { support, .. } => CardinalityClass::Continuous {
200 intervals: vec![support.clone()],
201 measure: ProductMeasure::Named(*self.distribution_name()),
202 },
203 }
204 }
205
206 /// `true` if this source is continuous (Continuous /
207 /// Distribution variants). Used by V7 (zip must be all
208 /// discrete) and V9 (union must be all discrete) without
209 /// a full cardinality computation.
210 pub fn is_continuous(&self) -> bool {
211 matches!(
212 self,
213 Source::ContinuousInterval { .. } | Source::Distribution { .. }
214 )
215 }
216
217 /// `true` if this source is discrete (every variant except
218 /// the continuous ones).
219 pub fn is_discrete(&self) -> bool {
220 !self.is_continuous()
221 }
222
223 fn distribution_name(&self) -> &MeasureName {
224 match self {
225 Source::Distribution { distribution, .. } => distribution,
226 _ => panic!("distribution_name called on non-Distribution source"),
227 }
228 }
229}
230
231// ── iteration interior and string-comprehension striping
232// (comprehension_forms.md §3.1.2, §3.1.3) ──
233
234/// The string-comprehension separator rule (comprehension_forms.md
235/// §3.1.3), in one place
236/// so the parse-time (`source_parser`) and runtime (`eval`)
237/// striping can never drift: split on runs of comma / semicolon /
238/// ASCII whitespace, trim, drop empties. Every other character
239/// (`:` `.` `-` `/` …) stays in the token. Returns the raw token
240/// substrings; callers type them (Value or LiteralValue).
241pub fn split_string_comprehension(s: &str) -> Vec<&str> {
242 s.split(|c: char| c == ',' || c == ';' || c.is_ascii_whitespace())
243 .map(str::trim)
244 .filter(|t| !t.is_empty())
245 .collect()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn literal_cardinality_is_list_length() {
254 let s = Source::Literal {
255 values: vec![
256 LiteralValue::Int(1),
257 LiteralValue::Int(2),
258 LiteralValue::Int(3),
259 ],
260 };
261 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(3)));
262 }
263
264 /// An integer has one literal form, `Int` up to `i64::MAX` and
265 /// `UInt` above, and its serialized form reads back as that form.
266 #[test]
267 fn an_unsigned_literal_keeps_its_value_through_serde() {
268 assert_eq!(
269 LiteralValue::unsigned(i64::MAX as u64),
270 LiteralValue::Int(i64::MAX)
271 );
272 assert_eq!(
273 LiteralValue::unsigned(u64::MAX),
274 LiteralValue::UInt(u64::MAX)
275 );
276 for v in [
277 LiteralValue::Int(-3),
278 LiteralValue::Int(i64::MAX),
279 LiteralValue::UInt(1 << 63),
280 LiteralValue::UInt(u64::MAX),
281 ] {
282 let json = serde_json::to_string(&v).unwrap();
283 let back: LiteralValue = serde_json::from_str(&json).unwrap();
284 assert_eq!(back, v, "{json}");
285 }
286 }
287
288 #[test]
289 fn int_range_step_1() {
290 let s = Source::IntRange {
291 lo: 1,
292 hi: 10,
293 step: 1,
294 };
295 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(9)));
296 }
297
298 #[test]
299 fn int_range_with_step() {
300 let s = Source::IntRange {
301 lo: 0,
302 hi: 10,
303 step: 2,
304 };
305 // 0,2,4,6,8 = 5 values
306 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(5)));
307 }
308
309 #[test]
310 fn int_range_empty() {
311 let s = Source::IntRange {
312 lo: 5,
313 hi: 5,
314 step: 1,
315 };
316 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(0)));
317 }
318
319 #[test]
320 fn generator_without_hint_is_unbounded() {
321 let s = Source::Generator {
322 expr: "live_query()".into(),
323 cardinality_hint: None,
324 };
325 assert!(matches!(s.cardinality(), CardinalityClass::Unbounded));
326 }
327
328 #[test]
329 fn generator_with_hint_is_bounded() {
330 let s = Source::Generator {
331 expr: "first_100()".into(),
332 cardinality_hint: Some(100),
333 };
334 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(100)));
335 }
336
337 #[test]
338 fn continuous_interval_produces_continuous_class() {
339 let s = Source::ContinuousInterval {
340 interval: Interval::closed(0.0, 1.0),
341 measure: ProductMeasure::Uniform,
342 };
343 match s.cardinality() {
344 CardinalityClass::Continuous { intervals, measure } => {
345 assert_eq!(intervals.len(), 1);
346 assert!(matches!(measure, ProductMeasure::Uniform));
347 }
348 other => panic!("expected Continuous, got {other:?}"),
349 }
350 assert!(s.is_continuous());
351 assert!(!s.is_discrete());
352 }
353
354 #[test]
355 fn distribution_source_classification() {
356 let s = Source::Distribution {
357 distribution: MeasureName::Normal,
358 support: Interval {
359 lo: f64::NEG_INFINITY,
360 hi: f64::INFINITY,
361 lo_open: true,
362 hi_open: true,
363 },
364 params: vec![0.0, 1.0],
365 };
366 assert!(s.is_continuous());
367 match s.cardinality() {
368 CardinalityClass::Continuous {
369 measure: ProductMeasure::Named(MeasureName::Normal),
370 ..
371 } => {}
372 other => panic!("expected Continuous with Named(Normal), got {other:?}"),
373 }
374 }
375}