modewise_pipeline/
plan.rs

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    /// Name under which the executor tracks this artifact.
17    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    /// The kind each output will have, decided when the plan is checked.
31    pub output_kinds: BTreeMap<String, ArtifactKind>,
32    /// Whether the outputs go to the store (`keep` in the spec).
33    pub keep: bool,
34    /// Ports with the references bound to them, kept for checking kinds in dependency order.
35    bindings: Vec<(crate::Port, String)>,
36}
37
38/// A checked pipeline: operations exist, parameters are valid, references resolve,
39/// kinds match, and steps are in dependency order.
40pub 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        // An output that is neither stored nor used would be computed for nothing.
95        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    /// The kind of data a pipeline input expects.
124    pub fn input_kind(&self, name: &str) -> Option<ArtifactKind> {
125        self.input_kinds.get(name).copied()
126    }
127
128    /// Step ids in the order they run.
129    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
258/// Checks that every input gets a kind its port accepts, and decides each step's output kinds.
259/// Steps are in dependency order, so the kinds of their inputs are already known.
260fn 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            /// Each step reads either the pipeline input or an earlier step; listing order is shuffled.
466            #[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                // Step i reads the pipeline input or one of the steps 0..i.
473                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}