1#[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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DistilledMemory {
27 pub summary: String,
29 pub facts: Vec<Fact>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Fact {
36 pub kind: FactKind,
38 pub text: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46#[non_exhaustive]
47pub enum FactKind {
48 Decision,
50 Correction,
52 Preference,
54 State,
56}
57
58pub const MAX_FACTS: usize = 64;
61pub const MAX_FACT_CHARS: usize = 2_048;
64pub const MAX_SUMMARY_CHARS: usize = 8_192;
67
68fn 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
78pub(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#[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 facts.push(fact);
130 }
131 }
132 }
133
134 cap_distilled_memory(DistilledMemory {
135 summary: summaries.join("\n\n"),
136 facts,
137 })
138}
139
140#[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
212fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
240pub enum ReduceStrategy {
241 #[default]
244 Structural,
245 Llm,
248}
249
250fn 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
269fn 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
298pub trait Distiller: Send + Sync {
303 fn distill(&self, transcript: &str) -> Result<DistilledMemory>;
320}
321
322pub struct ChunkingDistiller<D: Distiller> {
337 inner: D,
338 max_chunk_chars: usize,
339 reduce: ReduceStrategy,
340}
341
342impl<D: Distiller> ChunkingDistiller<D> {
343 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 #[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 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 facts: vec![fact("we decided to roll back the deploy. ")],
523 };
524
525 let merged = merge_distilled(vec![part1, part2]);
526
527 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 assert_eq!(merged.facts.len(), 2);
552 }
553
554 #[test]
555 fn merge_joins_summaries_with_blank_line_then_caps() {
556 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 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 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 assert_eq!(result.summary, "Consolidated summary.");
747 }
748
749 #[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 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 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 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 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}