Skip to main content

lc_chains/document_chains/
map_reduce.rs

1// lc-chains/src/document_chains/map_reduce.rs
2//! MapReduceDocumentsChain - processes documents in parallel then merges results.
3
4use async_trait::async_trait;
5use futures_util::future::try_join_all;
6use futures_util::StreamExt;
7use futures_util::TryStreamExt;
8use lc_core::BaseChatModel;
9use lc_providers::{wrap_chat_model, ProviderError};
10use lc_schema::Message;
11use lc_shared::document::Document;
12use serde_json::Value;
13use std::collections::HashMap;
14
15use crate::base::{
16    substitute_template, BaseChain, ChainError, ChainResult, ChainStream, StreamToken,
17};
18use crate::BoxedChatModel;
19
20/// Default Map processing prompt template.
21pub(crate) const DEFAULT_MAP_PROMPT: &str = "Answer the user's question based on the following document content. Provide a concise answer based on the document content.
22
23Document content:
24{context}
25
26Question: {input}
27
28Answer based on this document:";
29
30/// Default Reduce merge prompt template.
31pub(crate) const DEFAULT_REDUCE_PROMPT: &str = "Below are answers from multiple documents. Please merge them into a single complete and coherent final answer.
32
33Answers from each document:
34{summaries}
35
36Original question: {input}
37
38Final consolidated answer:";
39
40/// MapReduceDocumentsChain
41///
42/// Processes documents in two steps:
43/// 1. Map: Calls LLM independently for each document to generate an answer
44/// 2. Reduce: Merges all independent answers into a final answer
45pub struct MapReduceDocumentsChain {
46    llm: BoxedChatModel,
47    map_prompt_template: String,
48    reduce_prompt_template: String,
49    document_variable_name: String,
50    input_key: String,
51    output_key: String,
52    name: String,
53    verbose: bool,
54    /// Max number of in-flight map LLM calls; `None` = unbounded (P2-6).
55    map_concurrency: Option<usize>,
56}
57
58impl MapReduceDocumentsChain {
59    /// Create a new [`MapReduceDocumentsChain`] with the given LLM.
60    pub fn new<L>(llm: L) -> Self
61    where
62        L: BaseChatModel + Send + Sync + 'static,
63        L::Error: Into<ProviderError>,
64    {
65        Self {
66            llm: wrap_chat_model(llm),
67            map_prompt_template: DEFAULT_MAP_PROMPT.to_string(),
68            reduce_prompt_template: DEFAULT_REDUCE_PROMPT.to_string(),
69            document_variable_name: "context".to_string(),
70            input_key: "input".to_string(),
71            output_key: "output".to_string(),
72            name: "map_reduce_documents".to_string(),
73            verbose: false,
74            map_concurrency: None,
75        }
76    }
77
78    /// Set the map-phase prompt template.
79    pub fn with_map_prompt(mut self, template: impl Into<String>) -> Self {
80        self.map_prompt_template = template.into();
81        self
82    }
83
84    /// Set the reduce-phase prompt template.
85    pub fn with_reduce_prompt(mut self, template: impl Into<String>) -> Self {
86        self.reduce_prompt_template = template.into();
87        self
88    }
89
90    /// Set the document variable name used in the map prompt.
91    pub fn with_document_variable(mut self, name: impl Into<String>) -> Self {
92        self.document_variable_name = name.into();
93        self
94    }
95
96    /// Set the input key.
97    pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
98        self.input_key = key.into();
99        self
100    }
101
102    /// Set the output key.
103    pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
104        self.output_key = key.into();
105        self
106    }
107
108    /// Set the chain name.
109    pub fn with_name(mut self, name: impl Into<String>) -> Self {
110        self.name = name.into();
111        self
112    }
113
114    /// Set verbose mode.
115    pub fn with_verbose(mut self, verbose: bool) -> Self {
116        self.verbose = verbose;
117        self
118    }
119
120    /// Cap the number of concurrent map-phase LLM calls (P2-6).
121    ///
122    /// Default is unbounded (all documents mapped in parallel). Setting a
123    /// limit bounds in-flight requests — useful to respect provider rate
124    /// limits or bound memory on large document sets.
125    pub fn with_map_concurrency(mut self, limit: usize) -> Self {
126        self.map_concurrency = Some(limit);
127        self
128    }
129
130    /// Build the map-phase prompt by substituting the document content and input.
131    pub fn build_map_prompt(&self, context: &str, input: &str) -> String {
132        // 0.22.0 audit fix (H-C2): single-pass substitution — document
133        // contents containing literal `{input}` are never re-replaced.
134        let vars = HashMap::from([
135            (self.document_variable_name.clone(), context.to_string()),
136            ("input".to_string(), input.to_string()),
137        ]);
138        substitute_template(&self.map_prompt_template, &vars).0
139    }
140
141    /// Build the reduce-phase prompt from the per-document summaries and input.
142    pub fn build_reduce_prompt(&self, summaries: &[String], input: &str) -> String {
143        let summaries_text = summaries
144            .iter()
145            .enumerate()
146            .map(|(i, s)| format!("Answer from document {}:\n{}", i + 1, s))
147            .collect::<Vec<_>>()
148            .join("\n\n");
149
150        // 0.22.0 audit fix (H-C2): single-pass substitution — map answers
151        // containing literal `{summaries}` / `{input}` are never re-replaced.
152        let vars = HashMap::from([
153            ("summaries".to_string(), summaries_text),
154            ("input".to_string(), input.to_string()),
155        ]);
156        substitute_template(&self.reduce_prompt_template, &vars).0
157    }
158
159    /// Map phase: call LLM for a single document.
160    async fn map_document(
161        &self,
162        doc: &Document,
163        input: &str,
164        index: usize,
165    ) -> Result<String, ChainError> {
166        let prompt = self.build_map_prompt(&doc.content, input);
167
168        if self.verbose {
169            println!("\n--- Map document {} ---", index + 1);
170        }
171
172        let messages = vec![Message::human(&prompt)];
173        let response = self.llm.invoke(messages, None).await.map_err(|e| {
174            ChainError::ExecutionError(format!("Map call failed (document {}): {}", index + 1, e))
175        })?;
176
177        if self.verbose {
178            println!("Document {} answer: {}", index + 1, response.content);
179        }
180
181        Ok(response.content)
182    }
183
184    /// Map phase: run the per-document LLM calls concurrently (P2-6).
185    ///
186    /// Default is unbounded parallelism (`try_join_all`, all documents in
187    /// flight at once). With [`Self::with_map_concurrency`] the number of
188    /// in-flight calls is capped via `buffer_unordered`, which bounds memory
189    /// and lets users respect provider rate limits.
190    async fn map_phase(
191        &self,
192        documents: &[Document],
193        input: &str,
194    ) -> Result<Vec<String>, ChainError> {
195        // Materialize the per-document futures first: a `.map()` closure
196        // returning an async-fn future cannot express the higher-ranked
197        // `for<'a> FnMut((usize, &'a Document))` that `buffer_unordered`
198        // requires, so collect into a `Vec` of the concrete future type.
199        let mut map_futures = Vec::with_capacity(documents.len());
200        for (i, doc) in documents.iter().enumerate() {
201            map_futures.push(self.map_document(doc, input, i));
202        }
203
204        match self.map_concurrency {
205            Some(limit) => {
206                // 0.22.0 audit fix: `buffer_unordered(0)` panics — clamp a
207                // zero/negative-style limit to at least 1 with a warning.
208                let limit = if limit == 0 {
209                    log::warn!(
210                        "MapReduceDocumentsChain: map_concurrency 0 is invalid; clamping to 1"
211                    );
212                    1
213                } else {
214                    limit
215                };
216                futures_util::stream::iter(map_futures)
217                    .buffer_unordered(limit)
218                    .try_collect()
219                    .await
220            }
221            None => try_join_all(map_futures).await,
222        }
223    }
224
225    /// Invoke with documents and input directly.
226    pub async fn invoke_with_documents(
227        &self,
228        documents: Vec<Document>,
229        input: &str,
230    ) -> Result<String, ChainError> {
231        if documents.is_empty() {
232            return Err(ChainError::ExecutionError(
233                "Document list is empty".to_string(),
234            ));
235        }
236
237        if self.verbose {
238            println!("\n=== MapReduceDocumentsChain ===");
239            println!("Document count: {}", documents.len());
240            println!("Input: {}", input);
241        }
242
243        if self.verbose {
244            println!("\n--- Map phase ---");
245        }
246
247        let summaries = self.map_phase(&documents, input).await?;
248
249        if self.verbose {
250            println!("\n--- Reduce phase ---");
251        }
252
253        let reduce_prompt = self.build_reduce_prompt(&summaries, input);
254
255        if self.verbose {
256            println!("Merging answers from {} documents", summaries.len());
257        }
258
259        let messages = vec![Message::human(&reduce_prompt)];
260        let response = self
261            .llm
262            .invoke(messages, None)
263            .await
264            .map_err(|e| ChainError::ExecutionError(format!("Reduce call failed: {}", e)))?;
265
266        let final_answer = response.content;
267
268        if self.verbose {
269            println!("Final answer: {}", final_answer);
270            println!("=== MapReduceDocumentsChain complete ===\n");
271        }
272
273        Ok(final_answer)
274    }
275}
276
277#[async_trait]
278impl BaseChain for MapReduceDocumentsChain {
279    fn input_keys(&self) -> Vec<&str> {
280        vec![&self.input_key, "documents"]
281    }
282
283    fn output_keys(&self) -> Vec<&str> {
284        vec![&self.output_key]
285    }
286
287    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
288        // P2-8: validate inputs on the invoke path too, matching stream.
289        self.validate_inputs(&inputs)?;
290
291        let input = inputs
292            .get(&self.input_key)
293            .and_then(|v| v.as_str())
294            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
295
296        let documents = crate::base::documents_from_input(inputs.get("documents"))?;
297
298        let output = self.invoke_with_documents(documents, input).await?;
299
300        let mut result = HashMap::new();
301        result.insert(self.output_key.clone(), Value::String(output));
302        Ok(result)
303    }
304
305    /// Stream execution for MapReduceDocumentsChain.
306    ///
307    /// The map phase runs via invoke (parallel, non-streaming, since reduce
308    /// needs all summaries) — bounded by `map_concurrency` when configured.
309    /// The reduce phase is streamed token by token via `stream_chat`.
310    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
311        self.validate_inputs(&inputs)?;
312
313        let input = inputs
314            .get(&self.input_key)
315            .and_then(|v| v.as_str())
316            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
317
318        let documents = crate::base::documents_from_input(inputs.get("documents"))?;
319
320        if documents.is_empty() {
321            return Err(ChainError::ExecutionError(
322                "Document list is empty".to_string(),
323            ));
324        }
325
326        // Map phase: run all map calls in parallel (non-streaming), bounded by
327        // `map_concurrency` when configured (P2-6).
328        let summaries = self.map_phase(&documents, input).await?;
329
330        // Reduce phase: stream the final merged answer
331        let reduce_prompt = self.build_reduce_prompt(&summaries, input);
332        let messages = vec![Message::human(&reduce_prompt)];
333
334        let llm_stream = self
335            .llm
336            .stream_chat(messages, None)
337            .await
338            .map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
339
340        let stream = llm_stream.map(|result| match result {
341            Ok(chunk) => Ok(StreamToken {
342                token: chunk.text,
343                is_final: false,
344            }),
345            Err(e) => Err(ChainError::StreamError(format!(
346                "Stream token error: {}",
347                e
348            ))),
349        });
350
351        let final_stream = stream.chain(futures_util::stream::once(async move {
352            Ok(StreamToken {
353                token: String::new(),
354                is_final: true,
355            })
356        }));
357
358        Ok(Box::pin(final_stream))
359    }
360
361    fn name(&self) -> &str {
362        &self.name
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use async_trait::async_trait;
370    use futures_util::Stream;
371    use lc_core::language_models::{LLMResult, StreamChunk};
372    use lc_core::runnables::RunnableConfig;
373    use lc_core::{BaseLanguageModel, Runnable};
374    use std::pin::Pin;
375    use std::sync::atomic::{AtomicUsize, Ordering};
376    use std::sync::Arc;
377
378    /// Tracking chat model that counts `invoke` calls and records the maximum
379    /// number of in-flight invokes, so the map phase's parallelism and the
380    /// `with_map_concurrency` cap are provable rather than assumed. Each invoke
381    /// yields repeatedly so concurrently-polled futures genuinely overlap.
382    struct TrackingLLM {
383        invokes: Arc<AtomicUsize>,
384        in_flight: Arc<AtomicUsize>,
385        max_in_flight: Arc<AtomicUsize>,
386    }
387
388    impl TrackingLLM {
389        fn counters() -> (Arc<AtomicUsize>, Arc<AtomicUsize>, Arc<AtomicUsize>) {
390            (
391                Arc::new(AtomicUsize::new(0)),
392                Arc::new(AtomicUsize::new(0)),
393                Arc::new(AtomicUsize::new(0)),
394            )
395        }
396    }
397
398    #[async_trait]
399    impl Runnable<Vec<Message>, LLMResult> for TrackingLLM {
400        type Error = ProviderError;
401        async fn invoke(
402            &self,
403            input: Vec<Message>,
404            _config: Option<RunnableConfig>,
405        ) -> Result<LLMResult, Self::Error> {
406            let cur = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
407            self.max_in_flight.fetch_max(cur, Ordering::SeqCst);
408
409            // Yield repeatedly so every concurrently-polled future is actually
410            // scheduled before any of them returns.
411            for _ in 0..32 {
412                tokio::task::yield_now().await;
413            }
414
415            self.in_flight.fetch_sub(1, Ordering::SeqCst);
416            self.invokes.fetch_add(1, Ordering::SeqCst);
417
418            let is_reduce = input
419                .iter()
420                .any(|m| m.content.contains("Below are answers"));
421            let content = if is_reduce {
422                "final merged answer".to_string()
423            } else {
424                "map answer".to_string()
425            };
426            Ok(LLMResult {
427                content,
428                model: "mock".to_string(),
429                token_usage: None,
430                tool_calls: None,
431                thinking_content: None,
432            })
433        }
434    }
435
436    #[async_trait]
437    impl BaseLanguageModel<Vec<Message>, LLMResult> for TrackingLLM {
438        fn model_name(&self) -> &str {
439            "mock"
440        }
441        fn get_num_tokens(&self, t: &str) -> usize {
442            t.len()
443        }
444        fn with_temperature(self, _: f32) -> Self {
445            self
446        }
447        fn with_max_tokens(self, _: usize) -> Self {
448            self
449        }
450    }
451
452    #[async_trait]
453    impl BaseChatModel for TrackingLLM {
454        async fn chat(
455            &self,
456            messages: Vec<Message>,
457            _config: Option<RunnableConfig>,
458        ) -> Result<LLMResult, Self::Error> {
459            // Same counting behavior as invoke so the stream path's map phase
460            // is observably identical.
461            self.invoke(messages, None).await
462        }
463        async fn stream_chat(
464            &self,
465            _messages: Vec<Message>,
466            _config: Option<RunnableConfig>,
467        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
468        {
469            let tokens = [Ok(StreamChunk::new("streamed token"))];
470            Ok(Box::pin(futures_util::stream::iter(tokens)))
471        }
472    }
473
474    fn docs(n: usize) -> Vec<Document> {
475        (0..n).map(|i| Document::new(format!("doc {i}"))).collect()
476    }
477
478    /// P2-6: the map phase is genuinely parallel — with 6 documents the map
479    /// LLM calls overlap in flight (max in-flight > 1), then a single reduce
480    /// call produces the final answer.
481    #[tokio::test]
482    async fn test_map_reduce_map_phase_is_parallel() {
483        let (invokes, in_flight, max_in_flight) = TrackingLLM::counters();
484        let chain = MapReduceDocumentsChain::new(TrackingLLM {
485            invokes: invokes.clone(),
486            in_flight,
487            max_in_flight: max_in_flight.clone(),
488        });
489
490        let out = chain
491            .invoke_with_documents(docs(6), "question")
492            .await
493            .unwrap();
494        assert_eq!(out, "final merged answer");
495        assert_eq!(invokes.load(Ordering::SeqCst), 7, "6 map + 1 reduce");
496        assert!(
497            max_in_flight.load(Ordering::SeqCst) > 1,
498            "map phase must overlap calls, got max in-flight {}",
499            max_in_flight.load(Ordering::SeqCst)
500        );
501    }
502
503    /// P2-6: `with_map_concurrency(2)` caps in-flight map calls at 2 while
504    /// still running concurrently (max in-flight exactly 2).
505    #[tokio::test]
506    async fn test_map_reduce_concurrency_limit_caps_in_flight() {
507        let (invokes, in_flight, max_in_flight) = TrackingLLM::counters();
508        let chain = MapReduceDocumentsChain::new(TrackingLLM {
509            invokes: invokes.clone(),
510            in_flight,
511            max_in_flight: max_in_flight.clone(),
512        })
513        .with_map_concurrency(2);
514
515        let out = chain
516            .invoke_with_documents(docs(6), "question")
517            .await
518            .unwrap();
519        assert_eq!(out, "final merged answer");
520        assert_eq!(invokes.load(Ordering::SeqCst), 7);
521        assert_eq!(
522            max_in_flight.load(Ordering::SeqCst),
523            2,
524            "concurrency cap 2 must bound in-flight map calls"
525        );
526    }
527
528    /// P2-6: empty documents error loudly before any LLM call.
529    #[tokio::test]
530    async fn test_map_reduce_empty_documents() {
531        let (invokes, in_flight, max_in_flight) = TrackingLLM::counters();
532        let chain = MapReduceDocumentsChain::new(TrackingLLM {
533            invokes: invokes.clone(),
534            in_flight,
535            max_in_flight,
536        });
537        let err = match chain.invoke_with_documents(vec![], "q").await {
538            Ok(_) => panic!("expected an execution error"),
539            Err(e) => e,
540        };
541        assert!(matches!(err, ChainError::ExecutionError(_)));
542        assert_eq!(invokes.load(Ordering::SeqCst), 0);
543    }
544}