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        }
148    }
149
150    /// Remove entries older than `max_age_days` or below `min_confidence`.
151    pub fn cleanup(&mut self, max_age_days: i64, min_confidence: f32) -> usize {
152        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
153        let before = self.shared_facts.len();
154        self.shared_facts
155            .retain(|e| e.published_at >= cutoff && e.confidence >= min_confidence);
156        before - self.shared_facts.len()
157    }
158
159    pub fn entries_for_agent(&self, agent_id: &str) -> Vec<&BridgeEntry> {
160        self.shared_facts
161            .iter()
162            .filter(|e| e.source_agent == agent_id)
163            .collect()
164    }
165
166    pub fn summary(&self) -> String {
167        if self.shared_facts.is_empty() {
168            return format!(
169                "Knowledge Bridge [{}]: empty",
170                short_hash(&self.project_hash)
171            );
172        }
173
174        let mut agents: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
175        for entry in &self.shared_facts {
176            *agents.entry(&entry.source_agent).or_default() += 1;
177        }
178
179        let mut out = format!(
180            "Knowledge Bridge [{}]: {} shared facts from {} agent(s)\n",
181            short_hash(&self.project_hash),
182            self.shared_facts.len(),
183            agents.len(),
184        );
185        let mut sorted_agents: Vec<_> = agents.into_iter().collect();
186        sorted_agents.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
187        for (agent, count) in &sorted_agents {
188            out.push_str(&format!("  {agent}: {count} fact(s)\n"));
189        }
190        out.push_str(&format!(
191            "Last updated: {}",
192            self.updated_at.format("%Y-%m-%d %H:%M UTC")
193        ));
194        out
195    }
196}
197
198fn short_hash(hash: &str) -> &str {
199    if hash.len() > 8 {
200        &hash[..8]
201    } else {
202        hash
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::core::knowledge::KnowledgeFact;
210    use crate::core::memory_boundary::FactPrivacy;
211
212    fn make_fact(
213        cat: &str,
214        key: &str,
215        val: &str,
216        confidence: f32,
217        archetype: KnowledgeArchetype,
218    ) -> KnowledgeFact {
219        KnowledgeFact {
220            category: cat.into(),
221            key: key.into(),
222            value: val.into(),
223            source_session: "test-session".into(),
224            confidence,
225            created_at: Utc::now(),
226            last_confirmed: Utc::now(),
227            retrieval_count: 0,
228            last_retrieved: None,
229            valid_from: None,
230            valid_until: None,
231            supersedes: None,
232            confirmation_count: 1,
233            feedback_up: 0,
234            feedback_down: 0,
235            last_feedback: None,
236            privacy: FactPrivacy::default(),
237            imported_from: None,
238            archetype,
239            fidelity: None,
240        }
241    }
242
243    #[test]
244    fn publish_only_eligible_facts() {
245        let mut bridge = KnowledgeBridge::new("test-hash");
246        let facts = vec![
247            make_fact(
248                "arch",
249                "db",
250                "PostgreSQL",
251                0.9,
252                KnowledgeArchetype::Architecture,
253            ),
254            make_fact("random", "x", "low-conf", 0.3, KnowledgeArchetype::Fact),
255            make_fact(
256                "gotcha",
257                "trap",
258                "watch out",
259                0.85,
260                KnowledgeArchetype::Gotcha,
261            ),
262            make_fact(
263                "pref",
264                "editor",
265                "vim",
266                0.95,
267                KnowledgeArchetype::Preference,
268            ),
269        ];
270        let count = bridge.publish("agent-1", &facts);
271        assert_eq!(count, 2);
272        assert_eq!(bridge.shared_facts.len(), 2);
273    }
274
275    #[test]
276    fn pull_excludes_own_facts() {
277        let mut bridge = KnowledgeBridge::new("test-hash");
278        let facts = vec![make_fact(
279            "arch",
280            "db",
281            "PostgreSQL",
282            0.9,
283            KnowledgeArchetype::Architecture,
284        )];
285        bridge.publish("agent-1", &facts);
286
287        let pulled = bridge.pull("agent-1");
288        assert!(pulled.is_empty(), "Should not pull own facts");
289
290        let pulled = bridge.pull("agent-2");
291        assert_eq!(pulled.len(), 1);
292    }
293
294    #[test]
295    fn entry_to_fact_preserves_provenance() {
296        let entry = BridgeEntry {
297            fact_key: "db".into(),
298            fact_category: "arch".into(),
299            fact_value: "PostgreSQL".into(),
300            source_agent: "agent-1".into(),
301            published_at: Utc::now(),
302            archetype: KnowledgeArchetype::Architecture,
303            confidence: 0.9,
304            provenance: "session-abc".into(),
305        };
306        let fact = KnowledgeBridge::entry_to_fact(&entry);
307        assert_eq!(fact.imported_from, Some("bridge:agent-1".into()));
308        assert!(fact.confidence < 0.9);
309        assert_eq!(fact.archetype, KnowledgeArchetype::Architecture);
310    }
311
312    #[test]
313    fn no_duplicate_publish() {
314        let mut bridge = KnowledgeBridge::new("test-hash");
315        let facts = vec![make_fact(
316            "arch",
317            "db",
318            "PostgreSQL",
319            0.9,
320            KnowledgeArchetype::Architecture,
321        )];
322        bridge.publish("agent-1", &facts);
323        let second = bridge.publish("agent-1", &facts);
324        assert_eq!(second, 0, "Should not re-publish same fact");
325        assert_eq!(bridge.shared_facts.len(), 1);
326    }
327
328    #[test]
329    fn cleanup_removes_old_entries() {
330        let mut bridge = KnowledgeBridge::new("test-hash");
331        bridge.shared_facts.push(BridgeEntry {
332            fact_key: "old".into(),
333            fact_category: "arch".into(),
334            fact_value: "ancient".into(),
335            source_agent: "agent-1".into(),
336            published_at: Utc::now() - chrono::Duration::days(60),
337            archetype: KnowledgeArchetype::Architecture,
338            confidence: 0.9,
339            provenance: "old-session".into(),
340        });
341        bridge.shared_facts.push(BridgeEntry {
342            fact_key: "fresh".into(),
343            fact_category: "arch".into(),
344            fact_value: "new".into(),
345            source_agent: "agent-1".into(),
346            published_at: Utc::now(),
347            archetype: KnowledgeArchetype::Architecture,
348            confidence: 0.9,
349            provenance: "new-session".into(),
350        });
351        let removed = bridge.cleanup(30, 0.5);
352        assert_eq!(removed, 1);
353        assert_eq!(bridge.shared_facts.len(), 1);
354        assert_eq!(bridge.shared_facts[0].fact_key, "fresh");
355    }
356
357    #[test]
358    fn entries_for_agent_filters_correctly() {
359        let mut bridge = KnowledgeBridge::new("test-hash");
360        let facts_a = vec![make_fact(
361            "arch",
362            "db",
363            "PostgreSQL",
364            0.9,
365            KnowledgeArchetype::Architecture,
366        )];
367        let facts_b = vec![make_fact(
368            "gotcha",
369            "trap",
370            "watch out",
371            0.85,
372            KnowledgeArchetype::Gotcha,
373        )];
374        bridge.publish("agent-a", &facts_a);
375        bridge.publish("agent-b", &facts_b);
376
377        assert_eq!(bridge.entries_for_agent("agent-a").len(), 1);
378        assert_eq!(bridge.entries_for_agent("agent-b").len(), 1);
379        assert_eq!(bridge.entries_for_agent("agent-c").len(), 0);
380    }
381
382    #[test]
383    fn summary_format() {
384        let mut bridge = KnowledgeBridge::new("test-hash");
385        assert!(bridge.summary().contains("empty"));
386
387        let facts = vec![make_fact(
388            "arch",
389            "db",
390            "PostgreSQL",
391            0.9,
392            KnowledgeArchetype::Architecture,
393        )];
394        bridge.publish("agent-1", &facts);
395        let summary = bridge.summary();
396        assert!(summary.contains("1 shared facts"));
397        assert!(summary.contains("agent-1"));
398    }
399
400    #[test]
401    fn cleanup_removes_low_confidence() {
402        let mut bridge = KnowledgeBridge::new("test-hash");
403        bridge.shared_facts.push(BridgeEntry {
404            fact_key: "weak".into(),
405            fact_category: "arch".into(),
406            fact_value: "uncertain".into(),
407            source_agent: "agent-1".into(),
408            published_at: Utc::now(),
409            archetype: KnowledgeArchetype::Architecture,
410            confidence: 0.3,
411            provenance: "session".into(),
412        });
413        bridge.shared_facts.push(BridgeEntry {
414            fact_key: "strong".into(),
415            fact_category: "arch".into(),
416            fact_value: "certain".into(),
417            source_agent: "agent-1".into(),
418            published_at: Utc::now(),
419            archetype: KnowledgeArchetype::Architecture,
420            confidence: 0.9,
421            provenance: "session".into(),
422        });
423        let removed = bridge.cleanup(365, 0.5);
424        assert_eq!(removed, 1);
425        assert_eq!(bridge.shared_facts[0].fact_key, "strong");
426    }
427
428    #[test]
429    fn trust_penalty_reduces_confidence() {
430        let entry = BridgeEntry {
431            fact_key: "k".into(),
432            fact_category: "c".into(),
433            fact_value: "v".into(),
434            source_agent: "src".into(),
435            published_at: Utc::now(),
436            archetype: KnowledgeArchetype::Decision,
437            confidence: 1.0,
438            provenance: "s".into(),
439        };
440        let fact = KnowledgeBridge::entry_to_fact(&entry);
441        assert!((fact.confidence - 0.9).abs() < f32::EPSILON);
442    }
443
444    #[test]
445    fn archived_facts_not_published() {
446        let mut bridge = KnowledgeBridge::new("test-hash");
447        let mut fact = make_fact(
448            "arch",
449            "old-db",
450            "MySQL",
451            0.95,
452            KnowledgeArchetype::Architecture,
453        );
454        fact.valid_until = Some(Utc::now() - chrono::Duration::days(1));
455        let count = bridge.publish("agent-1", &[fact]);
456        assert_eq!(count, 0);
457    }
458}