Skip to main content

context_forge/
lib.rs

1//! `context-forge` — a local-first persistent memory library for LLM applications.
2//!
3//! This crate provides turso + Tantivy BM25 retrieval, recency-decay scoring, and
4//! token-budget-aware context assembly with no network calls. It is intended to
5//! be embedded in larger applications (CLI tools, bots, agent runtimes) that need
6//! durable, searchable memory.
7//!
8//! # Quick start
9//!
10//! ```no_run
11//! use context_forge::{kind, ContextForge, Config, SaveOptions};
12//! use std::path::PathBuf;
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), context_forge::Error> {
16//!     let mut config = Config::default();
17//!     config.db_path = PathBuf::from(":memory:");
18//!
19//!     let cf = ContextForge::open(config).await?;
20//!     cf.save("the deploy failure was caused by a missing env var", kind::SNAPSHOT, &SaveOptions::default()).await?;
21//!
22//!     let hits = cf.query("deploy failure", None, 2048).await?;
23//!     assert_eq!(hits.len(), 1);
24//!     Ok(())
25//! }
26//! ```
27//!
28//! # Security
29//!
30//! **Retrieved entries are untrusted text.** Content persisted from past
31//! conversations may contain adversarial instructions (stored prompt
32//! injection) — whatever was saved into the store, including text that
33//! originated from another user or from a tool's output, comes back out
34//! verbatim on [`ContextForge::query`] (aside from save-time secret
35//! scrubbing, see below).
36//!
37//! Callers **MUST** present retrieved memory to models as quoted data
38//! (e.g. inside a fenced or otherwise clearly delimited context block
39//! labeled as history), **never** as system-level instructions, and
40//! **MUST NOT** execute or evaluate anything found in it.
41//!
42//! ## Save-time secret scrubbing
43//!
44//! [`ContextForge::save`] applies [`scrub_secrets`] to `content` before it
45//! is persisted, using the [`ScrubConfig`] supplied in [`Config::scrub`].
46//! This redacts common credential formats (cloud provider keys, API
47//! tokens, private key blocks, JWTs, bearer tokens) with
48//! `[REDACTED:<label>]` placeholders so they never reach the database or
49//! the search index. Scrubbing is **on by default** and can be disabled
50//! via `Config { scrub: ScrubConfig { enabled: false }, .. }` — this is an
51//! explicit, non-silent opt-out.
52//!
53//! Note that [`SaveOptions::metadata`] is **not** scrubbed (see its docs).
54
55#![warn(clippy::pedantic)]
56#![warn(missing_docs)]
57
58/// [`ContextForgeBuilder`] — the opinionated construction path for [`ContextForge`].
59pub mod builder;
60/// Engine and scrub configuration types (`Config`, `EvictionPolicy`, `ScrubConfig`).
61pub mod config;
62/// Local-LLM distillation trait and the optional `distill-http` implementation.
63pub mod distill;
64/// `ContextEngine`: search, recency decay, and token-budget assembly.
65pub mod engine;
66/// `ContextEntry`, `ScoredEntry`, and the `kind` constants module.
67pub mod entry;
68/// The crate's `Error` type.
69pub mod error;
70/// Save-time secret scrubbing (`scrub_secrets`, `ScrubConfig`).
71pub mod scrub;
72/// Session grouping helpers (`group_entries_by_session`, `SessionGroup`).
73pub mod session;
74/// Turso-backed storage and search implementations.
75pub mod storage;
76/// `ContextStorage` and `Searcher` traits, and the crate's `Result` alias.
77pub mod traits;
78
79/// Lexicon-based importance scoring — [`LexiconScorer`] trait, [`ConfigLexiconScorer`],
80/// [`DefaultEnglishScorer`], [`CompositeLexiconScorer`], [`LexiconAppender`], and
81/// [`LexiconProposal`].
82pub mod lexicon;
83
84/// Dense-vector embedding abstraction for semantic search.
85///
86/// The [`semantic::Embedder`] trait is always available. [`semantic::FasEmbedder`]
87/// (fastembed + ONNX Runtime, all-MiniLM-L6-v2) is compiled only with the
88/// `semantic` Cargo feature.
89pub mod semantic;
90
91/// Importance-detection pipeline (tokenizer, lexicon, scoring). Pure
92/// computation, no I/O. Enabled by the `analysis` feature (default).
93#[cfg(feature = "analysis")]
94pub mod analysis;
95
96#[cfg(feature = "parallel")]
97pub use analysis::with_thread_cap;
98
99use std::path::Path;
100
101// Re-export primary types at crate root for convenience.
102pub use builder::ContextForgeBuilder;
103pub use config::{Config, EvictionPolicy};
104pub use distill::{
105    merge_distilled, split_on_budget, ChunkingDistiller, DistilledMemory, Distiller, Fact,
106    FactKind, ReduceStrategy,
107};
108pub use engine::{ContextEngine, SaveOptions, MATCH_ALL_QUERY};
109pub use entry::{kind, ContextEntry, ScoredEntry};
110pub use error::Error;
111pub use lexicon::{
112    bootstrap_prompt, CompositeLexiconScorer, ConfigLexiconScorer, DefaultEnglishScorer,
113    LexiconAppender, LexiconConfig, LexiconPatterns, LexiconProposal, LexiconScorer,
114};
115pub use scrub::{scrub_secrets, ScrubConfig};
116pub use semantic::Embedder;
117#[cfg(feature = "semantic")]
118pub use semantic::FasEmbedder;
119pub use session::{group_entries_by_session, SessionGroup};
120pub use storage::{open_storage, TursoSearcher, TursoStorage};
121pub use traits::{ContextStorage, Result, Searcher};
122
123/// The documented entry point for `context-forge`.
124///
125/// `ContextForge` wires together a [`TursoStorage`] backend, a
126/// [`TursoSearcher`], and a [`ContextEngine`] behind a small,
127/// stable API surface. Advanced callers that need direct access to the
128/// underlying storage or searcher can construct those types directly and
129/// pass them to [`ContextEngine::new`] instead.
130pub struct ContextForge {
131    engine: ContextEngine,
132    scrub_config: ScrubConfig,
133}
134
135impl ContextForge {
136    /// Construct from already-built parts. Used by [`ContextForgeBuilder`].
137    pub(crate) fn from_parts(engine: ContextEngine, scrub_config: ScrubConfig) -> Self {
138        Self {
139            engine,
140            scrub_config,
141        }
142    }
143
144    /// Create a builder for `ContextForge`.
145    ///
146    /// Lexicon scoring is **opt-in**: by default the engine ranks on relevance
147    /// (BM25, plus semantic when an embedding model is set), with no lexicon
148    /// layer. Enable it with
149    /// [`ContextForgeBuilder::with_default_english_scorer`] and/or
150    /// [`ContextForgeBuilder::with_persona_scorer`]. [`Self::open`] is the
151    /// lower-level path that likewise wires no scorer.
152    #[must_use]
153    pub fn builder(config: Config) -> ContextForgeBuilder {
154        ContextForgeBuilder::new(config)
155    }
156
157    /// Open (or create) the database at `config.db_path`, run any pending
158    /// migrations, and build the engine.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the database cannot be opened or migrations fail.
163    pub async fn open(config: Config) -> Result<Self> {
164        let db_path = config.db_path.clone();
165        let max_entries = config.max_entries;
166        let scrub_config = config.scrub.clone();
167        let (storage, searcher) = open_storage(Path::new(&db_path), max_entries).await?;
168        let engine = ContextEngine::new(Box::new(storage), Box::new(searcher), config);
169        Ok(Self {
170            engine,
171            scrub_config,
172        })
173    }
174
175    /// Save a new entry. Returns the generated entry ID.
176    ///
177    /// `kind` is a caller-defined classification (see [`mod@kind`] for
178    /// well-known values). Capacity enforcement (LRU eviction) is handled
179    /// atomically by the storage layer.
180    ///
181    /// Before persistence, `content` is passed through [`scrub_secrets`]
182    /// using this instance's [`ScrubConfig`] (see [`Config::scrub`]),
183    /// redacting common credential formats with `[REDACTED:<label>]`
184    /// placeholders. `opts.metadata` is stored verbatim and is **not**
185    /// scrubbed — see [`SaveOptions::metadata`].
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if `content` is empty or if the underlying storage
190    /// write fails.
191    pub async fn save(&self, content: &str, kind: &str, opts: &SaveOptions) -> Result<String> {
192        let scrubbed = scrub_secrets(content, &self.scrub_config);
193        self.engine.save_snapshot(&scrubbed, kind, opts).await
194    }
195
196    /// Save many entries in one batch, committing the search index **once** for
197    /// the whole batch instead of once per entry — much cheaper for bulk ingest.
198    ///
199    /// Each item is `(content, kind, options)`, handled exactly as
200    /// [`save`](Self::save) (content is scrubbed per item). Returns the generated
201    /// ids in the same order as `items`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if any item's `content` is empty or if the underlying
206    /// storage write fails. On failure, entries written before the failure may
207    /// remain persisted.
208    pub async fn save_batch(&self, items: &[(String, String, SaveOptions)]) -> Result<Vec<String>> {
209        let scrubbed: Vec<(String, String, SaveOptions)> = items
210            .iter()
211            .map(|(content, kind, opts)| {
212                (
213                    scrub_secrets(content, &self.scrub_config).into_owned(),
214                    kind.clone(),
215                    opts.clone(),
216                )
217            })
218            .collect();
219        self.engine.save_snapshot_batch(&scrubbed).await
220    }
221
222    /// Distill `transcript` into a summary and durable facts, then save
223    /// them as separate entries sharing `opts.scope` and
224    /// `opts.session_id`.
225    ///
226    /// `transcript` is passed through [`scrub_secrets`] **before** it is
227    /// sent to `distiller` (so secrets never reach a distillation
228    /// endpoint), and the summary/facts produced are scrubbed again before
229    /// persistence via the normal [`ContextForge::save`] path (defense in
230    /// depth).
231    ///
232    /// The summary is saved with `kind::SUMMARY`; each fact is saved with
233    /// `kind::FACT` and metadata `{"fact_kind": "<kind>", "source":
234    /// "distill"}`.
235    ///
236    /// The distilled output is bounded before any of it is saved: at most
237    /// [`MAX_FACTS`](crate::distill::MAX_FACTS) facts are kept, each fact's
238    /// text is truncated to at most
239    /// [`MAX_FACT_CHARS`](crate::distill::MAX_FACT_CHARS) characters, and the
240    /// summary is truncated to at most
241    /// [`MAX_SUMMARY_CHARS`](crate::distill::MAX_SUMMARY_CHARS) characters.
242    /// Excess facts and text beyond these limits are silently dropped or
243    /// truncated, since they are untrusted model-generated content.
244    ///
245    /// Returns the IDs of the saved entries: the summary's ID first,
246    /// followed by each fact's ID in order.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if distillation fails, or if any save fails.
251    pub async fn distill_and_save(
252        &self,
253        transcript: &str,
254        distiller: &dyn Distiller,
255        opts: &SaveOptions,
256    ) -> Result<Vec<String>> {
257        let scrubbed_transcript = scrub_secrets(transcript, &self.scrub_config);
258        let memory = tokio::task::block_in_place(|| distiller.distill(&scrubbed_transcript))?;
259        let memory = crate::distill::cap_distilled_memory(memory);
260
261        // Build all entries up front, then persist them in one batch so the
262        // search index commits once (not once per summary + fact). Content is
263        // scrubbed here — the same scrubbing `save` applies — before storage.
264        let mut items: Vec<(String, String, SaveOptions)> =
265            Vec::with_capacity(1 + memory.facts.len());
266        items.push((
267            scrub_secrets(&memory.summary, &self.scrub_config).into_owned(),
268            kind::SUMMARY.to_owned(),
269            opts.clone(),
270        ));
271
272        for fact in &memory.facts {
273            let fact_kind_str = match fact.kind {
274                FactKind::Decision => "decision",
275                FactKind::Correction => "correction",
276                FactKind::Preference => "preference",
277                FactKind::State => "state",
278            };
279            let metadata = serde_json::json!({
280                "fact_kind": fact_kind_str,
281                "source": "distill",
282            });
283            let fact_opts = SaveOptions {
284                session_id: opts.session_id.clone(),
285                scope: opts.scope.clone(),
286                metadata: Some(metadata),
287            };
288            items.push((
289                scrub_secrets(&fact.text, &self.scrub_config).into_owned(),
290                kind::FACT.to_owned(),
291                fact_opts,
292            ));
293        }
294
295        let ids = self.engine.save_snapshot_batch(&items).await?;
296        Ok(ids)
297    }
298
299    /// Assemble entries matching `query` that fit within `token_budget`.
300    ///
301    /// `scope = None` searches every entry regardless of scope (global
302    /// recall). `scope = Some(s)` restricts the search to entries whose
303    /// `scope` equals `s`.
304    ///
305    /// `query` is treated as natural-language text: it is split into
306    /// alphanumeric terms which are OR-matched and ranked by bm25 relevance.
307    /// FTS5 operator syntax (`AND`, `OR`, `NEAR`, prefix `*`, quoted phrases,
308    /// column filters, etc.) is **not** interpreted — operator characters are
309    /// treated as term separators, so arbitrary user text never produces a
310    /// query syntax error. A query with no alphanumeric terms (empty or
311    /// punctuation-only) returns an empty result set rather than an error.
312    /// The special value [`MATCH_ALL_QUERY`] (`"*"`) matches every entry.
313    ///
314    /// # Errors
315    ///
316    /// Returns an error if the search or recency-weighting step fails.
317    pub async fn query(
318        &self,
319        query: &str,
320        scope: Option<&str>,
321        token_budget: usize,
322    ) -> Result<Vec<ContextEntry>> {
323        self.engine.assemble(query, scope, token_budget).await
324    }
325
326    /// Delete a single entry by id. Returns `true` if an entry was removed.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the underlying storage delete fails.
331    pub async fn delete(&self, id: &str) -> Result<bool> {
332        self.engine.storage().delete(id).await
333    }
334
335    /// Remove all entries within a given scope. Returns the number of
336    /// entries removed.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error if the underlying storage delete fails.
341    pub async fn clear_scope(&self, scope: &str) -> Result<usize> {
342        self.engine.storage().clear_scope(scope).await
343    }
344
345    /// Remove all entries. Returns the number of entries removed.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if the underlying storage delete fails.
350    pub async fn clear_all(&self) -> Result<usize> {
351        self.engine.storage().clear().await
352    }
353
354    /// Return the total number of stored entries.
355    ///
356    /// # Errors
357    ///
358    /// Returns an error if the underlying storage count fails.
359    pub async fn count(&self) -> Result<usize> {
360        self.engine.storage().count().await
361    }
362
363    /// Backfill embeddings for all entries that do not yet have one.
364    ///
365    /// Call this once after enabling semantic search on an existing database to
366    /// index historical entries. New entries are embedded automatically at save
367    /// time, so backfill is only needed for pre-existing data.
368    ///
369    /// `batch_size` controls how many entries are embedded per ONNX inference
370    /// call (recommended: 32). `progress` is called after each batch with
371    /// `(done, total)`. Returns the number of entries successfully embedded.
372    ///
373    /// Does nothing and returns `Ok(0)` if no embedder is configured or if all
374    /// entries already have embeddings.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if fetching unembedded entries fails or if the
379    /// embedding task panics.
380    #[cfg(feature = "semantic")]
381    pub async fn backfill_embeddings(
382        &self,
383        batch_size: usize,
384        progress: impl Fn(usize, usize),
385    ) -> Result<usize> {
386        self.engine.backfill_embeddings(batch_size, progress).await
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::path::PathBuf;
394
395    #[test]
396    fn context_entry_json_roundtrip() {
397        let entry = ContextEntry {
398            id: "e1".into(),
399            content: "hello world".into(),
400            timestamp: 1_700_000_000,
401            kind: kind::MANUAL.to_owned(),
402            scope: None,
403            session_id: None,
404            token_count: Some(3),
405            metadata: None,
406        };
407        let json = serde_json::to_string(&entry).unwrap();
408        let back: ContextEntry = serde_json::from_str(&json).unwrap();
409        assert_eq!(back.id, "e1");
410        assert_eq!(back.token_count, Some(3));
411    }
412
413    #[test]
414    fn error_display_messages() {
415        let invalid = Error::InvalidEntry("empty content".into());
416        assert_eq!(invalid.to_string(), "invalid entry: empty content");
417
418        let migration = Error::Migration("schema mismatch".into());
419        assert_eq!(migration.to_string(), "migration error: schema mismatch");
420
421        let distill = Error::Distill("model unavailable".into());
422        assert_eq!(distill.to_string(), "distillation error: model unavailable");
423    }
424
425    #[test]
426    fn core_config_json_roundtrip() {
427        let cfg = Config {
428            max_entries: 1000,
429            token_budget: 8192,
430            db_path: PathBuf::from("/tmp/cf.db"),
431            eviction_policy: EvictionPolicy::Lru,
432            recency_half_life_secs: 259_200.0,
433            ..Config::default()
434        };
435        let json = serde_json::to_string(&cfg).unwrap();
436        let back: Config = serde_json::from_str(&json).unwrap();
437        assert_eq!(back.max_entries, 1000);
438        assert_eq!(back.eviction_policy, EvictionPolicy::Lru);
439    }
440
441    #[test]
442    fn trait_objects_are_object_safe() {
443        // This test verifies that the traits compile as trait objects.
444        fn _assert_storage(_s: Box<dyn ContextStorage>) {}
445        fn _assert_searcher(_s: Box<dyn Searcher>) {}
446    }
447
448    #[test]
449    fn kind_constants_are_distinct() {
450        assert_ne!(kind::MANUAL, kind::SNAPSHOT);
451        assert_ne!(kind::MANUAL, kind::SUMMARY);
452        assert_ne!(kind::MANUAL, kind::FACT);
453        assert_ne!(kind::SNAPSHOT, kind::SUMMARY);
454        assert_ne!(kind::SNAPSHOT, kind::FACT);
455        assert_ne!(kind::SUMMARY, kind::FACT);
456    }
457
458    #[test]
459    fn scored_entry_json_roundtrip() {
460        let scored = ScoredEntry {
461            entry: ContextEntry {
462                id: "s1".into(),
463                content: "search hit".into(),
464                timestamp: 1_700_000_001,
465                kind: kind::SUMMARY.to_owned(),
466                scope: None,
467                session_id: None,
468                token_count: None,
469                metadata: None,
470            },
471            score: 0.95,
472        };
473        let json = serde_json::to_string(&scored).unwrap();
474        let back: ScoredEntry = serde_json::from_str(&json).unwrap();
475        assert_eq!(back.entry.id, "s1");
476        assert!((back.score - 0.95).abs() < f64::EPSILON);
477    }
478
479    #[test]
480    fn context_forge_is_send_sync() {
481        fn assert_send_sync<T: Send + Sync>() {}
482        assert_send_sync::<ContextForge>();
483    }
484
485    #[tokio::test]
486    async fn context_forge_open_save_query_roundtrip() {
487        let config = Config {
488            db_path: PathBuf::from(":memory:"),
489            ..Config::default()
490        };
491        let cf = ContextForge::open(config).await.unwrap();
492
493        let id = cf
494            .save("hello world", kind::MANUAL, &SaveOptions::default())
495            .await
496            .unwrap();
497        assert!(!id.is_empty());
498        assert_eq!(cf.count().await.unwrap(), 1);
499
500        let hits = cf.query("hello", None, 1000).await.unwrap();
501        assert_eq!(hits.len(), 1);
502        assert_eq!(hits[0].id, id);
503
504        assert!(cf.delete(&id).await.unwrap());
505        assert_eq!(cf.count().await.unwrap(), 0);
506    }
507
508    #[tokio::test]
509    async fn context_forge_clear_all() {
510        let config = Config {
511            db_path: PathBuf::from(":memory:"),
512            ..Config::default()
513        };
514        let cf = ContextForge::open(config).await.unwrap();
515
516        cf.save("a", kind::MANUAL, &SaveOptions::default())
517            .await
518            .unwrap();
519        cf.save("b", kind::MANUAL, &SaveOptions::default())
520            .await
521            .unwrap();
522
523        let cleared = cf.clear_all().await.unwrap();
524        assert_eq!(cleared, 2);
525        assert_eq!(cf.count().await.unwrap(), 0);
526    }
527
528    /// A stub [`Distiller`] for tests that records the transcript it was
529    /// called with and returns a fixed [`DistilledMemory`].
530    struct StubDistiller {
531        transcript: std::sync::Mutex<Option<String>>,
532    }
533
534    impl StubDistiller {
535        fn new() -> Self {
536            Self {
537                transcript: std::sync::Mutex::new(None),
538            }
539        }
540    }
541
542    impl Distiller for StubDistiller {
543        fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
544            *self.transcript.lock().unwrap() = Some(transcript.to_owned());
545            Ok(DistilledMemory {
546                summary: "User decided to roll back the deploy.".to_owned(),
547                facts: vec![
548                    Fact {
549                        kind: FactKind::Decision,
550                        text: "We decided to roll back the deploy.".to_owned(),
551                    },
552                    Fact {
553                        kind: FactKind::Preference,
554                        text: "The user prefers terse commit messages.".to_owned(),
555                    },
556                ],
557            })
558        }
559    }
560
561    #[tokio::test(flavor = "multi_thread")]
562    async fn distill_and_save_scrubs_saves_and_returns_ids() {
563        let config = Config {
564            db_path: PathBuf::from(":memory:"),
565            ..Config::default()
566        };
567        let cf = ContextForge::open(config).await.unwrap();
568
569        let distiller = StubDistiller::new();
570        let transcript = "Here is a secret key=AKIAABCDEFGHIJKLMNOP end of transcript";
571
572        let opts = SaveOptions {
573            session_id: Some("sess-1".to_owned()),
574            scope: Some("project:test".to_owned()),
575            metadata: None,
576        };
577
578        let ids = cf
579            .distill_and_save(transcript, &distiller, &opts)
580            .await
581            .unwrap();
582
583        // Summary ID first, then one ID per fact.
584        assert_eq!(ids.len(), 3);
585        for id in &ids {
586            assert!(!id.is_empty());
587        }
588
589        // The transcript reaching the distiller was scrubbed.
590        let seen = distiller.transcript.lock().unwrap().clone().unwrap();
591        assert!(seen.contains("[REDACTED:aws-key]"));
592        assert!(!seen.contains("AKIAABCDEFGHIJKLMNOP"));
593
594        // Summary saved with kind::SUMMARY.
595        let summary = cf
596            .query("rollback OR rollback OR roll", None, 10_000)
597            .await
598            .unwrap();
599        let summary_entry = summary
600            .iter()
601            .find(|e| e.id == ids[0])
602            .expect("summary entry present");
603        assert_eq!(summary_entry.kind, kind::SUMMARY);
604        assert_eq!(summary_entry.scope.as_deref(), Some("project:test"));
605        assert_eq!(summary_entry.session_id.as_deref(), Some("sess-1"));
606
607        // Facts saved with kind::FACT and the right metadata.
608        let all = cf.query(MATCH_ALL_QUERY, None, 100_000).await.unwrap();
609        for fact_id in &ids[1..] {
610            let fact_entry = all
611                .iter()
612                .find(|e| &e.id == fact_id)
613                .expect("fact entry present");
614            assert_eq!(fact_entry.kind, kind::FACT);
615            let metadata = fact_entry.metadata.as_ref().expect("metadata present");
616            assert_eq!(metadata["source"], "distill");
617            assert!(metadata["fact_kind"].is_string());
618        }
619    }
620
621    /// A stub [`Distiller`] that returns a fixed number of facts, used to
622    /// verify that [`ContextForge::distill_and_save`] caps excess facts
623    /// before persisting them.
624    struct ManyFactsDistiller {
625        fact_count: usize,
626    }
627
628    impl Distiller for ManyFactsDistiller {
629        fn distill(&self, _transcript: &str) -> Result<DistilledMemory> {
630            let facts = (0..self.fact_count)
631                .map(|i| Fact {
632                    kind: FactKind::State,
633                    text: format!("fact number {i}"),
634                })
635                .collect();
636            Ok(DistilledMemory {
637                summary: "summary".to_owned(),
638                facts,
639            })
640        }
641    }
642
643    #[tokio::test(flavor = "multi_thread")]
644    async fn distill_and_save_caps_excess_facts() {
645        let config = Config {
646            db_path: PathBuf::from(":memory:"),
647            ..Config::default()
648        };
649        let cf = ContextForge::open(config).await.unwrap();
650
651        let distiller = ManyFactsDistiller {
652            fact_count: crate::distill::MAX_FACTS + 20,
653        };
654
655        let ids = cf
656            .distill_and_save("transcript", &distiller, &SaveOptions::default())
657            .await
658            .unwrap();
659
660        // Summary ID first, then one ID per capped fact.
661        assert_eq!(ids.len(), 1 + crate::distill::MAX_FACTS);
662        assert_eq!(cf.count().await.unwrap(), 1 + crate::distill::MAX_FACTS);
663    }
664}