zeph_core/pipeline/
mod.rs1pub mod builder;
5pub mod builtin;
6pub mod parallel;
7pub mod step;
8
9pub use builder::Pipeline;
10pub use parallel::ParallelStep;
11pub use step::Step;
12
13#[non_exhaustive]
14#[derive(Debug, thiserror::Error)]
15pub enum PipelineError {
16 #[error(transparent)]
17 Llm(#[from] zeph_llm::LlmError),
18
19 #[error(transparent)]
20 Memory(#[from] zeph_memory::MemoryError),
21
22 #[error("extraction failed: {0}")]
23 Extract(String),
24
25 #[error("{0}")]
26 Custom(String),
27}
28
29#[cfg(test)]
30mod tests {
31 #![allow(clippy::ignored_unit_patterns, clippy::manual_string_new)]
32 use std::assert_matches;
33
34 use std::sync::Arc;
35
36 use super::builtin::{ExtractStep, LlmStep, MapStep, RetrievalStep};
37 use super::parallel::parallel;
38 use super::*;
39 use zeph_llm::mock::MockProvider;
40 use zeph_memory::in_memory_store::InMemoryVectorStore;
41 use zeph_memory::vector_store::{VectorPoint, VectorStore};
42
43 struct AddSuffix {
44 suffix: String,
45 }
46
47 impl Step for AddSuffix {
48 type Input = String;
49 type Output = String;
50
51 async fn run(&self, input: Self::Input) -> Result<Self::Output, PipelineError> {
52 Ok(format!("{input}{}", self.suffix))
53 }
54 }
55
56 struct ParseLen;
57
58 impl Step for ParseLen {
59 type Input = String;
60 type Output = usize;
61
62 async fn run(&self, input: Self::Input) -> Result<Self::Output, PipelineError> {
63 Ok(input.len())
64 }
65 }
66
67 #[tokio::test]
68 async fn single_step_pipeline() {
69 let result = Pipeline::start(AddSuffix { suffix: "!".into() })
70 .run("hello".into())
71 .await
72 .unwrap();
73 assert_eq!(result, "hello!");
74 }
75
76 #[tokio::test]
77 async fn chained_pipeline() {
78 let result = Pipeline::start(AddSuffix {
79 suffix: " world".into(),
80 })
81 .step(AddSuffix { suffix: "!".into() })
82 .run("hello".into())
83 .await
84 .unwrap();
85 assert_eq!(result, "hello world!");
86 }
87
88 #[tokio::test]
89 async fn heterogeneous_chain() {
90 let result = Pipeline::start(AddSuffix {
91 suffix: "abc".into(),
92 })
93 .step(ParseLen)
94 .run(String::new())
95 .await
96 .unwrap();
97 assert_eq!(result, 3);
98 }
99
100 #[tokio::test]
101 async fn map_step() {
102 let result = Pipeline::start(MapStep::new(|s: String| s.to_uppercase()))
103 .run("hello".into())
104 .await
105 .unwrap();
106 assert_eq!(result, "HELLO");
107 }
108
109 #[tokio::test]
110 async fn parallel_step() {
111 let step = parallel(
112 AddSuffix {
113 suffix: "_a".into(),
114 },
115 AddSuffix {
116 suffix: "_b".into(),
117 },
118 );
119 let result = Pipeline::start(step).run("x".into()).await.unwrap();
120 assert_eq!(result, ("x_a".into(), "x_b".into()));
121 }
122
123 #[tokio::test]
124 async fn error_propagation() {
125 struct FailStep;
126
127 impl Step for FailStep {
128 type Input = String;
129 type Output = String;
130
131 async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
132 Err(PipelineError::Custom("boom".into()))
133 }
134 }
135
136 let result = Pipeline::start(AddSuffix {
137 suffix: "ok".into(),
138 })
139 .step(FailStep)
140 .run("hi".into())
141 .await;
142 assert!(result.is_err());
143 assert!(result.unwrap_err().to_string().contains("boom"));
144 }
145
146 #[tokio::test]
147 async fn extract_step() {
148 use super::builtin::ExtractStep;
149
150 let result = Pipeline::start(MapStep::new(|(): ()| r#"{"a":1,"b":"two"}"#.to_owned()))
151 .step(ExtractStep::<serde_json::Value>::new())
152 .run(())
153 .await
154 .unwrap();
155 assert_eq!(result["a"], 1);
156 assert_eq!(result["b"], "two");
157 }
158
159 #[tokio::test]
162 async fn llm_step_returns_response() {
163 let provider = Arc::new(MockProvider::with_responses(vec!["answer".into()]));
164 let result = Pipeline::start(LlmStep::new(provider))
165 .run("question".into())
166 .await
167 .unwrap();
168 assert_eq!(result, "answer");
169 }
170
171 #[tokio::test]
172 async fn llm_step_with_system_prompt() {
173 let provider = Arc::new(MockProvider::with_responses(vec!["ok".into()]));
174 let result = Pipeline::start(LlmStep::new(provider).with_system_prompt("sys"))
175 .run("input".into())
176 .await
177 .unwrap();
178 assert_eq!(result, "ok");
179 }
180
181 #[tokio::test]
182 async fn llm_step_propagates_error() {
183 let provider = Arc::new(MockProvider::failing());
184 let result = Pipeline::start(LlmStep::new(provider))
185 .run("input".into())
186 .await;
187 assert!(result.is_err());
188 assert!(
189 matches!(result.unwrap_err(), PipelineError::Llm(_)),
190 "expected PipelineError::Llm"
191 );
192 }
193
194 #[tokio::test]
197 async fn retrieval_step_returns_results() {
198 let store = Arc::new(InMemoryVectorStore::new());
199 store.ensure_collection("col", 3).await.unwrap();
200 store
201 .upsert(
202 "col",
203 vec![VectorPoint {
204 id: "p1".into(),
205 vector: vec![1.0, 0.0, 0.0],
206 payload: std::collections::HashMap::new(),
207 }],
208 )
209 .await
210 .unwrap();
211
212 let mut provider = MockProvider::default();
213 provider.supports_embeddings = true;
214 provider.embedding = vec![1.0, 0.0, 0.0];
215 let provider = Arc::new(provider);
216
217 let step = RetrievalStep::new(store, provider, "col", 5);
218 let results = Pipeline::start(step).run("query".into()).await.unwrap();
219 assert_eq!(results.len(), 1);
220 assert_eq!(results[0].id, "p1");
221 }
222
223 #[tokio::test]
224 async fn retrieval_step_embed_error_propagates() {
225 let store = Arc::new(InMemoryVectorStore::new());
226 store.ensure_collection("col", 3).await.unwrap();
227
228 let provider = Arc::new(MockProvider::default());
229
230 let step = RetrievalStep::new(store, provider, "col", 5);
231 let result = Pipeline::start(step).run("query".into()).await;
232 assert_matches!(result.unwrap_err(), PipelineError::Llm(_));
233 }
234
235 #[tokio::test]
238 async fn extract_step_invalid_json() {
239 let result = Pipeline::start(MapStep::new(|(): ()| "not json".to_owned()))
240 .step(ExtractStep::<serde_json::Value>::new())
241 .run(())
242 .await;
243 assert_matches!(result.unwrap_err(), PipelineError::Extract(_));
244 }
245
246 #[tokio::test]
247 async fn extract_step_type_mismatch() {
248 #[derive(Debug, serde::Deserialize)]
249 struct Strict {
250 #[expect(dead_code)]
251 required_field: Vec<u32>,
252 }
253
254 let result = Pipeline::start(MapStep::new(|(): ()| r#"{"a":1}"#.to_owned()))
255 .step(ExtractStep::<Strict>::new())
256 .run(())
257 .await;
258 assert_matches!(result.unwrap_err(), PipelineError::Extract(_));
259 }
260
261 #[tokio::test]
264 async fn parallel_step_first_fails() {
265 struct FailStep;
266 impl Step for FailStep {
267 type Input = String;
268 type Output = String;
269 async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
270 Err(PipelineError::Custom("fail_a".into()))
271 }
272 }
273
274 let step = parallel(
275 FailStep,
276 AddSuffix {
277 suffix: "_ok".into(),
278 },
279 );
280 let result = Pipeline::start(step).run("x".into()).await;
281 assert!(result.is_err());
282 }
283
284 #[tokio::test]
285 async fn parallel_step_both_fail() {
286 struct FailA;
287 impl Step for FailA {
288 type Input = String;
289 type Output = String;
290 async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
291 Err(PipelineError::Custom("fail_a".into()))
292 }
293 }
294 struct FailB;
295 impl Step for FailB {
296 type Input = String;
297 type Output = String;
298 async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
299 Err(PipelineError::Custom("fail_b".into()))
300 }
301 }
302
303 let step = parallel(FailA, FailB);
304 let result = Pipeline::start(step).run("x".into()).await;
305 assert!(result.is_err());
306 }
307}