Skip to main content

lean_ctx/core/
knowledge_bridge.rs

1//! Cross-Agent Knowledge Bridge — controlled sharing of high-confidence facts between agents.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7use crate::core::knowledge::{KnowledgeArchetype, KnowledgeFact};
8use crate::core::memory_boundary::FactPrivacy;
9
10const PUBLISHABLE_ARCHETYPES: &[KnowledgeArchetype] = &[
11    KnowledgeArchetype::Architecture,
12    KnowledgeArchetype::Convention,
13    KnowledgeArchetype::Decision,
14    KnowledgeArchetype::Dependency,
15    KnowledgeArchetype::Gotcha,
16];
17
18const MIN_PUBLISH_CONFIDENCE: f32 = 0.8;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BridgeEntry {
22    pub fact_key: String,
23    pub fact_category: String,
24    pub fact_value: String,
25    pub source_agent: String,
26    pub published_at: DateTime<Utc>,
27    pub archetype: KnowledgeArchetype,
28    pub confidence: f32,
29    pub provenance: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct KnowledgeBridge {
34    pub project_hash: String,
35    pub shared_facts: Vec<BridgeEntry>,
36    pub updated_at: DateTime<Utc>,
37}
38
39impl KnowledgeBridge {
40    pub fn new(project_hash: &str) -> Self {
41        Self {
42            project_hash: project_hash.to_string(),
43            shared_facts: Vec::new(),
44            updated_at: Utc::now(),
45        }
46    }
47
48    pub fn path(project_hash: &str) -> Result<PathBuf, String> {
49        Ok(crate::core::data_dir::lean_ctx_data_dir()?
50            .join("knowledge")
51            .join(project_hash)
52            .join("bridge.json"))
53    }
54
55    pub fn load(project_hash: &str) -> Option<Self> {
56        let path = Self::path(project_hash).ok()?;
57        let content = std::fs::read_to_string(&path).ok()?;
58        serde_json::from_str::<Self>(&content).ok()
59    }
60
61    pub fn load_or_create(project_hash: &str) -> Self {
62        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
63    }
64
65    pub fn save(&mut self) -> Result<(), String> {
66        self.updated_at = Utc::now();
67        let path = Self::path(&self.project_hash)?;
68        if let Some(parent) = path.parent() {
69            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
70        }
71        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
72        crate::config_io::write_atomic(&path, &json)
73    }
74
75    /// Publish eligible facts from an agent's knowledge store.
76    /// Only publishes facts with sufficient confidence, a publishable archetype,
77    /// and that haven't already been published by this agent.
78    pub fn publish(&mut self, agent_id: &str, facts: &[KnowledgeFact]) -> u32 {
79        let mut count = 0u32;
80        for fact in facts {
81            if !fact.is_current() {
82                continue;
83            }
84            if fact.confidence < MIN_PUBLISH_CONFIDENCE {
85                continue;
86            }
87            if !PUBLISHABLE_ARCHETYPES.contains(&fact.archetype) {
88                continue;
89            }
90            let already_published = self.shared_facts.iter().any(|e| {
91                e.fact_key == fact.key
92                    && e.fact_category == fact.category
93                    && e.source_agent == agent_id
94            });
95            if already_published {
96                continue;
97            }
98            self.shared_facts.push(BridgeEntry {
99                fact_key: fact.key.clone(),
100                fact_category: fact.category.clone(),
101                fact_value: fact.value.clone(),
102                source_agent: agent_id.to_string(),
103                published_at: Utc::now(),
104                archetype: fact.archetype.clone(),
105                confidence: fact.confidence,
106                provenance: fact.source_session.clone(),
107            });
108            count += 1;
109        }
110        count
111    }
112
113    /// Pull facts from the bridge that were published by other agents.
114    pub fn pull(&self, requesting_agent: &str) -> Vec<BridgeEntry> {
115        self.shared_facts
116            .iter()
117            .filter(|e| e.source_agent != requesting_agent)
118            .cloned()
119            .collect()
120    }
121
122    /// Convert a [`BridgeEntry`] into a [`KnowledgeFact`] for import.
123    /// Applies a 10% trust penalty to imported confidence.
124    pub fn entry_to_fact(entry: &BridgeEntry) -> KnowledgeFact {
125        let now = Utc::now();
126        KnowledgeFact {
127            category: entry.fact_category.clone(),
128            key: entry.fact_key.clone(),
129            value: entry.fact_value.clone(),
130            source_session: entry.provenance.clone(),
131            confidence: entry.confidence * 0.9,
132            created_at: now,
133            last_confirmed: now,
134            retrieval_count: 0,
135            last_retrieved: None,
136            valid_from: Some(now),
137            valid_until: None,
138            supersedes: None,
139            confirmation_count: 1,
140            feedback_up: 0,
141            feedback_down: 0,
142            last_feedback: None,
143            privacy: FactPrivacy::default(),
144            imported_from: Some(format!("bridge:{}", entry.source_agent)),
145            archetype: entry.archetype.clone(),
146            fidelity: None,
147            revision_count: 0,
148        }
149    }
150
151    /// Remove entries older than `max_age_days` or below `min_confidence`.
152    pub fn cleanup(&mut self, max_age_days: i64, min_confidence: f32) -> usize {
153        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
154        let before = self.shared_facts.len();
155        self.shared_facts
156            .retain(|e| e.published_at >= cutoff && e.confidence >= min_confidence);
157        before - self.shared_facts.len()
158    }
159
160    pub fn entries_for_agent(&self, agent_id: &str) -> Vec<&BridgeEntry> {
161        self.shared_facts
162            .iter()
163            .filter(|e| e.source_agent == agent_id)
164            .collect()
165    }
166
167    pub fn summary(&self) -> String {
168        if self.shared_facts.is_empty() {
169            return format!(
170                "Knowledge Bridge [{}]: empty",
171                short_hash(&self.project_hash)
172            );
173        }
174
175        let mut agents: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
176        for entry in &self.shared_facts {
177            *agents.entry(&entry.source_agent).or_default() += 1;
178        }
179
180        let mut out = format!(
181            "Knowledge Bridge [{}]: {} shared facts from {} agent(s)\n",
182            short_hash(&self.project_hash),
183            self.shared_facts.len(),
184            agents.len(),
185        );
186        let mut sorted_agents: Vec<_> = agents.into_iter().collect();
187        sorted_agents.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
188        for (agent, count) in &sorted_agents {
189            out.push_str(&format!("  {agent}: {count} fact(s)\n"));
190        }
191        out.push_str(&format!(
192            "Last updated: {}",
193            self.updated_at.format("%Y-%m-%d %H:%M UTC")
194        ));
195        out
196    }
197}
198
199fn short_hash(hash: &str) -> &str {
200    if hash.len() > 8 {
201        &hash[..8]
202    } else {
203        hash
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::core::knowledge::KnowledgeFact;
211    use crate::core::memory_boundary::FactPrivacy;
212
213    fn make_fact(
214        cat: &str,
215        key: &str,
216        val: &str,
217        confidence: f32,
218        archetype: KnowledgeArchetype,
219    ) -> KnowledgeFact {
220        KnowledgeFact {
221            category: cat.into(),
222            key: key.into(),
223            value: val.into(),
224            source_session: "test-session".into(),
225            confidence,
226            created_at: Utc::now(),
227            last_confirmed: Utc::now(),
228            retrieval_count: 0,
229            last_retrieved: None,
230            valid_from: None,
231            valid_until: None,
232            supersedes: None,
233            confirmation_count: 1,
234            feedback_up: 0,
235            feedback_down: 0,
236            last_feedback: None,
237            privacy: FactPrivacy::default(),
238            imported_from: None,
239            archetype,
240            fidelity: None,
241            revision_count: 0,
242        }
243    }
244
245    #[test]
246    fn publish_only_eligible_facts() {
247        let mut bridge = KnowledgeBridge::new("test-hash");
248        let facts = vec![
249            make_fact(
250                "arch",
251                "db",
252                "PostgreSQL",
253                0.9,
254                KnowledgeArchetype::Architecture,
255            ),
256            make_fact("random", "x", "low-conf", 0.3, KnowledgeArchetype::Fact),
257            make_fact(
258                "gotcha",
259                "trap",
260                "watch out",
261                0.85,
262                KnowledgeArchetype::Gotcha,
263            ),
264            make_fact(
265                "pref",
266                "editor",
267                "vim",
268                0.95,
269                KnowledgeArchetype::Preference,
270            ),
271        ];
272        let count = bridge.publish("agent-1", &facts);
273        assert_eq!(count, 2);
274        assert_eq!(bridge.shared_facts.len(), 2);
275    }
276
277    #[test]
278    fn pull_excludes_own_facts() {
279        let mut bridge = KnowledgeBridge::new("test-hash");
280        let facts = vec![make_fact(
281            "arch",
282            "db",
283            "PostgreSQL",
284            0.9,
285            KnowledgeArchetype::Architecture,
286        )];
287        bridge.publish("agent-1", &facts);
288
289        let pulled = bridge.pull("agent-1");
290        assert!(pulled.is_empty(), "Should not pull own facts");
291
292        let pulled = bridge.pull("agent-2");
293        assert_eq!(pulled.len(), 1);
294    }
295
296    #[test]
297    fn entry_to_fact_preserves_provenance() {
298        let entry = BridgeEntry {
299            fact_key: "db".into(),
300            fact_category: "arch".into(),
301            fact_value: "PostgreSQL".into(),
302            source_agent: "agent-1".into(),
303            published_at: Utc::now(),
304            archetype: KnowledgeArchetype::Architecture,
305            confidence: 0.9,
306            provenance: "session-abc".into(),
307        };
308        let fact = KnowledgeBridge::entry_to_fact(&entry);
309        assert_eq!(fact.imported_from, Some("bridge:agent-1".into()));
310        assert!(fact.confidence < 0.9);
311        assert_eq!(fact.archetype, KnowledgeArchetype::Architecture);
312    }
313
314    #[test]
315    fn no_duplicate_publish() {
316        let mut bridge = KnowledgeBridge::new("test-hash");
317        let facts = vec![make_fact(
318            "arch",
319            "db",
320            "PostgreSQL",
321            0.9,
322            KnowledgeArchetype::Architecture,
323        )];
324        bridge.publish("agent-1", &facts);
325        let second = bridge.publish("agent-1", &facts);
326        assert_eq!(second, 0, "Should not re-publish same fact");
327        assert_eq!(bridge.shared_facts.len(), 1);
328    }
329
330    #[test]
331    fn cleanup_removes_old_entries() {
332        let mut bridge = KnowledgeBridge::new("test-hash");
333        bridge.shared_facts.push(BridgeEntry {
334            fact_key: "old".into(),
335            fact_category: "arch".into(),
336            fact_value: "ancient".into(),
337            source_agent: "agent-1".into(),
338            published_at: Utc::now() - chrono::Duration::days(60),
339            archetype: KnowledgeArchetype::Architecture,
340            confidence: 0.9,
341            provenance: "old-session".into(),
342        });
343        bridge.shared_facts.push(BridgeEntry {
344            fact_key: "fresh".into(),
345            fact_category: "arch".into(),
346            fact_value: "new".into(),
347            source_agent: "agent-1".into(),
348            published_at: Utc::now(),
349            archetype: KnowledgeArchetype::Architecture,
350            confidence: 0.9,
351            provenance: "new-session".into(),
352        });
353        let removed = bridge.cleanup(30, 0.5);
354        assert_eq!(removed, 1);
355        assert_eq!(bridge.shared_facts.len(), 1);
356        assert_eq!(bridge.shared_facts[0].fact_key, "fresh");
357    }
358
359    #[test]
360    fn entries_for_agent_filters_correctly() {
361        let mut bridge = KnowledgeBridge::new("test-hash");
362        let facts_a = vec![make_fact(
363            "arch",
364            "db",
365            "PostgreSQL",
366            0.9,
367            KnowledgeArchetype::Architecture,
368        )];
369        let facts_b = vec![make_fact(
370            "gotcha",
371            "trap",
372            "watch out",
373            0.85,
374            KnowledgeArchetype::Gotcha,
375        )];
376        bridge.publish("agent-a", &facts_a);
377        bridge.publish("agent-b", &facts_b);
378
379        assert_eq!(bridge.entries_for_agent("agent-a").len(), 1);
380        assert_eq!(bridge.entries_for_agent("agent-b").len(), 1);
381        assert_eq!(bridge.entries_for_agent("agent-c").len(), 0);
382    }
383
384    #[test]
385    fn summary_format() {
386        let mut bridge = KnowledgeBridge::new("test-hash");
387        assert!(bridge.summary().contains("empty"));
388
389        let facts = vec![make_fact(
390            "arch",
391            "db",
392            "PostgreSQL",
393            0.9,
394            KnowledgeArchetype::Architecture,
395        )];
396        bridge.publish("agent-1", &facts);
397        let summary = bridge.summary();
398        assert!(summary.contains("1 shared facts"));
399        assert!(summary.contains("agent-1"));
400    }
401
402    #[test]
403    fn cleanup_removes_low_confidence() {
404        let mut bridge = KnowledgeBridge::new("test-hash");
405        bridge.shared_facts.push(BridgeEntry {
406            fact_key: "weak".into(),
407            fact_category: "arch".into(),
408            fact_value: "uncertain".into(),
409            source_agent: "agent-1".into(),
410            published_at: Utc::now(),
411            archetype: KnowledgeArchetype::Architecture,
412            confidence: 0.3,
413            provenance: "session".into(),
414        });
415        bridge.shared_facts.push(BridgeEntry {
416            fact_key: "strong".into(),
417            fact_category: "arch".into(),
418            fact_value: "certain".into(),
419            source_agent: "agent-1".into(),
420            published_at: Utc::now(),
421            archetype: KnowledgeArchetype::Architecture,
422            confidence: 0.9,
423            provenance: "session".into(),
424        });
425        let removed = bridge.cleanup(365, 0.5);
426        assert_eq!(removed, 1);
427        assert_eq!(bridge.shared_facts[0].fact_key, "strong");
428    }
429
430    #[test]
431    fn trust_penalty_reduces_confidence() {
432        let entry = BridgeEntry {
433            fact_key: "k".into(),
434            fact_category: "c".into(),
435            fact_value: "v".into(),
436            source_agent: "src".into(),
437            published_at: Utc::now(),
438            archetype: KnowledgeArchetype::Decision,
439            confidence: 1.0,
440            provenance: "s".into(),
441        };
442        let fact = KnowledgeBridge::entry_to_fact(&entry);
443        assert!((fact.confidence - 0.9).abs() < f32::EPSILON);
444    }
445
446    #[test]
447    fn archived_facts_not_published() {
448        let mut bridge = KnowledgeBridge::new("test-hash");
449        let mut fact = make_fact(
450            "arch",
451            "old-db",
452            "MySQL",
453            0.95,
454            KnowledgeArchetype::Architecture,
455        );
456        fact.valid_until = Some(Utc::now() - chrono::Duration::days(1));
457        let count = bridge.publish("agent-1", &[fact]);
458        assert_eq!(count, 0);
459    }
460}