1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use serde_json::Value;
5
6use crate::spec::{InputSpec, StepSpec};
7use crate::{ArtifactKind, Operation, OperationDescriptor, PipelineError, PipelineSpec, Registry};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub(crate) enum Source {
11 Input(String),
12 Step { step: String, output: String },
13}
14
15impl Source {
16 pub(crate) fn name(&self) -> String {
18 match self {
19 Self::Input(name) => name.clone(),
20 Self::Step { step, output } => format!("{step}.{output}"),
21 }
22 }
23}
24
25pub(crate) struct PlannedStep {
26 pub id: String,
27 pub op: Arc<dyn Operation>,
28 pub params: Value,
29 pub inputs: BTreeMap<String, Source>,
30 pub output_kinds: BTreeMap<String, ArtifactKind>,
32 pub keep: bool,
34 bindings: Vec<(crate::Port, String)>,
36}
37
38pub struct Plan {
41 pub(crate) name: String,
42 pub(crate) inputs: Vec<String>,
43 pub(crate) input_kinds: BTreeMap<String, ArtifactKind>,
44 pub(crate) steps: Vec<PlannedStep>,
45}
46
47impl Plan {
48 pub fn new(spec: &PipelineSpec, registry: &Registry) -> Result<Self, PipelineError> {
49 check_names(spec)?;
50 let ops = spec
51 .steps
52 .iter()
53 .map(|s| {
54 let op = registry
55 .get(&s.op)
56 .ok_or_else(|| PipelineError::UnknownOperation {
57 step: s.id.clone(),
58 op: s.op.clone(),
59 })?;
60 Ok((s.id.as_str(), op))
61 })
62 .collect::<Result<BTreeMap<_, _>, PipelineError>>()?;
63
64 let mut planned = Vec::with_capacity(spec.steps.len());
65 for step in &spec.steps {
66 let op = Arc::clone(&ops[step.id.as_str()]);
67 let descriptor = op.descriptor();
68 let params = op
69 .resolve(Value::Object(step.params.clone()))
70 .map_err(|message| PipelineError::InvalidParams {
71 step: step.id.clone(),
72 message,
73 })?;
74 let bindings = bind_ports(step, &descriptor)?;
75 let mut inputs = BTreeMap::new();
76 for (port, reference) in &bindings {
77 inputs.insert(
78 port.name.to_string(),
79 resolve_reference(spec, &ops, &step.id, reference)?,
80 );
81 }
82 planned.push(PlannedStep {
83 id: step.id.clone(),
84 op,
85 params,
86 inputs,
87 output_kinds: BTreeMap::new(),
88 keep: step.keep,
89 bindings,
90 });
91 }
92 let mut steps = in_dependency_order(planned)?;
93 check_kinds(spec, &mut steps)?;
94 if let Some(lost) = steps.iter().find(|s| {
96 !s.keep
97 && !steps.iter().any(|c| {
98 c.inputs
99 .values()
100 .any(|src| matches!(src, Source::Step { step, .. } if *step == s.id))
101 })
102 }) {
103 return Err(PipelineError::UnusedTransient(lost.id.clone()));
104 }
105
106 let declared = spec.pipeline.inputs.kinds();
107 Ok(Self {
108 name: spec.pipeline.name.clone(),
109 inputs: declared.iter().map(|(n, _)| n.clone()).collect(),
110 input_kinds: declared.into_iter().collect(),
111 steps,
112 })
113 }
114
115 pub fn name(&self) -> &str {
116 &self.name
117 }
118
119 pub fn inputs(&self) -> &[String] {
120 &self.inputs
121 }
122
123 pub fn input_kind(&self, name: &str) -> Option<ArtifactKind> {
125 self.input_kinds.get(name).copied()
126 }
127
128 pub fn step_ids(&self) -> impl Iterator<Item = &str> {
130 self.steps.iter().map(|s| s.id.as_str())
131 }
132}
133
134fn check_names(spec: &PipelineSpec) -> Result<(), PipelineError> {
135 let mut seen = BTreeSet::new();
136 let inputs = spec.pipeline.inputs.kinds();
137 for (name, kind) in &inputs {
138 if !matches!(
139 kind,
140 ArtifactKind::Series | ArtifactKind::Events | ArtifactKind::Channels
141 ) {
142 return Err(PipelineError::Spec(format!(
143 "input {name:?}: only series, events and channels can be pipeline inputs, not {kind}"
144 )));
145 }
146 }
147 let names = inputs
148 .iter()
149 .map(|(n, _)| n)
150 .chain(spec.steps.iter().map(|s| &s.id));
151 for name in names {
152 if name.is_empty() || name.contains('.') {
153 return Err(PipelineError::InvalidStepId(name.clone()));
154 }
155 if !seen.insert(name) {
156 return Err(PipelineError::DuplicateName(name.clone()));
157 }
158 }
159 Ok(())
160}
161
162fn bind_ports(
163 step: &StepSpec,
164 descriptor: &OperationDescriptor,
165) -> Result<Vec<(crate::Port, String)>, PipelineError> {
166 let invalid = |message: String| PipelineError::InvalidInputs {
167 step: step.id.clone(),
168 message,
169 };
170 let port_names = || {
171 descriptor
172 .inputs
173 .iter()
174 .map(|p| p.name)
175 .collect::<Vec<_>>()
176 .join(", ")
177 };
178 match &step.input {
179 InputSpec::Single(reference) => match descriptor.inputs {
180 [port] => Ok(vec![(*port, reference.clone())]),
181 _ => Err(invalid(format!(
182 "operation {:?} takes inputs [{}]; name each one in a table",
183 descriptor.name,
184 port_names()
185 ))),
186 },
187 InputSpec::Named(map) => {
188 if let Some(unknown) = map
189 .keys()
190 .find(|k| !descriptor.inputs.iter().any(|p| p.name == *k))
191 {
192 return Err(invalid(format!(
193 "operation {:?} has no input {unknown:?}; it takes [{}]",
194 descriptor.name,
195 port_names()
196 )));
197 }
198 descriptor
199 .inputs
200 .iter()
201 .map(|port| {
202 map.get(port.name)
203 .map(|r| (*port, r.clone()))
204 .ok_or_else(|| invalid(format!("input {:?} is not connected", port.name)))
205 })
206 .collect()
207 }
208 }
209}
210
211fn resolve_reference(
212 spec: &PipelineSpec,
213 ops: &BTreeMap<&str, Arc<dyn Operation>>,
214 step: &str,
215 reference: &str,
216) -> Result<Source, PipelineError> {
217 if spec
218 .pipeline
219 .inputs
220 .kinds()
221 .iter()
222 .any(|(n, _)| n == reference)
223 {
224 return Ok(Source::Input(reference.to_string()));
225 }
226 let unknown = || PipelineError::UnknownReference {
227 step: step.to_string(),
228 reference: reference.to_string(),
229 };
230 let (target, output) = match reference.split_once('.') {
231 Some((target, output)) => (target, Some(output)),
232 None => (reference, None),
233 };
234 let outputs = ops.get(target).ok_or_else(unknown)?.descriptor().outputs;
235 let port = match (output, outputs) {
236 (Some(name), _) => outputs
237 .iter()
238 .find(|p| p.name == name)
239 .ok_or_else(unknown)?,
240 (None, [only]) => only,
241 (None, _) => {
242 let names: Vec<_> = outputs
243 .iter()
244 .map(|p| format!("{target}.{}", p.name))
245 .collect();
246 return Err(PipelineError::InvalidInputs {
247 step: step.to_string(),
248 message: format!("{reference:?} has several outputs; pick one of {names:?}"),
249 });
250 }
251 };
252 Ok(Source::Step {
253 step: target.to_string(),
254 output: port.name.to_string(),
255 })
256}
257
258fn check_kinds(spec: &PipelineSpec, steps: &mut [PlannedStep]) -> Result<(), PipelineError> {
261 let declared: BTreeMap<String, ArtifactKind> =
262 spec.pipeline.inputs.kinds().into_iter().collect();
263 let mut produced: BTreeMap<String, ArtifactKind> = BTreeMap::new();
264 for step in steps.iter_mut() {
265 let mut bound = BTreeMap::new();
266 for (port, reference) in &step.bindings {
267 let source = &step.inputs[port.name];
268 let found = match source {
269 Source::Input(name) => declared[name],
270 Source::Step { .. } => produced[&source.name()],
271 };
272 if !port.kinds.contains(&found) {
273 return Err(PipelineError::KindMismatch {
274 step: step.id.clone(),
275 port: port.name.to_string(),
276 reference: reference.clone(),
277 expected: port.kinds.to_vec(),
278 found,
279 });
280 }
281 bound.insert(port.name.to_string(), found);
282 }
283 for port in step.op.descriptor().outputs {
284 let kind = step.op.output_kind(port, &bound);
285 step.output_kinds.insert(port.name.to_string(), kind);
286 produced.insert(format!("{}.{}", step.id, port.name), kind);
287 }
288 }
289 Ok(())
290}
291
292fn in_dependency_order(mut pending: Vec<PlannedStep>) -> Result<Vec<PlannedStep>, PipelineError> {
293 let mut done = BTreeSet::new();
294 let mut ordered = Vec::with_capacity(pending.len());
295 while !pending.is_empty() {
296 let ready = pending.iter().position(|s| {
297 s.inputs.values().all(|src| match src {
298 Source::Input(_) => true,
299 Source::Step { step, .. } => done.contains(step),
300 })
301 });
302 match ready {
303 Some(i) => {
304 let step = pending.remove(i);
305 done.insert(step.id.clone());
306 ordered.push(step);
307 }
308 None => {
309 return Err(PipelineError::Cycle(
310 pending.into_iter().map(|s| s.id).collect(),
311 ));
312 }
313 }
314 }
315 Ok(ordered)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::test_support::ops;
322
323 fn plan(toml: &str) -> Result<Plan, PipelineError> {
324 let text = format!("[pipeline]\nname = \"t\"\ninputs = [\"x\"]\n{toml}");
325 Plan::new(&PipelineSpec::from_toml(&text)?, &ops().registry)
326 }
327
328 #[test]
329 fn orders_steps_by_dependency_not_by_listing() {
330 let p = plan(
331 r#"
332 [[step]]
333 id = "b"
334 op = "scale"
335 input = "a"
336 [[step]]
337 id = "a"
338 op = "scale"
339 input = "x"
340 "#,
341 )
342 .unwrap();
343 assert_eq!(p.step_ids().collect::<Vec<_>>(), ["a", "b"]);
344 }
345
346 #[test]
347 fn fills_parameter_defaults() {
348 let p = plan("[[step]]\nid = \"a\"\nop = \"scale\"\ninput = \"x\"\n").unwrap();
349 assert_eq!(p.steps[0].params["factor"], 1.0);
350 }
351
352 #[test]
353 fn rejects_cycles() {
354 let err = plan(
355 r#"
356 [[step]]
357 id = "a"
358 op = "scale"
359 input = "b"
360 [[step]]
361 id = "b"
362 op = "scale"
363 input = "a"
364 "#,
365 )
366 .err()
367 .unwrap();
368 assert!(matches!(err, PipelineError::Cycle(ids) if ids == ["a", "b"]));
369 }
370
371 #[test]
372 fn rejects_invalid_specs() {
373 let cases = [
374 (
375 "duplicate id",
376 "[[step]]\nid = \"x\"\nop = \"scale\"\ninput = \"x\"\n",
377 ),
378 (
379 "dot in id",
380 "[[step]]\nid = \"a.b\"\nop = \"scale\"\ninput = \"x\"\n",
381 ),
382 (
383 "unknown op",
384 "[[step]]\nid = \"a\"\nop = \"nope\"\ninput = \"x\"\n",
385 ),
386 (
387 "unknown param",
388 "[[step]]\nid = \"a\"\nop = \"scale\"\ninput = \"x\"\nparams = { factr = 2 }\n",
389 ),
390 (
391 "unknown reference",
392 "[[step]]\nid = \"a\"\nop = \"scale\"\ninput = \"y\"\n",
393 ),
394 (
395 "kind mismatch",
396 "[[step]]\nid = \"a\"\nop = \"needs_modes\"\ninput = \"x\"\n",
397 ),
398 (
399 "single input to pair",
400 "[[step]]\nid = \"a\"\nop = \"pair\"\ninput = \"x\"\n",
401 ),
402 (
403 "unconnected port",
404 "[[step]]\nid = \"a\"\nop = \"pair\"\ninput = { left = \"x\" }\n",
405 ),
406 (
407 "unknown port",
408 "[[step]]\nid = \"a\"\nop = \"scale\"\ninput = { sig = \"x\" }\n",
409 ),
410 ("no input at all", "[[step]]\nid = \"a\"\nop = \"scale\"\n"),
411 (
412 "extra port beside a connected one",
413 "[[step]]\nid = \"a\"\nop = \"scale\"\ninput = { signal = \"x\", extra = \"x\" }\n",
414 ),
415 (
416 "ambiguous output",
417 "[[step]]\nid = \"s\"\nop = \"split\"\ninput = \"x\"\n[[step]]\nid = \"a\"\nop = \"scale\"\ninput = \"s\"\n",
418 ),
419 ];
420 for (case, toml) in cases {
421 assert!(plan(toml).is_err(), "accepted: {case}");
422 }
423 }
424
425 #[test]
426 fn wires_the_named_output_of_a_multi_output_step() {
427 for output in ["low", "high"] {
428 let p = plan(&format!(
429 "[[step]]\nid = \"s\"\nop = \"split\"\ninput = \"x\"\n[[step]]\nid = \"a\"\nop = \"scale\"\ninput = \"s.{output}\"\n"
430 ))
431 .unwrap();
432 let consumer = p.steps.iter().find(|s| s.id == "a").unwrap();
433 assert_eq!(
434 consumer.inputs["signal"],
435 Source::Step {
436 step: "s".into(),
437 output: output.into()
438 }
439 );
440 }
441 }
442
443 #[test]
444 fn accepts_every_port_named() {
445 let p =
446 plan("[[step]]\nid = \"a\"\nop = \"pair\"\ninput = { left = \"x\", right = \"x\" }\n")
447 .unwrap();
448 assert_eq!(p.steps[0].inputs.len(), 2);
449 let single = plan("[[step]]\nid = \"a\"\nop = \"scale\"\ninput = { signal = \"x\" }\n");
450 assert!(single.is_ok());
451 }
452
453 #[test]
454 fn exposes_name_and_inputs() {
455 let p = plan("").unwrap();
456 assert_eq!((p.name(), p.inputs()), ("t", ["x".to_string()].as_slice()));
457 }
458
459 mod properties {
460 use proptest::prelude::*;
461
462 use super::*;
463
464 proptest! {
465 #[test]
467 fn steps_always_run_after_their_inputs(
468 sources in prop::collection::vec(any::<prop::sample::Index>(), 1..12),
469 shuffle_seed in any::<u64>(),
470 ) {
471 let n = sources.len();
472 let source = |i: usize| match sources[i].index(i + 1) {
474 0 => "x".to_string(),
475 j => format!("s{}", j - 1),
476 };
477 let mut order: Vec<usize> = (0..n).collect();
478 let mut state = shuffle_seed;
479 for i in (1..n).rev() {
480 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
481 order.swap(i, (state >> 33) as usize % (i + 1));
482 }
483 let steps: String = order
484 .iter()
485 .map(|&i| format!("[[step]]\nid = \"s{i}\"\nop = \"scale\"\ninput = \"{}\"\n", source(i)))
486 .collect();
487 let p = plan(&steps).unwrap();
488 let position: BTreeMap<&str, usize> = p.step_ids().enumerate().map(|(k, id)| (id, k)).collect();
489 for i in 0..n {
490 let src = source(i);
491 if src != "x" {
492 let step = format!("s{i}");
493 prop_assert!(position[src.as_str()] < position[step.as_str()], "{} must run before {}", src, step);
494 }
495 }
496 }
497 }
498 }
499}