Skip to main content

ipfrs_tensorlogic/
kb_federation.rs

1//! Knowledge Base Federation
2//!
3//! Provides functions for merging, exporting, and importing knowledge bases
4//! across the IPFRS network using content-addressed IPLD blocks.
5//!
6//! # Overview
7//!
8//! - [`merge_knowledge_bases`] – merge two KBs, deduplicating by content hash.
9//! - [`export_kb_as_cid`] – serialize a store's KB as IPLD, return the root CID.
10//! - [`import_remote_kb`] – fetch a KB snapshot by CID and merge it into a local store.
11
12use crate::ipld_codec::{
13    block_to_kb, fact_ipld_to_predicate, kb_to_block, predicate_to_fact_ipld, rule_cid,
14    rule_ipld_to_rule, rule_to_rule_ipld, KnowledgeBaseIpld,
15};
16use crate::ir::{KnowledgeBase, Predicate, Rule};
17use ipfrs_core::{Block, Cid, Result};
18use ipfrs_storage::traits::BlockStore;
19use serde::{Deserialize, Serialize};
20
21// ─── Public types ────────────────────────────────────────────────────────────
22
23/// Summary of changes produced by merging two knowledge bases.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct KbMergeDiff {
26    /// Number of facts added from the remote KB
27    pub facts_added: usize,
28    /// Number of facts skipped because they already existed (by content hash)
29    pub facts_skipped_duplicate: usize,
30    /// Number of rules added from the remote KB
31    pub rules_added: usize,
32    /// Number of rules skipped because they already existed (by content hash)
33    pub rules_skipped_duplicate: usize,
34    /// List of predicate conflicts (same predicate/arity, different bodies)
35    pub conflicts: Vec<KbConflict>,
36}
37
38/// A conflict detected when merging two knowledge bases.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub enum KbConflict {
41    /// Same predicate name / arity but different rule body.
42    /// Both versions are retained with a version prefix to distinguish them.
43    RuleBodyConflict {
44        /// The predicate name involved in the conflict
45        predicate: String,
46        /// Display form of the local rule
47        local_rule: String,
48        /// Display form of the remote rule
49        remote_rule: String,
50    },
51}
52
53// ─── Core merge logic ─────────────────────────────────────────────────────────
54
55/// Compute the FNV-1a hash of a serialized predicate (used for deduplication).
56fn fact_content_hash(fact: &Predicate) -> u64 {
57    match predicate_to_fact_ipld(fact) {
58        Ok(ipld) => match serde_json::to_vec(&ipld) {
59            Ok(bytes) => fnv1a(&bytes),
60            Err(_) => {
61                // Fallback: hash the debug representation
62                fnv1a(format!("{:?}", fact).as_bytes())
63            }
64        },
65        Err(_) => fnv1a(format!("{:?}", fact).as_bytes()),
66    }
67}
68
69fn fnv1a(data: &[u8]) -> u64 {
70    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
71    const FNV_PRIME: u64 = 1_099_511_628_211;
72    let mut hash = FNV_OFFSET;
73    for &byte in data {
74        hash ^= byte as u64;
75        hash = hash.wrapping_mul(FNV_PRIME);
76    }
77    hash
78}
79
80/// Compute the content hash of a rule via its IPLD representation.
81fn rule_content_hash(rule: &Rule) -> Option<u64> {
82    let ipld = rule_to_rule_ipld(rule).ok()?;
83    let bytes = serde_json::to_vec(&ipld).ok()?;
84    Some(fnv1a(&bytes))
85}
86
87/// Merge two knowledge bases, deduplicating facts and rules by content hash.
88///
89/// Rules with the same head predicate name and arity but a different body
90/// produce a [`KbConflict::RuleBodyConflict`] entry.  Both versions are kept
91/// in the merged output (the local version is already present; the remote
92/// version is renamed with a `"remote_"` prefix on the head predicate).
93///
94/// # Returns
95/// A tuple of `(merged_kb, diff_summary)`.
96pub fn merge_knowledge_bases(
97    local: &KnowledgeBase,
98    remote: &KnowledgeBase,
99) -> (KnowledgeBase, KbMergeDiff) {
100    let mut merged = local.clone();
101    let mut diff = KbMergeDiff::default();
102
103    // ── Fact deduplication ───────────────────────────────────────────────────
104    // Build a set of content hashes for existing local facts.
105    let local_fact_hashes: std::collections::HashSet<u64> =
106        local.facts.iter().map(fact_content_hash).collect();
107
108    for remote_fact in &remote.facts {
109        let h = fact_content_hash(remote_fact);
110        if local_fact_hashes.contains(&h) {
111            diff.facts_skipped_duplicate += 1;
112        } else {
113            merged.add_fact(remote_fact.clone());
114            diff.facts_added += 1;
115        }
116    }
117
118    // ── Rule deduplication & conflict detection ──────────────────────────────
119    // Build a map from content hash → rule for fast duplicate lookup.
120    let local_rule_hashes: std::collections::HashMap<u64, &Rule> = local
121        .rules
122        .iter()
123        .filter_map(|r| rule_content_hash(r).map(|h| (h, r)))
124        .collect();
125
126    // Build a map from (head_name, arity) → rule body display for conflict detection.
127    let local_signature_map: std::collections::HashMap<(String, usize), String> = local
128        .rules
129        .iter()
130        .map(|r| {
131            let key = (r.head.name.clone(), r.head.args.len());
132            let body_repr = rule_body_repr(r);
133            (key, body_repr)
134        })
135        .collect();
136
137    for remote_rule in &remote.rules {
138        let remote_hash = match rule_content_hash(remote_rule) {
139            Some(h) => h,
140            None => {
141                // Cannot compute hash; add unconditionally
142                merged.add_rule(remote_rule.clone());
143                diff.rules_added += 1;
144                continue;
145            }
146        };
147
148        if local_rule_hashes.contains_key(&remote_hash) {
149            diff.rules_skipped_duplicate += 1;
150            continue;
151        }
152
153        // Check for body conflict: same predicate/arity but different body.
154        let sig = (remote_rule.head.name.clone(), remote_rule.head.args.len());
155        if let Some(local_body_repr) = local_signature_map.get(&sig) {
156            let remote_body_repr = rule_body_repr(remote_rule);
157            if *local_body_repr != remote_body_repr {
158                // Conflict – record it and add the remote rule with a versioned prefix
159                diff.conflicts.push(KbConflict::RuleBodyConflict {
160                    predicate: remote_rule.head.name.clone(),
161                    local_rule: format!(
162                        "{}/{}: {}",
163                        remote_rule.head.name,
164                        remote_rule.head.args.len(),
165                        local_body_repr
166                    ),
167                    remote_rule: format!(
168                        "{}/{}: {}",
169                        remote_rule.head.name,
170                        remote_rule.head.args.len(),
171                        remote_body_repr
172                    ),
173                });
174
175                // Keep both: create a renamed copy of the remote rule so it does
176                // not shadow the local one.
177                let mut renamed = remote_rule.clone();
178                renamed.head.name = format!("remote_{}", remote_rule.head.name);
179                merged.add_rule(renamed);
180                diff.rules_added += 1;
181                continue;
182            }
183        }
184
185        merged.add_rule(remote_rule.clone());
186        diff.rules_added += 1;
187    }
188
189    (merged, diff)
190}
191
192/// Produce a compact string representation of a rule's body for conflict detection.
193fn rule_body_repr(rule: &Rule) -> String {
194    rule.body
195        .iter()
196        .map(|p| format!("{}({})", p.name, p.args.len()))
197        .collect::<Vec<_>>()
198        .join(",")
199}
200
201// ─── IPLD export / import ─────────────────────────────────────────────────────
202
203/// Export the current knowledge base of `store` as an IPLD block DAG.
204///
205/// Each rule is stored as a separate block (deduplication-friendly). Facts are
206/// inlined in the root `KnowledgeBaseIpld` block.
207///
208/// Returns the CID of the root block.
209pub async fn export_kb_as_cid<S: BlockStore>(
210    store: &crate::storage::TensorLogicStore<S>,
211    block_store: &dyn BlockStore,
212) -> Result<Cid> {
213    // Snapshot the KB
214    let kb = store.snapshot_kb()?;
215
216    // Store each rule individually
217    let mut rule_cids: Vec<String> = Vec::with_capacity(kb.rules.len());
218    for rule in &kb.rules {
219        let rule_ipld = rule_to_rule_ipld(rule)?;
220        let cid = rule_cid(&rule_ipld)?;
221
222        // Build the block manually so we can store it in the caller's block_store
223        let json_bytes = serde_json::to_vec(&rule_ipld)
224            .map_err(|e| ipfrs_core::Error::Serialization(format!("rule IPLD: {}", e)))?;
225        let block = Block::from_parts(cid, bytes::Bytes::from(json_bytes));
226        block_store.put(&block).await?;
227
228        rule_cids.push(cid.to_string());
229    }
230
231    // Inline facts
232    let fact_iplds = kb
233        .facts
234        .iter()
235        .map(predicate_to_fact_ipld)
236        .collect::<std::result::Result<Vec<_>, _>>()?;
237
238    let kb_ipld = KnowledgeBaseIpld {
239        rules: rule_cids,
240        facts: fact_iplds,
241        version: "1.0.0".to_string(),
242    };
243
244    let root_block = kb_to_block(&kb_ipld)?;
245    let root_cid = *root_block.cid();
246    block_store.put(&root_block).await?;
247
248    Ok(root_cid)
249}
250
251/// Import a KB snapshot identified by `remote_cid` into `store`, merging with
252/// the existing local KB.
253///
254/// The function fetches the root `KnowledgeBaseIpld` block and all linked rule
255/// blocks from `block_store`, reconstructs the remote [`KnowledgeBase`], and
256/// then calls [`merge_knowledge_bases`] to produce the merged result.
257///
258/// # Errors
259/// Returns an error if any block is missing from `block_store` or if decoding
260/// fails.
261pub async fn import_remote_kb<S: BlockStore>(
262    remote_cid: &Cid,
263    store: &crate::storage::TensorLogicStore<S>,
264    block_store: &dyn BlockStore,
265) -> Result<KbMergeDiff> {
266    // Fetch root block
267    let root_block = block_store
268        .get(remote_cid)
269        .await?
270        .ok_or_else(|| ipfrs_core::Error::BlockNotFound(remote_cid.to_string()))?;
271
272    let kb_ipld = block_to_kb(&root_block)?;
273
274    // Reconstruct remote KB
275    let mut remote_kb = KnowledgeBase::new();
276
277    // Load rules
278    for rule_cid_str in &kb_ipld.rules {
279        let rule_cid: Cid = rule_cid_str
280            .parse()
281            .map_err(|e| ipfrs_core::Error::Cid(format!("invalid CID {}: {}", rule_cid_str, e)))?;
282
283        let rule_block = block_store
284            .get(&rule_cid)
285            .await?
286            .ok_or_else(|| ipfrs_core::Error::BlockNotFound(rule_cid.to_string()))?;
287
288        let rule_ipld = crate::ipld_codec::block_to_rule(&rule_block)?;
289        let rule = rule_ipld_to_rule(&rule_ipld)?;
290        remote_kb.add_rule(rule);
291    }
292
293    // Load facts from inline ipld
294    for fact_ipld in &kb_ipld.facts {
295        let predicate = fact_ipld_to_predicate(fact_ipld)?;
296        remote_kb.add_fact(predicate);
297    }
298
299    // Snapshot local KB
300    let local_kb = store.snapshot_kb()?;
301
302    // Merge
303    let (merged_kb, diff) = merge_knowledge_bases(&local_kb, &remote_kb);
304
305    // Apply merged KB back into store
306    for fact in &merged_kb.facts {
307        // Only add facts that are new (not already in local)
308        let local_fact_hashes: std::collections::HashSet<u64> =
309            local_kb.facts.iter().map(fact_content_hash).collect();
310        if !local_fact_hashes.contains(&fact_content_hash(fact)) {
311            store.add_fact(fact.clone())?;
312        }
313    }
314
315    for rule in &merged_kb.rules {
316        let local_rule_hashes: std::collections::HashSet<u64> = local_kb
317            .rules
318            .iter()
319            .filter_map(rule_content_hash)
320            .collect();
321        if let Some(h) = rule_content_hash(rule) {
322            if !local_rule_hashes.contains(&h) {
323                store.add_rule(rule.clone())?;
324            }
325        }
326    }
327
328    Ok(diff)
329}
330
331// ─── Tests ────────────────────────────────────────────────────────────────────
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::ir::{Constant, Predicate, Rule, Term};
337    use crate::storage::TensorLogicStore;
338    use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
339    use std::sync::Arc;
340
341    fn make_store(suffix: &str) -> Arc<TensorLogicStore<SledBlockStore>> {
342        let path = std::env::temp_dir().join(format!("ipfrs-test-kb-fed-{}", suffix));
343        let _ = std::fs::remove_dir_all(&path);
344        let config = BlockStoreConfig {
345            path,
346            cache_size: 32 * 1024 * 1024,
347        };
348        let sled = Arc::new(SledBlockStore::new(config).expect("sled store"));
349        Arc::new(TensorLogicStore::new(sled).expect("tensorlogic store"))
350    }
351
352    fn make_sled_store(suffix: &str) -> Arc<SledBlockStore> {
353        let path = std::env::temp_dir().join(format!("ipfrs-test-sled-fed-{}", suffix));
354        let _ = std::fs::remove_dir_all(&path);
355        let config = BlockStoreConfig {
356            path,
357            cache_size: 16 * 1024 * 1024,
358        };
359        Arc::new(SledBlockStore::new(config).expect("sled store"))
360    }
361
362    fn parent_fact(a: &str, b: &str) -> Predicate {
363        Predicate::new(
364            "parent".to_string(),
365            vec![
366                Term::Const(Constant::String(a.to_string())),
367                Term::Const(Constant::String(b.to_string())),
368            ],
369        )
370    }
371
372    fn ancestor_rule_base() -> Rule {
373        Rule::new(
374            Predicate::new(
375                "ancestor".to_string(),
376                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
377            ),
378            vec![Predicate::new(
379                "parent".to_string(),
380                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
381            )],
382        )
383    }
384
385    fn sibling_rule() -> Rule {
386        Rule::new(
387            Predicate::new(
388                "sibling".to_string(),
389                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
390            ),
391            vec![
392                Predicate::new(
393                    "parent".to_string(),
394                    vec![Term::Var("Z".to_string()), Term::Var("X".to_string())],
395                ),
396                Predicate::new(
397                    "parent".to_string(),
398                    vec![Term::Var("Z".to_string()), Term::Var("Y".to_string())],
399                ),
400            ],
401        )
402    }
403
404    #[test]
405    fn test_merge_disjoint_kbs() {
406        let mut local_kb = KnowledgeBase::new();
407        local_kb.add_fact(parent_fact("alice", "bob"));
408        local_kb.add_rule(ancestor_rule_base());
409
410        let mut remote_kb = KnowledgeBase::new();
411        remote_kb.add_fact(parent_fact("bob", "charlie"));
412        remote_kb.add_rule(sibling_rule());
413
414        let (merged, diff) = merge_knowledge_bases(&local_kb, &remote_kb);
415
416        assert_eq!(merged.facts.len(), 2, "should have both facts");
417        assert_eq!(merged.rules.len(), 2, "should have both rules");
418        assert_eq!(diff.facts_added, 1);
419        assert_eq!(diff.facts_skipped_duplicate, 0);
420        assert_eq!(diff.rules_added, 1);
421        assert_eq!(diff.rules_skipped_duplicate, 0);
422        assert!(diff.conflicts.is_empty());
423    }
424
425    #[test]
426    fn test_merge_duplicate_facts() {
427        let mut local_kb = KnowledgeBase::new();
428        local_kb.add_fact(parent_fact("alice", "bob"));
429
430        let mut remote_kb = KnowledgeBase::new();
431        // Same fact in remote
432        remote_kb.add_fact(parent_fact("alice", "bob"));
433        // Plus a new one
434        remote_kb.add_fact(parent_fact("bob", "charlie"));
435
436        let (merged, diff) = merge_knowledge_bases(&local_kb, &remote_kb);
437
438        assert_eq!(merged.facts.len(), 2);
439        assert_eq!(
440            diff.facts_skipped_duplicate, 1,
441            "duplicate should be skipped"
442        );
443        assert_eq!(diff.facts_added, 1);
444    }
445
446    #[test]
447    fn test_merge_conflicting_rules() {
448        let mut local_kb = KnowledgeBase::new();
449        // ancestor(X, Y) :- parent(X, Y)
450        local_kb.add_rule(ancestor_rule_base());
451
452        let mut remote_kb = KnowledgeBase::new();
453        // ancestor(X, Y) :- ancestor(X, Z), ancestor(Z, Y)  (different body)
454        let conflict_rule = Rule::new(
455            Predicate::new(
456                "ancestor".to_string(),
457                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
458            ),
459            vec![
460                Predicate::new(
461                    "ancestor".to_string(),
462                    vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
463                ),
464                Predicate::new(
465                    "ancestor".to_string(),
466                    vec![Term::Var("Z".to_string()), Term::Var("Y".to_string())],
467                ),
468            ],
469        );
470        remote_kb.add_rule(conflict_rule);
471
472        let (_merged, diff) = merge_knowledge_bases(&local_kb, &remote_kb);
473
474        assert_eq!(diff.conflicts.len(), 1, "should have one conflict");
475        match &diff.conflicts[0] {
476            KbConflict::RuleBodyConflict { predicate, .. } => {
477                assert_eq!(predicate, "ancestor");
478            }
479        }
480        // Both rules are kept: local + remote renamed
481        assert_eq!(diff.rules_added, 1);
482    }
483
484    #[tokio::test]
485    async fn test_export_import_roundtrip() {
486        let local_store = make_store("roundtrip-local");
487        let block_store = make_sled_store("roundtrip-blocks");
488
489        // Populate local store
490        local_store
491            .add_fact(parent_fact("alice", "bob"))
492            .expect("add fact");
493        local_store
494            .add_rule(ancestor_rule_base())
495            .expect("add rule");
496
497        // Export
498        let cid = export_kb_as_cid(&local_store, block_store.as_ref())
499            .await
500            .expect("export");
501
502        // Import into a fresh store
503        let fresh_store = make_store("roundtrip-fresh");
504        let diff = import_remote_kb(&cid, &fresh_store, block_store.as_ref())
505            .await
506            .expect("import");
507
508        assert_eq!(diff.facts_added, 1, "should have imported 1 fact");
509        assert_eq!(diff.rules_added, 1, "should have imported 1 rule");
510
511        let kb = fresh_store.snapshot_kb().expect("snapshot");
512        assert_eq!(kb.facts.len(), 1);
513        assert_eq!(kb.rules.len(), 1);
514        assert_eq!(kb.facts[0].name, "parent");
515        assert_eq!(kb.rules[0].head.name, "ancestor");
516    }
517}