Skip to main content

lean_ctx/core/
consolidation_engine.rs

1//! Canonical session→knowledge consolidation (#995 Phase 4).
2//!
3//! One session-import core (`import_session_into`) and one option set
4//! ([`ConsolidateOptions`]) back every consolidation driver — the CLI/MCP
5//! `consolidate`, the post-dispatch scheduled pass, startup auto-consolidate and
6//! the cognition loop — so promotion budgets, fact keys, confidences and the
7//! lossless capacity reclaim stay identical regardless of who triggers a run.
8//!
9//! The full, locked orchestrator (import + history + lifecycle + per-store
10//! reclaim + report) lives in
11//! `ctx_knowledge::consolidate_project_knowledge_with`; this module owns the
12//! shared import primitive plus the thin `scheduled` wrapper the background
13//! drivers call.
14
15use chrono::{DateTime, Utc};
16
17use crate::core::archive::bindings::{self, Item};
18use crate::core::knowledge::{KnowledgeFact, ProjectKnowledge};
19use crate::core::memory_policy::MemoryPolicy;
20use crate::core::session::{Finding, SessionState};
21
22/// Promotion budgets for the scheduled (post-dispatch / cognition) pass.
23#[derive(Debug, Clone, Copy)]
24pub struct ConsolidationBudgets {
25    pub max_decisions: usize,
26    pub max_findings: usize,
27}
28
29impl Default for ConsolidationBudgets {
30    fn default() -> Self {
31        Self {
32            max_decisions: 5,
33            max_findings: 8,
34        }
35    }
36}
37
38/// Leaner outcome kept for the scheduled callers (post_dispatch / tool_lifecycle)
39/// that only need the promotion + lifecycle headline, not the full report.
40#[derive(Debug, Clone)]
41pub struct ConsolidationOutcome {
42    pub promoted: u32,
43    pub promoted_decisions: u32,
44    pub promoted_findings: u32,
45    pub lifecycle_archived: usize,
46    pub lifecycle_remaining: usize,
47}
48
49/// How a consolidation run imports the session and reclaims capacity. One option
50/// set per driver — see the constructors. Replaces the four divergent, copy-pasted
51/// import loops (each with subtly different keys, caps and confidences).
52#[derive(Debug, Clone)]
53pub struct ConsolidateOptions {
54    /// Promote the latest session's findings/decisions into knowledge.
55    pub import_session: bool,
56    /// Cap promoted decisions (`None` = all).
57    pub decision_budget: Option<usize>,
58    /// Cap promoted findings (`None` = all).
59    pub finding_budget: Option<usize>,
60    /// Skip findings below this salience score (`None` = import all).
61    pub finding_salience_floor: Option<u32>,
62    /// Confidence assigned to imported decisions.
63    pub decision_confidence: f32,
64    /// Confidence assigned to imported findings.
65    pub finding_confidence: f32,
66    /// Import only items newer than the session watermark and advance it after
67    /// (incremental auto-consolidate).
68    pub incremental: bool,
69    /// Run the fact lifecycle (decay / dedup / quality + capacity) after import.
70    pub run_lifecycle: bool,
71    /// Run the lossless capacity reclaim for history / procedures / patterns.
72    pub reclaim_stores: bool,
73    /// Emit a `KnowledgeUpdate` event after a successful (non-dry) run.
74    pub emit_event: bool,
75    /// Compute the report without mutating knowledge, archives or the session.
76    pub dry_run: bool,
77}
78
79impl ConsolidateOptions {
80    /// Explicit CLI / MCP `consolidate`: import everything, full lifecycle and a
81    /// lossless reclaim of every store. Applies a salience floor to keep
82    /// low-signal auto-findings ("Read file.txt") out of knowledge.
83    pub fn manual() -> Self {
84        Self {
85            import_session: true,
86            decision_budget: None,
87            finding_budget: None,
88            finding_salience_floor: Some(45),
89            decision_confidence: 0.85,
90            finding_confidence: 0.7,
91            incremental: false,
92            run_lifecycle: true,
93            reclaim_stores: true,
94            emit_event: false,
95            dry_run: false,
96        }
97    }
98
99    /// Scheduled background pass (post-dispatch / cognition): salience-gated,
100    /// budgeted, runs the fact lifecycle and emits an event.
101    pub fn scheduled(b: ConsolidationBudgets) -> Self {
102        Self {
103            import_session: true,
104            decision_budget: Some(b.max_decisions),
105            finding_budget: Some(b.max_findings),
106            finding_salience_floor: Some(45),
107            decision_confidence: 0.9,
108            finding_confidence: 0.75,
109            incremental: false,
110            run_lifecycle: true,
111            reclaim_stores: false,
112            emit_event: true,
113            dry_run: false,
114        }
115    }
116
117    /// Startup auto-consolidate: incremental (watermark) import only, no lifecycle.
118    pub fn incremental_auto() -> Self {
119        Self {
120            import_session: true,
121            decision_budget: None,
122            finding_budget: None,
123            finding_salience_floor: Some(45),
124            decision_confidence: 0.85,
125            finding_confidence: 0.7,
126            incremental: true,
127            run_lifecycle: false,
128            reclaim_stores: false,
129            emit_event: false,
130            dry_run: false,
131        }
132    }
133
134    /// Same plan, but preview-only: no writes to knowledge, archives or session.
135    #[must_use]
136    pub fn into_dry_run(mut self) -> Self {
137        self.dry_run = true;
138        self
139    }
140}
141
142/// Counts of items promoted by a single `import_session_into` call.
143#[derive(Debug, Default, Clone, Copy)]
144pub struct ImportCounts {
145    pub decisions: usize,
146    pub findings: usize,
147}
148
149impl ImportCounts {
150    pub fn total(self) -> usize {
151        self.decisions + self.findings
152    }
153}
154
155/// The single session→knowledge import. Operates on an already-locked
156/// `knowledge` (no nested knowledge lock), so both the locked orchestrator and the
157/// cognition loop — which holds the knowledge lock across all its steps — share
158/// one implementation. `watermark` (incremental mode) imports only newer items.
159pub fn import_session_into(
160    knowledge: &mut ProjectKnowledge,
161    session: &SessionState,
162    opts: &ConsolidateOptions,
163    policy: &MemoryPolicy,
164    watermark: Option<DateTime<Utc>>,
165) -> ImportCounts {
166    let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
167
168    let mut decisions: Vec<&crate::core::session::Decision> = session
169        .decisions
170        .iter()
171        .filter(|d| is_new(d.timestamp))
172        .collect();
173    decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
174    let mut findings: Vec<&Finding> = session
175        .findings
176        .iter()
177        .filter(|f| is_new(f.timestamp))
178        .collect();
179    findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
180    if let Some(n) = opts.decision_budget {
181        decisions.truncate(n);
182    }
183    let mut decision_count = 0;
184    for d in &decisions {
185        let key = slug_key(&d.summary, 50);
186        knowledge.remember(
187            "decision",
188            &key,
189            &d.summary,
190            &session.id,
191            opts.decision_confidence,
192            policy,
193        );
194        decision_count += 1;
195    }
196
197    let mut finding_count = 0;
198    for f in &findings {
199        if opts.finding_budget.is_some_and(|n| finding_count >= n) {
200            break;
201        }
202        if crate::core::auto_findings::is_activity_record(f.summary.trim()) {
203            continue;
204        }
205        if let Some(floor) = opts.finding_salience_floor
206            && crate::core::memory_salience::text_salience(&f.summary) < floor
207        {
208            continue;
209        }
210        let key = finding_key(f);
211        // The fact's value is the finding's summary: it keeps the finding's
212        // source binding, and is not stored when that binding does not hold.
213        let stored = store_derived(
214            knowledge,
215            &[finding_item(session, f)],
216            session.project_root.as_deref(),
217            ["finding", &key, &f.summary],
218            opts.dry_run,
219            |knowledge| {
220                knowledge.remember(
221                    "finding",
222                    &key,
223                    &f.summary,
224                    &session.id,
225                    opts.finding_confidence,
226                    policy,
227                );
228            },
229        );
230        if stored {
231            finding_count += 1;
232        }
233    }
234
235    if opts.import_session {
236        store_session_facts(knowledge, session, opts.dry_run);
237    }
238
239    ImportCounts {
240        decisions: decision_count,
241        findings: finding_count,
242    }
243}
244
245fn finding_item<'a>(session: &'a SessionState, finding: &'a Finding) -> (Item<'a>, &'a str) {
246    let item = Item::SessionFinding {
247        session: &session.id,
248        file: finding.file.as_deref(),
249    };
250    (item, finding.summary.as_str())
251}
252
253/// Store the facts a session derives (`SessionState::derived_facts`). A fact
254/// built from a finding keeps that finding's source binding ([`store_derived`]);
255/// a decision and a touched-file record have none, and are stored as before.
256/// `dry_run`: the store is a throwaway copy, no binding is written.
257pub(crate) fn store_session_facts(
258    knowledge: &mut ProjectKnowledge,
259    session: &SessionState,
260    dry_run: bool,
261) {
262    for (fact, finding) in session.derived_facts() {
263        let Some(finding) = finding else {
264            knowledge.add_fact(fact);
265            continue;
266        };
267        let (category, key, value) = (fact.category.clone(), fact.key.clone(), fact.value.clone());
268        store_derived(
269            knowledge,
270            &[finding_item(session, finding)],
271            session.project_root.as_deref(),
272            [&category, &key, &value],
273            dry_run,
274            |knowledge| {
275                knowledge.add_fact(fact);
276            },
277        );
278    }
279}
280
281/// Store a fact (`store`) whose `text` is DERIVED from other stored items —
282/// `origins`, each with the text its store keeps — and carry their source
283/// bindings over to it (`archive::bindings::carry`). Returns whether the fact
284/// was stored (in a dry run: would be).
285///
286/// - every origin has a binding that holds now: the fact is stored and bound
287///   to the same files, read again now. Its recall is then re-checked against
288///   those files like a fact remembered with `sources`;
289/// - an origin has no binding: the fact is stored originless, as before;
290/// - while protection is active, an origin that is bound and not served now,
291///   a source that no longer admits, or a text the store would not keep
292///   byte-identical: NOTHING is stored. An unbound copy of text whose source
293///   is withheld would be delivered where the origin is not.
294///
295/// A binding is written only after the store kept exactly `text` under
296/// `category`/`key`; if the register then refuses the write, the fact is
297/// taken out again. Without protection nothing is refused.
298pub(crate) fn store_derived(
299    knowledge: &mut ProjectKnowledge,
300    origins: &[(Item<'_>, &str)],
301    reader_project: Option<&str>,
302    fields: [&str; 3],
303    dry_run: bool,
304    store: impl FnOnce(&mut ProjectKnowledge),
305) -> bool {
306    let [category, key, text] = fields;
307    let root = knowledge.project_root.clone();
308    let fact = Item::KnowledgeFact {
309        project: &root,
310        category,
311        key,
312    };
313    let carried = bindings::carry(&fact, text, origins, reader_project);
314    store_carried(knowledge, carried, fields, dry_run, store)
315}
316
317/// `store_derived` with the carry already prepared by the caller, for a
318/// fact whose origin's binding is not in the register (`bindings::carry_from`:
319/// a fact restored from a handoff ledger). `carried` must be the carry of
320/// exactly `fields` for the fact item of this store's root. The rules are
321/// those of `store_derived`: `Err` stores nothing, `Ok(None)` stores the
322/// fact originless, `Ok(Some(_))` stores it and then writes its binding.
323pub fn store_carried(
324    knowledge: &mut ProjectKnowledge,
325    carried: Result<Option<bindings::Carried>, crate::core::context_admission::carrier::Withheld>,
326    [category, key, text]: [&str; 3],
327    dry_run: bool,
328    store: impl FnOnce(&mut ProjectKnowledge),
329) -> bool {
330    let enforced = crate::core::context_admission::stored::source_authority_required();
331    let root = knowledge.project_root.clone();
332    let fact = Item::KnowledgeFact {
333        project: &root,
334        category,
335        key,
336    };
337    let Ok(carried) = carried else {
338        return false;
339    };
340    let verbatim = carried.is_some() && knowledge.admits_verbatim([category, key, text]);
341    if enforced && carried.is_some() && !verbatim {
342        return false;
343    }
344    store(knowledge);
345    let kept = |stored: &KnowledgeFact| {
346        stored.is_current()
347            && stored.category == category
348            && stored.key == key
349            && stored.value == text
350    };
351    // AP01 §4: a machine-derived fact that carries source bindings or names
352    // files is about code at a revision (`content`), never a convention.
353    let names_files = carried.is_some()
354        || [key, text]
355            .iter()
356            .any(|part| !crate::core::content_chunk::extract_file_references(part).is_empty());
357    if names_files {
358        for stored in knowledge.facts.iter_mut().filter(|stored| kept(stored)) {
359            stored
360                .validity
361                .get_or_insert(crate::core::knowledge::FactValidity::Content);
362        }
363    }
364    let Some(carried) = carried.filter(|_| verbatim && !dry_run) else {
365        return true;
366    };
367    if !knowledge.facts.iter().any(kept) || carried.commit(&fact, text) || !enforced {
368        return true;
369    }
370    knowledge.facts.retain(|stored| !kept(stored));
371    knowledge.rebuild_index();
372    false
373}
374
375/// Stable knowledge key for a session finding: `file[:line]` when located, else a
376/// content slug. Content-based (never index-based), so re-imports upsert the same
377/// fact and the output stays deterministic across runs (#498).
378pub fn finding_key(f: &Finding) -> String {
379    match (&f.file, f.line) {
380        (Some(file), Some(line)) => format!("{file}:{line}"),
381        (Some(file), None) => file.clone(),
382        (None, _) => format!("finding-{}", slug_key(&f.summary, 36)),
383    }
384}
385
386/// Scheduled background consolidation. Thin wrapper over the canonical
387/// orchestrator with [`ConsolidateOptions::scheduled`]; kept for the
388/// post-dispatch / SDK callers and their `ConsolidationOutcome`. Imports only
389/// the caller's own session (`session::caller`); without a caller only
390/// project facts are consolidated — never the project's latest saved
391/// session, which may be another agent's (I07/S03).
392pub fn consolidate_latest(
393    project_root: &str,
394    budgets: ConsolidationBudgets,
395) -> Result<ConsolidationOutcome, String> {
396    let opts = ConsolidateOptions::scheduled(budgets);
397    let report = crate::core::knowledge_consolidate::consolidate_project_knowledge_with(
398        project_root,
399        &opts,
400    )?;
401
402    Ok(ConsolidationOutcome {
403        promoted: (report.imported_decisions + report.imported_findings) as u32,
404        promoted_decisions: report.imported_decisions as u32,
405        promoted_findings: report.imported_findings as u32,
406        lifecycle_archived: report.lifecycle.archived_count,
407        lifecycle_remaining: report.lifecycle.remaining_facts,
408    })
409}
410
411/// Deterministic, filesystem-safe slug for a fact key: lowercase alphanumerics,
412/// single dashes for separators, trimmed, capped at `max` bytes.
413pub fn slug_key(s: &str, max: usize) -> String {
414    let mut out = String::new();
415    for ch in s.chars() {
416        if out.len() >= max {
417            break;
418        }
419        if ch.is_ascii_alphanumeric() {
420            out.push(ch.to_ascii_lowercase());
421        } else if (ch.is_whitespace() || ch == '-' || ch == '_')
422            && !out.ends_with('-')
423            && !out.is_empty()
424        {
425            out.push('-');
426        }
427    }
428    out.trim_matches('-').to_string()
429}
430
431#[cfg(test)]
432pub mod tests {
433    use super::*;
434
435    #[test]
436    fn consolidate_promotes_decisions_and_salient_findings_only() {
437        let _lock = crate::core::data_dir::test_env_lock();
438        let tmp = tempfile::tempdir().expect("tempdir");
439        crate::test_env::set_var(
440            "LEAN_CTX_DATA_DIR",
441            tmp.path().to_string_lossy().to_string(),
442        );
443
444        let project_root = tmp.path().join("proj");
445        std::fs::create_dir_all(&project_root).expect("mkdir");
446        let project_root_str = project_root.to_string_lossy().to_string();
447
448        let mut session = SessionState::new();
449        session.project_root = Some(project_root_str.clone());
450        session.add_decision("Use archive-only memory lifecycle", None);
451        session.add_finding(None, None, "panic: index out of bounds");
452        session.add_finding(None, None, "just a note");
453        session.save().expect("save session");
454        let budgets = ConsolidationBudgets {
455            max_decisions: 5,
456            max_findings: 5,
457        };
458
459        // I07/S03: a run without a caller (background, SDK, CLI) promotes
460        // nothing from the saved session — it may be another agent's. It
461        // imported the project's latest session.
462        let out = consolidate_latest(&project_root_str, budgets).expect("consolidate");
463        assert_eq!(out.promoted, 0);
464        let leaked = ProjectKnowledge::load(&project_root_str)
465            .is_some_and(|k| k.facts.iter().any(|f| f.value.contains("archive-only")));
466        assert!(
467            !leaked,
468            "another agent's decision reached project knowledge"
469        );
470
471        // The session's own agent consolidates it.
472        let own = std::sync::Arc::new(tokio::sync::RwLock::new(session));
473        let out = crate::core::session::caller::scope(Some(own), || {
474            consolidate_latest(&project_root_str, budgets)
475        })
476        .expect("consolidate");
477        assert!(out.promoted_decisions >= 1);
478        assert!(out.promoted_findings >= 1);
479
480        let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
481        let active = k.facts.iter().filter(|f| f.is_current()).count();
482        assert!(active >= 2, "expected promoted facts");
483
484        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
485    }
486
487    #[test]
488    fn finding_key_is_content_based_and_deterministic() {
489        let f1 = Finding {
490            file: Some("src/main.rs".into()),
491            line: Some(42),
492            summary: "boom".into(),
493            timestamp: Utc::now(),
494        };
495        assert_eq!(finding_key(&f1), "src/main.rs:42");
496
497        let f2 = Finding {
498            file: None,
499            line: None,
500            summary: "Race condition in cache".into(),
501            timestamp: Utc::now(),
502        };
503        // Same content → same key (idempotent re-import, no index drift).
504        assert_eq!(finding_key(&f2), finding_key(&f2));
505        assert_eq!(finding_key(&f2), "finding-race-condition-in-cache");
506    }
507
508    use crate::core::archive::delivered::LineageRefusal;
509    use crate::core::context_admission::stored::lineage_support::{
510        deliver, governed, project, reusable, scoped,
511    };
512    use crate::core::policy::runtime::TestPolicyOverride;
513
514    const BOUND: &str = "panic: the pool fails to lease";
515    const TYPED: &str = "error: the deploy fails without a token";
516
517    /// [`governed`] with `auto_capture` switched on: the store keeps a
518    /// machine-derived fact (`auto:blocker`) only then (#1802,
519    /// `knowledge::persist::machine_derived_writes_allowed`), and a
520    /// configuration FILE that does not name the key reads as `false` — the
521    /// file `governed` writes switched it off.
522    fn governed_with_auto_capture() -> crate::core::data_dir::IsolatedDataDir {
523        let data = governed();
524        let dir = crate::core::paths::config_dir_read_only().expect("isolated config dir");
525        std::fs::write(
526            dir.join("config.toml"),
527            "auto_capture = true\n\n[context_gateway]\nmode = \"governed\"\n",
528        )
529        .expect("config");
530        data
531    }
532
533    /// A session with one finding bound to `pool.rs` and one without a file.
534    fn session_with_two_findings(root: &str) -> SessionState {
535        std::fs::write(format!("{root}/pool.rs"), "pub fn lease() {}\n").expect("source");
536        let mut session = SessionState::new();
537        session.project_root = Some(root.to_owned());
538        session.add_finding_for_file(Some("pool.rs"), Some(1), BOUND);
539        session.add_finding_for_file(None, None, TYPED);
540        session
541    }
542
543    /// Import `session` into a fresh in-memory store, every finding eligible.
544    fn import(
545        root: &str,
546        session: &SessionState,
547        dry_run: bool,
548    ) -> (ProjectKnowledge, ImportCounts) {
549        let options = ConsolidateOptions {
550            finding_salience_floor: None,
551            dry_run,
552            ..ConsolidateOptions::manual()
553        };
554        scoped(root, || {
555            let mut knowledge = ProjectKnowledge::new(root);
556            let counts = import_session_into(
557                &mut knowledge,
558                session,
559                &options,
560                &MemoryPolicy::default(),
561                None,
562            );
563            (knowledge, counts)
564        })
565    }
566
567    fn derived(knowledge: &ProjectKnowledge, category: &str, text: &str) -> Option<KnowledgeFact> {
568        knowledge
569            .facts
570            .iter()
571            .find(|fact| {
572                fact.is_current() && fact.category == category && fact.value.contains(text)
573            })
574            .cloned()
575    }
576
577    /// Deliver one stored fact the way every knowledge renderer serves it.
578    fn recall(root: &str, fact: &KnowledgeFact) -> (String, usize) {
579        deliver(root, || {
580            let item = Item::KnowledgeFact {
581                project: root,
582                category: &fact.category,
583                key: &fact.key,
584            };
585            match bindings::serve(&item, &fact.value, Some(root)) {
586                Ok(()) => format!("[{}] {}", fact.category, fact.value),
587                Err(withheld) => withheld.notice(),
588            }
589        })
590    }
591
592    /// Both facts an import derives from a finding — the `finding` fact and
593    /// the `auto:blocker` fact — are bound to the finding's file: their
594    /// recall is registered and they are withheld with the file. A finding
595    /// without a binding derives originless facts. A finding that is not
596    /// served any more derives nothing.
597    #[test]
598    fn a_fact_derived_from_a_finding_keeps_the_binding_of_that_finding() {
599        let _data = governed_with_auto_capture();
600        let _community = TestPolicyOverride::set(None);
601        let (_directory, root) = project();
602        let session = session_with_two_findings(&root);
603
604        let (knowledge, counts) = import(&root, &session, false);
605        assert_eq!(counts.findings, 2);
606        let mut bound_facts = Vec::new();
607        for category in ["finding", "auto:blocker"] {
608            let bound = derived(&knowledge, category, BOUND).expect(category);
609            let (reply, recorded) = recall(&root, &bound);
610            assert!(reply.contains(BOUND), "{reply}");
611            assert_eq!(recorded, 1, "{category}: bound to pool.rs");
612            assert_eq!(reusable(&root, &reply), Ok(()));
613            bound_facts.push((bound, reply));
614
615            let typed = derived(&knowledge, category, TYPED).expect(category);
616            let (reply, recorded) = recall(&root, &typed);
617            assert!(reply.contains(TYPED), "{reply}");
618            assert_eq!(recorded, 0, "{category}: no binding to carry");
619            assert_eq!(reusable(&root, &reply), Err(LineageRefusal::Unknown));
620        }
621
622        std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
623        for (fact, reply) in &bound_facts {
624            assert_eq!(
625                reusable(&root, reply),
626                Err(LineageRefusal::SourceNoLongerAdmitted)
627            );
628            let (withheld, recorded) = recall(&root, fact);
629            assert!(!withheld.contains(BOUND), "{withheld}");
630            assert_eq!(recorded, 0);
631        }
632        let (knowledge, counts) = import(&root, &session, false);
633        assert_eq!(
634            counts.findings, 1,
635            "the finding that is not served is not imported"
636        );
637        for category in ["finding", "auto:blocker"] {
638            assert!(derived(&knowledge, category, BOUND).is_none(), "{category}");
639            assert!(derived(&knowledge, category, TYPED).is_some(), "{category}");
640        }
641    }
642
643    /// A preview imports into a throwaway copy and writes no binding.
644    #[test]
645    fn a_dry_run_import_writes_no_binding() {
646        let _data = governed_with_auto_capture();
647        let _community = TestPolicyOverride::set(None);
648        let (_directory, root) = project();
649        let session = session_with_two_findings(&root);
650        let (knowledge, counts) = import(&root, &session, true);
651        assert_eq!(counts.findings, 2);
652        let previewed = derived(&knowledge, "auto:blocker", BOUND).expect("previewed");
653        assert_eq!(
654            recall(&root, &previewed).1,
655            0,
656            "no register entry was written"
657        );
658    }
659
660    /// Developer mode: nothing is refused. A finding whose file is gone still
661    /// derives its facts, as before.
662    #[test]
663    fn an_import_in_developer_mode_refuses_nothing() {
664        let _data = crate::core::data_dir::isolated_data_dir();
665        // `auto_capture` is opt-in; machine-derived facts need it (#1802).
666        let config = crate::core::paths::config_dir_read_only().expect("isolated config dir");
667        std::fs::create_dir_all(&config).expect("config dir");
668        std::fs::write(config.join("config.toml"), "auto_capture = true\n").expect("config");
669        let _community = TestPolicyOverride::set(None);
670        let (_directory, root) = project();
671        let session = session_with_two_findings(&root);
672        std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
673        let (knowledge, counts) = import(&root, &session, false);
674        assert_eq!(counts.findings, 2);
675        for category in ["finding", "auto:blocker"] {
676            assert!(derived(&knowledge, category, BOUND).is_some(), "{category}");
677        }
678    }
679}