Skip to main content

polydat_grammar/comprehension/
source.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Clause source values — spec §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//! per spec §3.1 + §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 (spec §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    /// A float.
118    Float(f64),
119    /// A string.
120    String(String),
121    /// A boolean.
122    Bool(bool),
123    /// A JSON value carrying its own kind: an item of a JSON list a
124    /// generator supplied at run time, bound where the element is
125    /// declared `json`. Last, so an untagged read tries the scalar
126    /// forms first.
127    Json(serde_json::Value),
128}
129
130impl Source {
131    /// The names this source references (spec §10.7.0): a generator
132    /// expression's parsed free identifiers (`concat(foo)`) and its
133    /// `{name}` interpolation placeholders, and a workload parameter
134    /// list's own name. Literals, ranges, and intervals reference
135    /// nothing. A source with no references is context-free.
136    pub fn referenced_names(&self) -> std::collections::BTreeSet<String> {
137        let mut out = std::collections::BTreeSet::new();
138        match self {
139            Source::WorkloadParamList { name, .. } => {
140                out.insert(name.clone());
141            }
142            Source::Generator { expr, .. } => {
143                out.extend(crate::refs::referenced_names(expr));
144                crate::refs::collect_string_interpolation_refs(expr, &mut out);
145            }
146            Source::Literal { .. }
147            | Source::IntRange { .. }
148            | Source::ContinuousInterval { .. }
149            | Source::Distribution { .. } => {}
150        }
151        out
152    }
153
154    /// Declare this source's cardinality class for use by
155    /// `clause` metadata propagation.
156    pub fn cardinality(&self) -> CardinalityClass {
157        match self {
158            Source::Literal { values } => CardinalityClass::Bounded(values.len() as u64),
159            Source::IntRange { lo, hi, step } => {
160                let step = (*step).max(1).unsigned_abs();
161                if hi <= lo {
162                    CardinalityClass::Bounded(0)
163                } else {
164                    let span = (hi - lo) as u64;
165                    let n = span.div_ceil(step);
166                    CardinalityClass::Bounded(n)
167                }
168            }
169            Source::Generator {
170                cardinality_hint, ..
171            } => match cardinality_hint {
172                Some(n) => CardinalityClass::Bounded(*n),
173                None => CardinalityClass::Unbounded,
174            },
175            Source::WorkloadParamList { len_hint, .. } => match len_hint {
176                Some(n) => CardinalityClass::Bounded(*n),
177                None => CardinalityClass::Unbounded,
178            },
179            Source::ContinuousInterval { interval, measure } => CardinalityClass::Continuous {
180                intervals: vec![interval.clone()],
181                measure: measure.clone(),
182            },
183            Source::Distribution { support, .. } => CardinalityClass::Continuous {
184                intervals: vec![support.clone()],
185                measure: ProductMeasure::Named(*self.distribution_name()),
186            },
187        }
188    }
189
190    /// `true` if this source is continuous (Continuous /
191    /// Distribution variants). Used by V7 (zip must be all
192    /// discrete) and V9 (union must be all discrete) without
193    /// a full cardinality computation.
194    pub fn is_continuous(&self) -> bool {
195        matches!(
196            self,
197            Source::ContinuousInterval { .. } | Source::Distribution { .. }
198        )
199    }
200
201    /// `true` if this source is discrete (every variant except
202    /// the continuous ones).
203    pub fn is_discrete(&self) -> bool {
204        !self.is_continuous()
205    }
206
207    fn distribution_name(&self) -> &MeasureName {
208        match self {
209            Source::Distribution { distribution, .. } => distribution,
210            _ => panic!("distribution_name called on non-Distribution source"),
211        }
212    }
213}
214
215// ── SRD-18f: iteration interior + string-comprehension striping ──
216
217/// The SRD-18f string-comprehension separator rule, in one place
218/// so the parse-time (`source_parser`) and runtime (`eval`)
219/// striping can never drift: split on runs of comma / semicolon /
220/// ASCII whitespace, trim, drop empties. Every other character
221/// (`:` `.` `-` `/` …) stays in the token. Returns the raw token
222/// substrings; callers type them (Value or LiteralValue).
223pub fn split_string_comprehension(s: &str) -> Vec<&str> {
224    s.split(|c: char| c == ',' || c == ';' || c.is_ascii_whitespace())
225        .map(str::trim)
226        .filter(|t| !t.is_empty())
227        .collect()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn literal_cardinality_is_list_length() {
236        let s = Source::Literal {
237            values: vec![
238                LiteralValue::Int(1),
239                LiteralValue::Int(2),
240                LiteralValue::Int(3),
241            ],
242        };
243        assert!(matches!(s.cardinality(), CardinalityClass::Bounded(3)));
244    }
245
246    #[test]
247    fn int_range_step_1() {
248        let s = Source::IntRange {
249            lo: 1,
250            hi: 10,
251            step: 1,
252        };
253        assert!(matches!(s.cardinality(), CardinalityClass::Bounded(9)));
254    }
255
256    #[test]
257    fn int_range_with_step() {
258        let s = Source::IntRange {
259            lo: 0,
260            hi: 10,
261            step: 2,
262        };
263        // 0,2,4,6,8 = 5 values
264        assert!(matches!(s.cardinality(), CardinalityClass::Bounded(5)));
265    }
266
267    #[test]
268    fn int_range_empty() {
269        let s = Source::IntRange {
270            lo: 5,
271            hi: 5,
272            step: 1,
273        };
274        assert!(matches!(s.cardinality(), CardinalityClass::Bounded(0)));
275    }
276
277    #[test]
278    fn generator_without_hint_is_unbounded() {
279        let s = Source::Generator {
280            expr: "live_query()".into(),
281            cardinality_hint: None,
282        };
283        assert!(matches!(s.cardinality(), CardinalityClass::Unbounded));
284    }
285
286    #[test]
287    fn generator_with_hint_is_bounded() {
288        let s = Source::Generator {
289            expr: "first_100()".into(),
290            cardinality_hint: Some(100),
291        };
292        assert!(matches!(s.cardinality(), CardinalityClass::Bounded(100)));
293    }
294
295    #[test]
296    fn continuous_interval_produces_continuous_class() {
297        let s = Source::ContinuousInterval {
298            interval: Interval::closed(0.0, 1.0),
299            measure: ProductMeasure::Uniform,
300        };
301        match s.cardinality() {
302            CardinalityClass::Continuous { intervals, measure } => {
303                assert_eq!(intervals.len(), 1);
304                assert!(matches!(measure, ProductMeasure::Uniform));
305            }
306            other => panic!("expected Continuous, got {other:?}"),
307        }
308        assert!(s.is_continuous());
309        assert!(!s.is_discrete());
310    }
311
312    #[test]
313    fn distribution_source_classification() {
314        let s = Source::Distribution {
315            distribution: MeasureName::Normal,
316            support: Interval {
317                lo: f64::NEG_INFINITY,
318                hi: f64::INFINITY,
319                lo_open: true,
320                hi_open: true,
321            },
322            params: vec![0.0, 1.0],
323        };
324        assert!(s.is_continuous());
325        match s.cardinality() {
326            CardinalityClass::Continuous {
327                measure: ProductMeasure::Named(MeasureName::Normal),
328                ..
329            } => {}
330            other => panic!("expected Continuous with Named(Normal), got {other:?}"),
331        }
332    }
333}