Skip to main content

context_forge/distill/
mod.rs

1//! Distillation: turning a raw conversation transcript into durable memory.
2//!
3//! This module defines the [`Distiller`] trait and the data types it
4//! produces. The trait itself is always available (not feature-gated) so
5//! that callers can implement their own distillers — for example, a remote
6//! API client, a different local model runtime, or a test stub.
7//!
8//! The `OpenAiCompatDistiller` implementation in the `openai_compat`
9//! submodule (behind the `distill-http` feature) talks to an
10//! OpenAI-compatible chat completions endpoint such as Ollama or
11//! llama-server. It is the only place in this crate that performs HTTP, and
12//! only when that feature is enabled.
13
14#[cfg(feature = "distill-http")]
15pub mod openai_compat;
16
17use std::collections::HashSet;
18use std::fmt::Write as _;
19
20use serde::{Deserialize, Serialize};
21
22use crate::traits::Result;
23
24/// The result of distilling a conversation transcript into durable memory.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DistilledMemory {
27    /// A summary of the conversation, intended to be under 150 words.
28    pub summary: String,
29    /// Individual facts worth remembering across future sessions.
30    pub facts: Vec<Fact>,
31}
32
33/// A single distilled fact extracted from a transcript.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Fact {
36    /// The category of this fact.
37    pub kind: FactKind,
38    /// One self-contained sentence describing the fact, understandable
39    /// without the original transcript.
40    pub text: String,
41}
42
43/// The category of a distilled [`Fact`].
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46#[non_exhaustive]
47pub enum FactKind {
48    /// A decision that was made, and (ideally) why.
49    Decision,
50    /// A correction the user gave.
51    Correction,
52    /// A user preference.
53    Preference,
54    /// A state change ("X is now Y").
55    State,
56}
57
58/// Maximum number of facts persisted from a single distillation. Excess
59/// facts (untrusted model output) are dropped.
60pub const MAX_FACTS: usize = 64;
61/// Maximum character length of a single distilled fact's text. Longer text
62/// is truncated on a `char` boundary.
63pub const MAX_FACT_CHARS: usize = 2_048;
64/// Maximum character length of a distilled summary. Longer text is
65/// truncated on a `char` boundary.
66pub const MAX_SUMMARY_CHARS: usize = 8_192;
67
68/// Truncates `text` to at most `max_chars` Unicode scalar values, keeping
69/// the beginning and dropping the tail. Truncation always lands on a `char`
70/// boundary.
71fn truncate_keep_start(text: &str, max_chars: usize) -> &str {
72    match text.char_indices().nth(max_chars) {
73        Some((byte_idx, _)) => &text[..byte_idx],
74        None => text,
75    }
76}
77
78/// Caps a [`DistilledMemory`] produced by an untrusted [`Distiller`] so that
79/// it cannot persist unbounded data.
80///
81/// The summary is truncated to at most [`MAX_SUMMARY_CHARS`] characters, the
82/// fact list is truncated to at most [`MAX_FACTS`] entries, and each
83/// surviving fact's text is truncated to at most [`MAX_FACT_CHARS`]
84/// characters. All truncation keeps the beginning of the text and is
85/// silent: no error, no marker, no logging.
86pub(crate) fn cap_distilled_memory(memory: DistilledMemory) -> DistilledMemory {
87    let DistilledMemory { summary, mut facts } = memory;
88
89    let summary = truncate_keep_start(&summary, MAX_SUMMARY_CHARS).to_owned();
90
91    facts.truncate(MAX_FACTS);
92    for fact in &mut facts {
93        if fact.text.chars().count() > MAX_FACT_CHARS {
94            fact.text = truncate_keep_start(&fact.text, MAX_FACT_CHARS).to_owned();
95        }
96    }
97
98    DistilledMemory { summary, facts }
99}
100
101/// Reduces several [`DistilledMemory`] partial results — one per transcript
102/// chunk — into a single [`DistilledMemory`].
103///
104/// Facts are concatenated in input order and deduplicated on
105/// `(kind, normalized text)`, where normalization trims whitespace and
106/// lowercases; the first occurrence of each duplicate is kept. Summaries are
107/// joined with a blank line between them. The combined result is passed
108/// through `cap_distilled_memory`, so the output is bounded the same way a
109/// single distillation's output would be.
110///
111/// This is the pure, deterministic reduce: no model call, safe to call with
112/// an empty `Vec` (returns an empty [`DistilledMemory`]) or a single-element
113/// `Vec` (returns that element's content, capped).
114#[must_use]
115pub fn merge_distilled(parts: Vec<DistilledMemory>) -> DistilledMemory {
116    let mut summaries = Vec::with_capacity(parts.len());
117    let mut facts = Vec::new();
118    let mut seen = HashSet::new();
119
120    for part in parts {
121        if !part.summary.is_empty() {
122            summaries.push(part.summary);
123        }
124        for fact in part.facts {
125            let key = (fact.kind, fact.text.trim().to_lowercase());
126            if seen.insert(key) {
127                // "insert into the seen-set, and only
128                //keep this fact if that insert was new." One line does both the membership check and the recording
129                facts.push(fact);
130            }
131        }
132    }
133
134    cap_distilled_memory(DistilledMemory {
135        summary: summaries.join("\n\n"),
136        facts,
137    })
138}
139
140/// Splits `transcript` into chunks that each fit within `max_chars`.
141///
142/// Packing is line-aware: each chunk is filled with whole lines (a
143/// transcript is one line per turn) up to `max_chars`, so a chunk only ever
144/// cuts between turns, never mid-turn. A single line longer than
145/// `max_chars` on its own is hard-split on `char` boundaries into one or
146/// more chunks — this function is a strict size guarantee, not a hint, so
147/// even a pathologically long line cannot produce an over-budget chunk.
148///
149/// `max_chars == 0` has no valid split (every chunk would have to be
150/// empty), so it clamps to a single chunk containing the whole
151/// `transcript` — the same behavior the crate had before chunking existed.
152/// Debug builds panic via `debug_assert_ne!` so a misconfigured budget is
153/// caught during development; release builds degrade silently, matching
154/// `cap_distilled_memory`'s convention of never panicking on
155/// untrusted/misconfigured input.
156///
157/// An empty `transcript` returns an empty `Vec` (zero chunks).
158///
159/// This is a pure, zero-copy split: the returned slices borrow from
160/// `transcript`, and concatenating them in order reproduces `transcript`
161/// exactly — no data is dropped, added, or copied.
162#[must_use]
163pub fn split_on_budget(transcript: &str, max_chars: usize) -> Vec<&str> {
164    if transcript.is_empty() {
165        return Vec::new();
166    }
167
168    if max_chars == 0 {
169        debug_assert_ne!(
170            max_chars, 0,
171            "split_on_budget called with max_chars == 0; clamping to a single, unsplit chunk"
172        );
173        return vec![transcript];
174    }
175
176    let mut chunks = Vec::new();
177    let mut chunk_start = 0usize;
178    let mut consumed = 0usize;
179    let mut chunk_chars = 0usize;
180
181    for line in transcript.split_inclusive('\n') {
182        let line_chars = line.chars().count();
183
184        if line_chars > max_chars {
185            if consumed > chunk_start {
186                chunks.push(&transcript[chunk_start..consumed]);
187            }
188            chunks.extend(hard_split(line, max_chars));
189            consumed += line.len();
190            chunk_start = consumed;
191            chunk_chars = 0;
192            continue;
193        }
194
195        if chunk_chars + line_chars > max_chars && consumed > chunk_start {
196            chunks.push(&transcript[chunk_start..consumed]);
197            chunk_start = consumed;
198            chunk_chars = 0;
199        }
200
201        consumed += line.len();
202        chunk_chars += line_chars;
203    }
204
205    if consumed > chunk_start {
206        chunks.push(&transcript[chunk_start..consumed]);
207    }
208
209    chunks
210}
211
212/// Hard-splits `line` into pieces of at most `max_chars` Unicode scalar
213/// values each, on `char` boundaries. Used by [`split_on_budget`] when a
214/// single line exceeds the chunk budget on its own.
215fn hard_split(mut line: &str, max_chars: usize) -> Vec<&str> {
216    let mut pieces = Vec::new();
217    while !line.is_empty() {
218        if let Some((byte_idx, _)) = line.char_indices().nth(max_chars) {
219            pieces.push(&line[..byte_idx]);
220            line = &line[byte_idx..];
221        } else {
222            pieces.push(line);
223            break;
224        }
225    }
226    pieces
227}
228
229/// Strategy used to reduce several partial [`DistilledMemory`] results —
230/// one per transcript chunk — into a single result.
231///
232/// `Structural` is the default: deterministic, no extra model call, and
233/// therefore no extra risk of re-introducing the prompt-size problem
234/// chunking exists to fix. `Llm` is strictly opt-in — it can produce better
235/// prose and more semantic deduplication, but it is a second, non-
236/// deterministic pass that re-reads already-distilled output and could
237/// silently drop a detail the first pass captured. Prefer `Structural`
238/// unless you have a specific reason to accept that tradeoff.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
240pub enum ReduceStrategy {
241    /// Merge partial results with [`merge_distilled`]. Deterministic, no
242    /// model call.
243    #[default]
244    Structural,
245    /// Render partial results back into text and call the distiller on
246    /// them once more for a consolidated pass.
247    Llm,
248}
249
250/// Renders partial distillation results back into a single transcript-like
251/// string, for [`ReduceStrategy::Llm`]: each part's summary and facts are
252/// listed in order, so a model reading the result can see everything every
253/// chunk produced and consolidate it in one pass.
254fn render_partials(parts: &[DistilledMemory]) -> String {
255    let mut rendered = String::new();
256    for (i, part) in parts.iter().enumerate() {
257        let _ = writeln!(rendered, "Summary {}: {}", i + 1, part.summary);
258        if !part.facts.is_empty() {
259            rendered.push_str("Facts:\n");
260            for fact in &part.facts {
261                let _ = writeln!(rendered, "- [{:?}] {}", fact.kind, fact.text);
262            }
263        }
264        rendered.push('\n');
265    }
266    rendered
267}
268
269/// Reduces `parts` — one [`DistilledMemory`] per transcript chunk — into a
270/// single [`DistilledMemory`], using `strategy` to choose how.
271///
272/// An empty `parts` short-circuits to [`merge_distilled`]'s empty-`Vec`
273/// behavior regardless of `strategy`: there is nothing for `inner` to
274/// reduce, so there is nothing to call it with.
275///
276/// # Errors
277///
278/// Returns an error if `strategy` is [`ReduceStrategy::Llm`] and the call to
279/// `inner.distill` fails.
280fn reduce<D: Distiller>(
281    parts: Vec<DistilledMemory>,
282    inner: &D,
283    strategy: ReduceStrategy,
284) -> Result<DistilledMemory> {
285    if parts.is_empty() {
286        return Ok(merge_distilled(parts));
287    }
288
289    match strategy {
290        ReduceStrategy::Structural => Ok(merge_distilled(parts)),
291        ReduceStrategy::Llm => {
292            let rendered = render_partials(&parts);
293            inner.distill(&rendered)
294        }
295    }
296}
297
298/// Produces [`DistilledMemory`] from a raw conversation transcript.
299///
300/// Implementations must be thread-safe (`Send + Sync`) so a single
301/// distiller instance can be shared across worker threads.
302pub trait Distiller: Send + Sync {
303    /// Distill `transcript` into a summary and a list of durable facts.
304    ///
305    /// # Security
306    ///
307    /// Implementations transmit `transcript` verbatim to the underlying
308    /// model or service — no secret scrubbing is applied at this layer.
309    /// [`ContextForge::distill_and_save`](crate::ContextForge::distill_and_save)
310    /// is the only entry point that scrubs secrets (via
311    /// [`scrub_secrets`](crate::scrub_secrets)) before a transcript reaches
312    /// a [`Distiller`]; callers invoking [`Distiller::distill`] directly are
313    /// responsible for scrubbing first.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if distillation fails (e.g. the backing model or
318    /// service is unavailable, or its response cannot be parsed).
319    fn distill(&self, transcript: &str) -> Result<DistilledMemory>;
320}
321
322/// A [`Distiller`] decorator that bounds the size of any single prompt sent
323/// to `inner`.
324///
325/// A long transcript is split into chunks of at most `max_chunk_chars` (via
326/// [`split_on_budget`]), each chunk is distilled independently through
327/// `inner`, and the partial results are combined into one
328/// [`DistilledMemory`] (via an internal `reduce` step, using the configured
329/// [`ReduceStrategy`]). A transcript that already fits in one chunk — including
330/// an empty transcript — is passed through to `inner` unchanged, with no
331/// splitting or reducing.
332///
333/// `max_chunk_chars` is caller-supplied policy: this type has no opinion on
334/// what a safe prompt size is for any particular model or host, only on how
335/// to split, map, and reduce once a budget is given.
336pub struct ChunkingDistiller<D: Distiller> {
337    inner: D,
338    max_chunk_chars: usize,
339    reduce: ReduceStrategy,
340}
341
342impl<D: Distiller> ChunkingDistiller<D> {
343    /// Wraps `inner`, splitting any transcript over `max_chunk_chars` into
344    /// multiple distillation calls. Uses [`ReduceStrategy::Structural`] to
345    /// combine the results; call [`Self::with_reduce_strategy`] to use
346    /// [`ReduceStrategy::Llm`] instead.
347    pub fn new(inner: D, max_chunk_chars: usize) -> Self {
348        Self {
349            inner,
350            max_chunk_chars,
351            reduce: ReduceStrategy::default(),
352        }
353    }
354
355    /// Sets the [`ReduceStrategy`] used to combine chunk results.
356    #[must_use]
357    pub fn with_reduce_strategy(mut self, strategy: ReduceStrategy) -> Self {
358        self.reduce = strategy;
359        self
360    }
361}
362
363impl<D: Distiller> Distiller for ChunkingDistiller<D> {
364    /// # Errors
365    ///
366    /// Returns an error if any chunk's call to `inner.distill` fails, or if
367    /// [`ReduceStrategy::Llm`] is used and the consolidating call fails. On
368    /// error, no partial result is produced: chunks already distilled
369    /// successfully before the failure are discarded, not saved or returned.
370    fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
371        let chunks = split_on_budget(transcript, self.max_chunk_chars);
372        if chunks.len() <= 1 {
373            return self.inner.distill(transcript);
374        }
375
376        let parts = chunks
377            .iter()
378            .map(|chunk| self.inner.distill(chunk))
379            .collect::<Result<Vec<_>>>()?;
380
381        reduce(parts, &self.inner, self.reduce)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::{
388        cap_distilled_memory, merge_distilled, reduce, split_on_budget, ChunkingDistiller,
389        DistilledMemory, Distiller, Fact, FactKind, ReduceStrategy, MAX_FACTS, MAX_FACT_CHARS,
390        MAX_SUMMARY_CHARS,
391    };
392    use crate::error::Error;
393    use crate::traits::Result;
394
395    fn fact(text: impl Into<String>) -> Fact {
396        Fact {
397            kind: FactKind::State,
398            text: text.into(),
399        }
400    }
401
402    #[test]
403    fn cap_drops_excess_facts() {
404        let facts = (0..MAX_FACTS + 50)
405            .map(|i| fact(format!("fact {i}")))
406            .collect();
407        let memory = DistilledMemory {
408            summary: "summary".to_owned(),
409            facts,
410        };
411
412        let capped = cap_distilled_memory(memory);
413
414        assert_eq!(capped.facts.len(), MAX_FACTS);
415    }
416
417    #[test]
418    fn cap_truncates_long_fact_text() {
419        let long_text = "a".repeat(MAX_FACT_CHARS + 1000);
420        let memory = DistilledMemory {
421            summary: "summary".to_owned(),
422            facts: vec![fact(long_text.clone())],
423        };
424
425        let capped = cap_distilled_memory(memory);
426
427        assert_eq!(capped.facts[0].text.chars().count(), MAX_FACT_CHARS);
428        assert!(long_text.starts_with(&capped.facts[0].text));
429    }
430
431    #[test]
432    fn cap_truncates_long_summary() {
433        let long_summary = "b".repeat(MAX_SUMMARY_CHARS + 1000);
434        let memory = DistilledMemory {
435            summary: long_summary.clone(),
436            facts: vec![],
437        };
438
439        let capped = cap_distilled_memory(memory);
440
441        assert_eq!(capped.summary.chars().count(), MAX_SUMMARY_CHARS);
442        assert!(long_summary.starts_with(&capped.summary));
443    }
444
445    #[test]
446    fn cap_respects_char_boundaries() {
447        let long_text = "é".repeat(MAX_FACT_CHARS + 10);
448        let memory = DistilledMemory {
449            summary: "🎉".repeat(MAX_SUMMARY_CHARS + 10),
450            facts: vec![fact(long_text)],
451        };
452
453        let capped = cap_distilled_memory(memory);
454
455        assert_eq!(capped.facts[0].text.chars().count(), MAX_FACT_CHARS);
456        assert_eq!(capped.summary.chars().count(), MAX_SUMMARY_CHARS);
457    }
458
459    #[test]
460    fn cap_leaves_compliant_memory_unchanged() {
461        let memory = DistilledMemory {
462            summary: "A short summary.".to_owned(),
463            facts: vec![fact("A short fact.")],
464        };
465
466        let capped = cap_distilled_memory(memory.clone());
467
468        assert_eq!(capped.summary, memory.summary);
469        assert_eq!(capped.facts.len(), memory.facts.len());
470        assert_eq!(capped.facts[0].text, memory.facts[0].text);
471    }
472
473    #[test]
474    fn merge_empty_input_returns_empty_memory() {
475        let merged = merge_distilled(vec![]);
476
477        assert_eq!(merged.summary, "");
478        assert!(merged.facts.is_empty());
479    }
480
481    #[test]
482    fn merge_single_part_is_passthrough() {
483        let part = DistilledMemory {
484            summary: "A short summary.".to_owned(),
485            facts: vec![fact("A short fact.")],
486        };
487
488        let merged = merge_distilled(vec![part.clone()]);
489
490        assert_eq!(merged.summary, part.summary);
491        assert_eq!(merged.facts.len(), 1);
492        assert_eq!(merged.facts[0].text, part.facts[0].text);
493    }
494
495    #[test]
496    fn merge_concatenates_facts_from_multiple_parts_in_order() {
497        let part1 = DistilledMemory {
498            summary: "First.".to_owned(),
499            facts: vec![fact("Fact A")],
500        };
501        let part2 = DistilledMemory {
502            summary: "Second.".to_owned(),
503            facts: vec![fact("Fact B")],
504        };
505
506        let merged = merge_distilled(vec![part1, part2]);
507
508        assert_eq!(merged.facts.len(), 2);
509        assert_eq!(merged.facts[0].text, "Fact A");
510        assert_eq!(merged.facts[1].text, "Fact B");
511    }
512
513    #[test]
514    fn merge_dedups_facts_with_same_kind_and_normalized_text() {
515        let part1 = DistilledMemory {
516            summary: "First.".to_owned(),
517            facts: vec![fact("We decided to roll back the deploy.")],
518        };
519        let part2 = DistilledMemory {
520            summary: "Second.".to_owned(),
521            // Same fact, different case and trailing whitespace.
522            facts: vec![fact("we decided to roll back the deploy.  ")],
523        };
524
525        let merged = merge_distilled(vec![part1, part2]);
526
527        // Only the first occurrence survives, with its original text intact.
528        assert_eq!(merged.facts.len(), 1);
529        assert_eq!(merged.facts[0].text, "We decided to roll back the deploy.");
530    }
531
532    #[test]
533    fn merge_does_not_dedup_same_text_across_different_kinds() {
534        let part = DistilledMemory {
535            summary: "summary".to_owned(),
536            facts: vec![
537                Fact {
538                    kind: FactKind::Decision,
539                    text: "Same text.".to_owned(),
540                },
541                Fact {
542                    kind: FactKind::State,
543                    text: "Same text.".to_owned(),
544                },
545            ],
546        };
547
548        let merged = merge_distilled(vec![part]);
549
550        // Different kind means a different dedup key, even with identical text.
551        assert_eq!(merged.facts.len(), 2);
552    }
553
554    #[test]
555    fn merge_joins_summaries_with_blank_line_then_caps() {
556        // Exactly at the cap already, so nothing from the second summary
557        // should survive truncation.
558        let summary_a = "a".repeat(MAX_SUMMARY_CHARS);
559        let summary_b = "b".repeat(1_000);
560        let part1 = DistilledMemory {
561            summary: summary_a.clone(),
562            facts: vec![],
563        };
564        let part2 = DistilledMemory {
565            summary: summary_b,
566            facts: vec![],
567        };
568
569        let merged = merge_distilled(vec![part1, part2]);
570
571        assert_eq!(merged.summary, summary_a);
572        assert_eq!(merged.summary.chars().count(), MAX_SUMMARY_CHARS);
573    }
574
575    #[test]
576    fn merge_caps_total_facts_at_max_facts() {
577        let parts: Vec<DistilledMemory> = (0..MAX_FACTS + 20)
578            .map(|i| DistilledMemory {
579                summary: String::new(),
580                facts: vec![fact(format!("fact number {i}"))],
581            })
582            .collect();
583
584        let merged = merge_distilled(parts);
585
586        assert_eq!(merged.facts.len(), MAX_FACTS);
587    }
588
589    #[test]
590    #[should_panic(expected = "max_chars == 0")]
591    fn split_on_budget_panics_in_debug_on_zero_budget() {
592        let _ = split_on_budget("a\nb\n", 0);
593    }
594
595    #[test]
596    fn split_on_budget_empty_transcript_returns_no_chunks() {
597        let chunks = split_on_budget("", 60);
598
599        assert!(chunks.is_empty());
600    }
601
602    #[test]
603    fn split_on_budget_packs_lines_into_one_chunk_when_they_fit() {
604        let transcript = "ab\ncde\nfg\n";
605        let chunks = split_on_budget(transcript, 12);
606
607        assert_eq!(chunks, vec!["ab\ncde\nfg\n"]);
608        assert_eq!(chunks.concat(), transcript);
609    }
610
611    #[test]
612    fn split_on_budget_flushes_before_exceeding_budget() {
613        let transcript = "ab\ncdefghij\nklm\n";
614        let chunks = split_on_budget(transcript, 10);
615
616        assert_eq!(chunks, vec!["ab\n", "cdefghij\n", "klm\n"]);
617        assert!(chunks.iter().all(|c| c.chars().count() <= 10));
618        assert_eq!(chunks.concat(), transcript);
619    }
620
621    #[test]
622    fn split_on_budget_hard_splits_oversized_single_line() {
623        let transcript = "abcdefghij\n";
624        let chunks = split_on_budget(transcript, 5);
625
626        assert_eq!(chunks, vec!["abcde", "fghij", "\n"]);
627        assert!(chunks.iter().all(|c| c.chars().count() <= 5));
628        assert_eq!(chunks.concat(), transcript);
629    }
630
631    #[test]
632    fn split_on_budget_flushes_pending_chunk_before_hard_splitting_oversized_line() {
633        let transcript = format!("hi\n{}\nok\n", "c".repeat(13));
634        let chunks = split_on_budget(&transcript, 5);
635
636        assert_eq!(chunks, vec!["hi\n", "ccccc", "ccccc", "ccc\n", "ok\n"]);
637        assert!(chunks.iter().all(|c| c.chars().count() <= 5));
638        assert_eq!(chunks.concat(), transcript);
639    }
640
641    #[test]
642    fn split_on_budget_reconstructs_transcript_without_trailing_newline() {
643        let transcript = "a\nbb\nccc";
644        let chunks = split_on_budget(transcript, 100);
645
646        assert_eq!(chunks, vec!["a\nbb\nccc"]);
647        assert_eq!(chunks.concat(), transcript);
648    }
649
650    /// A [`Distiller`] that panics if called, used to prove a code path
651    /// under test never invokes the inner distiller at all.
652    struct PanicIfCalledDistiller;
653
654    impl Distiller for PanicIfCalledDistiller {
655        fn distill(&self, _transcript: &str) -> Result<DistilledMemory> {
656            panic!("inner.distill should not be called for this reduce strategy");
657        }
658    }
659
660    /// A [`Distiller`] that records every transcript it was called with (in
661    /// call order) and returns a fixed [`DistilledMemory`].
662    struct RecordingDistiller {
663        calls: std::sync::Mutex<Vec<String>>,
664    }
665
666    impl RecordingDistiller {
667        fn new() -> Self {
668            Self {
669                calls: std::sync::Mutex::new(Vec::new()),
670            }
671        }
672    }
673
674    impl Distiller for RecordingDistiller {
675        fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
676            self.calls.lock().unwrap().push(transcript.to_owned());
677            Ok(DistilledMemory {
678                summary: "Consolidated summary.".to_owned(),
679                facts: vec![],
680            })
681        }
682    }
683
684    #[test]
685    fn reduce_structural_never_calls_inner() {
686        let parts = vec![
687            DistilledMemory {
688                summary: "First.".to_owned(),
689                facts: vec![fact("Fact A")],
690            },
691            DistilledMemory {
692                summary: "Second.".to_owned(),
693                facts: vec![fact("Fact B")],
694            },
695        ];
696
697        let result = reduce(
698            parts.clone(),
699            &PanicIfCalledDistiller,
700            ReduceStrategy::Structural,
701        )
702        .expect("structural reduce does not call inner, so it cannot fail");
703
704        assert_eq!(result.summary, merge_distilled(parts).summary);
705    }
706
707    #[test]
708    fn reduce_empty_parts_returns_empty_without_calling_inner() {
709        let result = reduce(vec![], &PanicIfCalledDistiller, ReduceStrategy::Llm)
710            .expect("empty parts short-circuits before inner is ever called");
711
712        assert_eq!(result.summary, "");
713        assert!(result.facts.is_empty());
714    }
715
716    #[test]
717    fn reduce_llm_calls_inner_exactly_once_with_rendered_partials() {
718        let parts = vec![
719            DistilledMemory {
720                summary: "First chunk summary.".to_owned(),
721                facts: vec![fact("Fact A")],
722            },
723            DistilledMemory {
724                summary: "Second chunk summary.".to_owned(),
725                facts: vec![Fact {
726                    kind: FactKind::Decision,
727                    text: "Fact B".to_owned(),
728                }],
729            },
730        ];
731        let distiller = RecordingDistiller::new();
732
733        let result = reduce(parts, &distiller, ReduceStrategy::Llm).unwrap();
734
735        let calls = distiller.calls.lock().unwrap();
736        assert_eq!(calls.len(), 1, "inner.distill must be called exactly once");
737
738        let rendered = &calls[0];
739        assert!(rendered.contains("First chunk summary."));
740        assert!(rendered.contains("Second chunk summary."));
741        assert!(rendered.contains("[State] Fact A"));
742        assert!(rendered.contains("[Decision] Fact B"));
743
744        // The result is whatever the single consolidated call returned, not
745        // a merge of the original parts.
746        assert_eq!(result.summary, "Consolidated summary.");
747    }
748
749    /// A [`Distiller`] that records every transcript it was called with into
750    /// a shared, externally observable list. Unlike [`RecordingDistiller`],
751    /// the shared `Arc` lets a test keep inspecting calls after this stub
752    /// has been moved into another type (e.g. [`ChunkingDistiller`], which
753    /// takes ownership of its inner distiller).
754    #[derive(Clone)]
755    struct SharedRecordingDistiller {
756        calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
757    }
758
759    impl SharedRecordingDistiller {
760        fn new() -> (Self, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
761            let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
762            (
763                Self {
764                    calls: calls.clone(),
765                },
766                calls,
767            )
768        }
769    }
770
771    impl Distiller for SharedRecordingDistiller {
772        fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
773            self.calls.lock().unwrap().push(transcript.to_owned());
774            Ok(DistilledMemory {
775                summary: "stub summary".to_owned(),
776                facts: vec![],
777            })
778        }
779    }
780
781    #[test]
782    fn chunking_distiller_passthrough_calls_inner_once_with_original_transcript() {
783        let (stub, calls) = SharedRecordingDistiller::new();
784        let chunking = ChunkingDistiller::new(stub, 1_000);
785        let transcript = "a short transcript, well under budget";
786
787        chunking.distill(transcript).unwrap();
788
789        let calls = calls.lock().unwrap();
790        assert_eq!(calls.len(), 1);
791        assert_eq!(calls[0], transcript);
792    }
793
794    #[test]
795    fn chunking_distiller_empty_transcript_calls_inner_once_with_empty_string() {
796        let (stub, calls) = SharedRecordingDistiller::new();
797        let chunking = ChunkingDistiller::new(stub, 1_000);
798
799        chunking.distill("").unwrap();
800
801        let calls = calls.lock().unwrap();
802        assert_eq!(calls.len(), 1);
803        assert_eq!(calls[0], "");
804    }
805
806    /// A [`Distiller`] that returns content derived from its input
807    /// transcript, so a multi-chunk test can verify each chunk was
808    /// distilled independently (rather than, say, all chunks colliding on
809    /// one fixed stub response).
810    struct EchoDistiller;
811
812    impl Distiller for EchoDistiller {
813        fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
814            Ok(DistilledMemory {
815                summary: format!("Summary of: {transcript}"),
816                facts: vec![Fact {
817                    kind: FactKind::State,
818                    text: format!("Fact from: {transcript}"),
819                }],
820            })
821        }
822    }
823
824    #[test]
825    fn chunking_distiller_maps_each_chunk_and_merges_structurally() {
826        // max_chunk_chars = 4 packs each 4-char line ("aaa\n", "bbb\n") into
827        // its own chunk; see split_on_budget's own tests for this packing
828        // rule. Default reduce strategy is Structural (merge_distilled).
829        let transcript = "aaa\nbbb\n";
830        let chunking = ChunkingDistiller::new(EchoDistiller, 4);
831
832        let result = chunking.distill(transcript).unwrap();
833
834        assert_eq!(result.facts.len(), 2);
835        assert!(result.facts.iter().any(|f| f.text == "Fact from: aaa\n"));
836        assert!(result.facts.iter().any(|f| f.text == "Fact from: bbb\n"));
837        assert!(result.summary.contains("Summary of: aaa\n"));
838        assert!(result.summary.contains("Summary of: bbb\n"));
839    }
840
841    /// A [`Distiller`] that fails for any transcript containing `trigger`,
842    /// otherwise succeeds with a fixed result. Used to prove a single
843    /// chunk's failure propagates instead of being silently dropped.
844    struct FailsOnDistiller {
845        trigger: &'static str,
846    }
847
848    impl Distiller for FailsOnDistiller {
849        fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
850            if transcript.contains(self.trigger) {
851                Err(Error::Distill("simulated chunk failure".to_owned()))
852            } else {
853                Ok(DistilledMemory {
854                    summary: "ok".to_owned(),
855                    facts: vec![],
856                })
857            }
858        }
859    }
860
861    #[test]
862    fn chunking_distiller_propagates_a_single_chunk_failure() {
863        // Same packing as the merge test: "aaa\n" and "bbb\n" become two
864        // chunks under max_chunk_chars = 4. The second chunk fails.
865        let transcript = "aaa\nbbb\n";
866        let chunking = ChunkingDistiller::new(FailsOnDistiller { trigger: "bbb" }, 4);
867
868        let result = chunking.distill(transcript);
869
870        assert!(result.is_err());
871    }
872}