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 /// Resolved at clause construction; cardinality may be
54 /// `Unbounded` if the generator is open-ended.
55 Generator {
56 /// The generator call, as Polydat source.
57 expr: String,
58 /// How many values it yields, when known.
59 cardinality_hint: Option<u64>,
60 },
61
62 /// Reference to a workload-level parameter that resolves to
63 /// a list of values. Cardinality is the parameter's
64 /// declared list length.
65 WorkloadParamList {
66 /// The parameter's name.
67 name: String,
68 /// The list's length, when known.
69 len_hint: Option<u64>,
70 },
71
72 /// Real interval (continuous source). Combined with a
73 /// `measure` to form a `Continuous` cardinality.
74 /// Integrability is checked at parse via V8.
75 ContinuousInterval {
76 /// The interval.
77 interval: Interval,
78 /// The measure drawn from.
79 measure: ProductMeasure,
80 },
81
82 /// Named continuous distribution. The distribution carries
83 /// its own support; the `support` field records the
84 /// effective interval for V8's check.
85 Distribution {
86 /// The distribution.
87 distribution: MeasureName,
88 /// Its effective support.
89 support: Interval,
90 /// Its parameters, in the distribution's order.
91 params: Vec<f64>,
92 },
93}
94
95/// A literal value carried in a `Source::Literal`. Subset of
96/// the polydat `Value` type — the kinds clauses can directly
97/// bind. Extension to richer value types lives in the source
98/// evaluator, not the AST.
99///
100/// Serialized untagged because the variants are primitives;
101/// the JSON/YAML representation is just the bare value
102/// (`1` / `"x"` / `true` / `1.5`).
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(untagged)]
105pub enum LiteralValue {
106 /// An integer.
107 Int(i64),
108 /// A float.
109 Float(f64),
110 /// A string.
111 String(String),
112 /// A boolean.
113 Bool(bool),
114 /// A JSON value carrying its own kind: an item of a JSON list a
115 /// generator supplied at run time, bound where the element is
116 /// declared `json`. Last, so an untagged read tries the scalar
117 /// forms first.
118 Json(serde_json::Value),
119}
120
121impl Source {
122 /// Declare this source's cardinality class for use by
123 /// `clause` metadata propagation.
124 pub fn cardinality(&self) -> CardinalityClass {
125 match self {
126 Source::Literal { values } => CardinalityClass::Bounded(values.len() as u64),
127 Source::IntRange { lo, hi, step } => {
128 let step = (*step).max(1).unsigned_abs();
129 if hi <= lo {
130 CardinalityClass::Bounded(0)
131 } else {
132 let span = (hi - lo) as u64;
133 let n = span.div_ceil(step);
134 CardinalityClass::Bounded(n)
135 }
136 }
137 Source::Generator {
138 cardinality_hint, ..
139 } => match cardinality_hint {
140 Some(n) => CardinalityClass::Bounded(*n),
141 None => CardinalityClass::Unbounded,
142 },
143 Source::WorkloadParamList { len_hint, .. } => match len_hint {
144 Some(n) => CardinalityClass::Bounded(*n),
145 None => CardinalityClass::Unbounded,
146 },
147 Source::ContinuousInterval { interval, measure } => CardinalityClass::Continuous {
148 intervals: vec![interval.clone()],
149 measure: measure.clone(),
150 },
151 Source::Distribution { support, .. } => CardinalityClass::Continuous {
152 intervals: vec![support.clone()],
153 measure: ProductMeasure::Named(*self.distribution_name()),
154 },
155 }
156 }
157
158 /// `true` if this source is continuous (Continuous /
159 /// Distribution variants). Used by V7 (zip must be all
160 /// discrete) and V9 (union must be all discrete) without
161 /// a full cardinality computation.
162 pub fn is_continuous(&self) -> bool {
163 matches!(
164 self,
165 Source::ContinuousInterval { .. } | Source::Distribution { .. }
166 )
167 }
168
169 /// `true` if this source is discrete (every variant except
170 /// the continuous ones).
171 pub fn is_discrete(&self) -> bool {
172 !self.is_continuous()
173 }
174
175 fn distribution_name(&self) -> &MeasureName {
176 match self {
177 Source::Distribution { distribution, .. } => distribution,
178 _ => panic!("distribution_name called on non-Distribution source"),
179 }
180 }
181}
182
183// ── SRD-18f: iteration interior + string-comprehension striping ──
184
185/// The SRD-18f string-comprehension separator rule, in one place
186/// so the parse-time (`source_parser`) and runtime (`eval`)
187/// striping can never drift: split on runs of comma / semicolon /
188/// ASCII whitespace, trim, drop empties. Every other character
189/// (`:` `.` `-` `/` …) stays in the token. Returns the raw token
190/// substrings; callers type them (Value or LiteralValue).
191pub fn split_string_comprehension(s: &str) -> Vec<&str> {
192 s.split(|c: char| c == ',' || c == ';' || c.is_ascii_whitespace())
193 .map(str::trim)
194 .filter(|t| !t.is_empty())
195 .collect()
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn literal_cardinality_is_list_length() {
204 let s = Source::Literal {
205 values: vec![
206 LiteralValue::Int(1),
207 LiteralValue::Int(2),
208 LiteralValue::Int(3),
209 ],
210 };
211 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(3)));
212 }
213
214 #[test]
215 fn int_range_step_1() {
216 let s = Source::IntRange {
217 lo: 1,
218 hi: 10,
219 step: 1,
220 };
221 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(9)));
222 }
223
224 #[test]
225 fn int_range_with_step() {
226 let s = Source::IntRange {
227 lo: 0,
228 hi: 10,
229 step: 2,
230 };
231 // 0,2,4,6,8 = 5 values
232 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(5)));
233 }
234
235 #[test]
236 fn int_range_empty() {
237 let s = Source::IntRange {
238 lo: 5,
239 hi: 5,
240 step: 1,
241 };
242 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(0)));
243 }
244
245 #[test]
246 fn generator_without_hint_is_unbounded() {
247 let s = Source::Generator {
248 expr: "live_query()".into(),
249 cardinality_hint: None,
250 };
251 assert!(matches!(s.cardinality(), CardinalityClass::Unbounded));
252 }
253
254 #[test]
255 fn generator_with_hint_is_bounded() {
256 let s = Source::Generator {
257 expr: "first_100()".into(),
258 cardinality_hint: Some(100),
259 };
260 assert!(matches!(s.cardinality(), CardinalityClass::Bounded(100)));
261 }
262
263 #[test]
264 fn continuous_interval_produces_continuous_class() {
265 let s = Source::ContinuousInterval {
266 interval: Interval::closed(0.0, 1.0),
267 measure: ProductMeasure::Uniform,
268 };
269 match s.cardinality() {
270 CardinalityClass::Continuous { intervals, measure } => {
271 assert_eq!(intervals.len(), 1);
272 assert!(matches!(measure, ProductMeasure::Uniform));
273 }
274 other => panic!("expected Continuous, got {other:?}"),
275 }
276 assert!(s.is_continuous());
277 assert!(!s.is_discrete());
278 }
279
280 #[test]
281 fn distribution_source_classification() {
282 let s = Source::Distribution {
283 distribution: MeasureName::Normal,
284 support: Interval {
285 lo: f64::NEG_INFINITY,
286 hi: f64::INFINITY,
287 lo_open: true,
288 hi_open: true,
289 },
290 params: vec![0.0, 1.0],
291 };
292 assert!(s.is_continuous());
293 match s.cardinality() {
294 CardinalityClass::Continuous {
295 measure: ProductMeasure::Named(MeasureName::Normal),
296 ..
297 } => {}
298 other => panic!("expected Continuous with Named(Normal), got {other:?}"),
299 }
300 }
301}