Skip to main content

rune_chain_parallel/
lib.rs

1//! `ParallelChain` — run multiple chains concurrently and merge their outputs.
2//!
3//! All registered steps execute simultaneously using `tokio::spawn`. Their text
4//! outputs are merged into the shared variable map keyed by the step's
5//! `output_key`, making them available to downstream chains.
6//!
7//! Token usage across all branches is summed.
8//!
9//! # Quick Start
10//!
11//! ```rust,no_run
12//! use rune_chain_parallel::ParallelChain;
13//! use rune_chain_core::{Chain, ChainError, GenerateResult, PromptArgs, prompt_args};
14//! use async_trait::async_trait;
15//!
16//! struct UpperCase;
17//! #[async_trait]
18//! impl Chain for UpperCase {
19//!     async fn call(&self, input: PromptArgs) -> Result<GenerateResult, ChainError> {
20//!         let text = input.get("input").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
21//!         Ok(GenerateResult::from_text(text))
22//!     }
23//! }
24//!
25//! struct LowerCase;
26//! #[async_trait]
27//! impl Chain for LowerCase {
28//!     async fn call(&self, input: PromptArgs) -> Result<GenerateResult, ChainError> {
29//!         let text = input.get("input").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
30//!         Ok(GenerateResult::from_text(text))
31//!     }
32//! }
33//!
34//! async fn run() {
35//!     let par = ParallelChain::new()
36//!         .branch(UpperCase, "upper")
37//!         .branch(LowerCase, "lower");
38//!
39//!     let out = par.execute(prompt_args! { "input" => "Hello" }).await.unwrap();
40//!     assert_eq!(out["upper"].as_str().unwrap(), "HELLO");
41//!     assert_eq!(out["lower"].as_str().unwrap(), "hello");
42//! }
43//! ```
44
45use std::collections::HashMap;
46use std::sync::Arc;
47
48use async_trait::async_trait;
49use futures::future::join_all;
50use rune_chain_core::{Chain, ChainError, GenerateResult, PromptArgs, TokenUsage};
51use serde_json::{Value, json};
52
53struct Branch {
54    chain: Arc<dyn Chain>,
55    output_key: String,
56}
57
58/// A fan-out chain that runs multiple sub-chains concurrently and merges outputs.
59///
60/// Each branch receives the same input and runs in parallel via `tokio`. Their
61/// outputs are stored under distinct keys in the result map.
62pub struct ParallelChain {
63    branches: Vec<Branch>,
64}
65
66impl ParallelChain {
67    /// Create an empty parallel chain.
68    pub fn new() -> Self {
69        Self {
70            branches: Vec::new(),
71        }
72    }
73
74    /// Add a branch: run `chain` in parallel and store its output under `output_key`.
75    ///
76    /// # Example
77    ///
78    /// ```rust
79    /// use rune_chain_parallel::ParallelChain;
80    /// use rune_chain_core::{Chain, ChainError, GenerateResult, PromptArgs};
81    /// use async_trait::async_trait;
82    ///
83    /// struct Noop;
84    /// #[async_trait]
85    /// impl Chain for Noop {
86    ///     async fn call(&self, _: PromptArgs) -> Result<GenerateResult, ChainError> {
87    ///         Ok(GenerateResult::from_text(""))
88    ///     }
89    /// }
90    ///
91    /// let par = ParallelChain::new().branch(Noop, "a").branch(Noop, "b");
92    /// assert_eq!(par.output_keys(), vec!["a", "b", "generate_result"]);
93    /// ```
94    pub fn branch(mut self, chain: impl Chain + 'static, output_key: impl Into<String>) -> Self {
95        self.branches.push(Branch {
96            chain: Arc::new(chain),
97            output_key: output_key.into(),
98        });
99        self
100    }
101}
102
103impl Default for ParallelChain {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109#[async_trait]
110impl Chain for ParallelChain {
111    async fn call(&self, input: PromptArgs) -> Result<GenerateResult, ChainError> {
112        let map = self.execute(input).await?;
113        let result_value = map
114            .get("generate_result")
115            .cloned()
116            .unwrap_or(json!(GenerateResult::default()));
117        serde_json::from_value(result_value)
118            .map_err(|e| ChainError::Other(format!("result deserialisation failed: {e}")))
119    }
120
121    async fn execute(&self, input: PromptArgs) -> Result<HashMap<String, Value>, ChainError> {
122        let futures: Vec<_> = self
123            .branches
124            .iter()
125            .map(|b| {
126                let chain = Arc::clone(&b.chain);
127                let input_clone = input.clone();
128                let key = b.output_key.clone();
129                async move {
130                    let result = chain.call(input_clone).await;
131                    (key, result)
132                }
133            })
134            .collect();
135
136        let results = join_all(futures).await;
137
138        let mut accumulated: HashMap<String, Value> = HashMap::new();
139        let mut total_tokens: Option<TokenUsage> = None;
140        let mut last_generation = String::new();
141
142        for (key, result) in results {
143            let r = result?;
144            last_generation = r.generation.clone();
145            if let Some(usage) = &r.tokens {
146                total_tokens = Some(match total_tokens.take() {
147                    None => usage.clone(),
148                    Some(prev) => prev.combine(usage),
149                });
150            }
151            accumulated.insert(key, json!(r.generation));
152        }
153
154        let final_result = GenerateResult {
155            generation: last_generation,
156            tokens: total_tokens,
157            tool_calls: vec![],
158        };
159
160        accumulated.insert(
161            "generate_result".to_string(),
162            serde_json::to_value(&final_result).unwrap_or(Value::Null),
163        );
164
165        Ok(accumulated)
166    }
167
168    fn output_keys(&self) -> Vec<String> {
169        let mut keys: Vec<String> = self.branches.iter().map(|b| b.output_key.clone()).collect();
170        keys.push("generate_result".to_string());
171        keys
172    }
173
174    fn input_keys(&self) -> Vec<String> {
175        self.branches
176            .first()
177            .map(|b| b.chain.input_keys())
178            .unwrap_or_default()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use async_trait::async_trait;
186    use rune_chain_core::prompt_args;
187
188    struct Echo(String);
189
190    #[async_trait]
191    impl Chain for Echo {
192        async fn call(&self, input: PromptArgs) -> Result<GenerateResult, ChainError> {
193            let text = input
194                .get(&self.0)
195                .and_then(|v| v.as_str())
196                .unwrap_or("")
197                .to_string();
198            Ok(GenerateResult::from_text(text))
199        }
200    }
201
202    struct Upper(String);
203
204    #[async_trait]
205    impl Chain for Upper {
206        async fn call(&self, input: PromptArgs) -> Result<GenerateResult, ChainError> {
207            let text = input
208                .get(&self.0)
209                .and_then(|v| v.as_str())
210                .unwrap_or("")
211                .to_uppercase();
212            Ok(GenerateResult::from_text(text))
213        }
214    }
215
216    #[tokio::test]
217    async fn runs_branches_and_merges_output() {
218        let par = ParallelChain::new()
219            .branch(Echo("input".into()), "echo")
220            .branch(Upper("input".into()), "upper");
221
222        let map = par
223            .execute(prompt_args! { "input" => "hello" })
224            .await
225            .unwrap();
226        assert_eq!(map["echo"].as_str().unwrap(), "hello");
227        assert_eq!(map["upper"].as_str().unwrap(), "HELLO");
228    }
229
230    #[tokio::test]
231    async fn output_keys_includes_all_branches() {
232        let par = ParallelChain::new()
233            .branch(Echo("input".into()), "a")
234            .branch(Echo("input".into()), "b");
235        assert_eq!(par.output_keys(), vec!["a", "b", "generate_result"]);
236    }
237
238    #[tokio::test]
239    async fn empty_parallel_chain_returns_default() {
240        let par = ParallelChain::new();
241        let result = par.call(prompt_args! {}).await.unwrap();
242        assert_eq!(result.generation, "");
243    }
244}