Skip to main content

ipfrs_tensorlogic/
storage.rs

1//! Storage for TensorLogic IR
2//!
3//! Provides content-addressed storage for logical terms, predicates, and rules
4
5use crate::inference_cache::InferenceCache;
6use crate::ir::{KnowledgeBase, Predicate, Rule, Term};
7use crate::reasoning::{InferenceEngine, Proof, Substitution};
8use ipfrs_core::{Block, Cid, Result};
9use ipfrs_storage::traits::BlockStore;
10use parking_lot::Mutex;
11use serde::{Deserialize, Serialize};
12use serde_json;
13use std::collections::VecDeque;
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18/// Extended statistics for a [`TensorLogicStore`] including CID accounting.
19///
20/// Produced by [`TensorLogicStore::kb_stats_with_cids`].
21#[derive(Debug, Clone)]
22pub struct TensorLogicStoreStats {
23    /// Number of rules currently loaded in the in-memory knowledge base
24    pub rule_count: usize,
25    /// Number of ground facts currently loaded in the in-memory knowledge base
26    pub fact_count: usize,
27    /// How many rules already have a corresponding DAG-CBOR block in the store
28    /// (i.e., content-addressed deduplication is active for those rules)
29    pub cid_indexed_rules: usize,
30}
31
32/// Configuration for automatic snapshot persistence
33pub struct TensorLogicPersistenceConfig {
34    /// Path where snapshots are saved
35    pub snapshot_path: std::path::PathBuf,
36    /// Whether to auto-save when changes are detected
37    pub auto_save: bool,
38    /// Interval between automatic saves
39    pub snapshot_interval: std::time::Duration,
40}
41
42/// Persistent snapshot of the knowledge base
43///
44/// Captures all rules and facts at a point in time for cross-restart persistence.
45#[derive(Debug, Serialize, Deserialize)]
46pub struct KnowledgeBaseSnapshot {
47    /// Schema/format version
48    pub version: u32,
49    /// Serialized rules
50    pub rules: Vec<RuleSnapshot>,
51    /// Serialized facts
52    pub facts: Vec<FactSnapshot>,
53    /// Unix timestamp (seconds) when the snapshot was created
54    pub created_at: u64,
55    /// Number of rules in this snapshot
56    pub rule_count: usize,
57    /// Number of facts in this snapshot
58    pub fact_count: usize,
59}
60
61/// Snapshot of a single rule
62#[derive(Debug, Serialize, Deserialize)]
63pub struct RuleSnapshot {
64    /// Name of the head predicate
65    pub head_predicate: String,
66    /// String representations of head arguments
67    pub head_args: Vec<String>,
68    /// String representations of body goals
69    pub body_goals: Vec<String>,
70    /// Optional CID string (set if rule was previously stored as a block)
71    pub cid: Option<String>,
72}
73
74/// Snapshot of a single ground fact
75#[derive(Debug, Serialize, Deserialize)]
76pub struct FactSnapshot {
77    /// Predicate name
78    pub predicate: String,
79    /// String representations of arguments
80    pub args: Vec<String>,
81}
82
83/// Errors specific to TensorLogic persistence operations
84#[derive(Debug, thiserror::Error)]
85pub enum TensorLogicError {
86    #[error("Snapshot IO error: {0}")]
87    Io(#[from] std::io::Error),
88    #[error("Snapshot serialization error: {0}")]
89    Serialization(String),
90    #[error("Lock poisoned: {0}")]
91    LockPoisoned(String),
92}
93
94/// Storage manager for TensorLogic IR
95///
96/// Stores terms, predicates, and rules as content-addressed blocks
97pub struct TensorLogicStore<S: BlockStore> {
98    /// Underlying block store
99    store: Arc<S>,
100    /// In-memory knowledge base for inference
101    knowledge_base: std::sync::RwLock<KnowledgeBase>,
102    /// Inference engine
103    engine: InferenceEngine,
104    /// Whether knowledge base has unsaved changes since last snapshot
105    dirty: AtomicBool,
106    /// Rolling window of recent inference durations (capped at 100 entries).
107    ///
108    /// Guarded by a `parking_lot::Mutex` for contention-free appends from
109    /// concurrent callers.
110    inference_times: Mutex<VecDeque<Duration>>,
111    /// Monotonic counter incremented on every KB mutation (add_rule, add_fact, retract).
112    kb_version: AtomicU64,
113    /// Inference memoization cache keyed by (goal_hash, kb_version).
114    inference_cache: Mutex<InferenceCache>,
115}
116
117impl<S: BlockStore> TensorLogicStore<S> {
118    /// Create a new TensorLogic store
119    pub fn new(store: Arc<S>) -> Result<Self> {
120        Ok(Self {
121            store,
122            knowledge_base: std::sync::RwLock::new(KnowledgeBase::new()),
123            engine: InferenceEngine::new(),
124            dirty: AtomicBool::new(false),
125            inference_times: Mutex::new(VecDeque::with_capacity(100)),
126            kb_version: AtomicU64::new(0),
127            inference_cache: Mutex::new(InferenceCache::new(1024)),
128        })
129    }
130
131    /// Return the current KB version counter (monotonically increasing).
132    pub fn kb_version(&self) -> u64 {
133        self.kb_version.load(Ordering::Acquire)
134    }
135
136    /// Access the inference cache statistics.
137    pub fn inference_cache_stats(&self) -> crate::inference_cache::CacheStats {
138        self.inference_cache.lock().stats()
139    }
140
141    /// Invalidate all inference cache entries for the current (pre-bump) KB version.
142    fn bump_kb_version_and_invalidate(&self) {
143        let old_version = self.kb_version.fetch_add(1, Ordering::AcqRel);
144        self.inference_cache
145            .lock()
146            .invalidate_for_kb_version(old_version);
147    }
148
149    /// Store a term and return its CID
150    pub async fn store_term(&self, term: &Term) -> Result<Cid> {
151        let json = serde_json::to_vec(term)
152            .map_err(|e| ipfrs_core::Error::Serialization(format!("Term serialization: {}", e)))?;
153
154        let block = Block::new(json.into())?;
155        let cid = *block.cid();
156
157        self.store.put(&block).await?;
158
159        Ok(cid)
160    }
161
162    /// Retrieve a term by CID
163    pub async fn get_term(&self, cid: &Cid) -> Result<Option<Term>> {
164        match self.store.get(cid).await? {
165            Some(block) => {
166                let term = serde_json::from_slice(block.data()).map_err(|e| {
167                    ipfrs_core::Error::Deserialization(format!("Term deserialization: {}", e))
168                })?;
169                Ok(Some(term))
170            }
171            None => Ok(None),
172        }
173    }
174
175    /// Store a predicate and return its CID
176    pub async fn store_predicate(&self, predicate: &Predicate) -> Result<Cid> {
177        let json = serde_json::to_vec(predicate).map_err(|e| {
178            ipfrs_core::Error::Serialization(format!("Predicate serialization: {}", e))
179        })?;
180
181        let block = Block::new(json.into())?;
182        let cid = *block.cid();
183
184        self.store.put(&block).await?;
185
186        Ok(cid)
187    }
188
189    /// Retrieve a predicate by CID
190    pub async fn get_predicate(&self, cid: &Cid) -> Result<Option<Predicate>> {
191        match self.store.get(cid).await? {
192            Some(block) => {
193                let predicate = serde_json::from_slice(block.data()).map_err(|e| {
194                    ipfrs_core::Error::Deserialization(format!("Predicate deserialization: {}", e))
195                })?;
196                Ok(Some(predicate))
197            }
198            None => Ok(None),
199        }
200    }
201
202    /// Store a rule and return its CID
203    pub async fn store_rule(&self, rule: &Rule) -> Result<Cid> {
204        let json = serde_json::to_vec(rule)
205            .map_err(|e| ipfrs_core::Error::Serialization(format!("Rule serialization: {}", e)))?;
206
207        let block = Block::new(json.into())?;
208        let cid = *block.cid();
209
210        self.store.put(&block).await?;
211
212        Ok(cid)
213    }
214
215    /// Retrieve a rule by CID
216    pub async fn get_rule(&self, cid: &Cid) -> Result<Option<Rule>> {
217        match self.store.get(cid).await? {
218            Some(block) => {
219                let rule = serde_json::from_slice(block.data()).map_err(|e| {
220                    ipfrs_core::Error::Deserialization(format!("Rule deserialization: {}", e))
221                })?;
222                Ok(Some(rule))
223            }
224            None => Ok(None),
225        }
226    }
227
228    /// Check if a CID exists in storage
229    pub async fn has(&self, cid: &Cid) -> Result<bool> {
230        self.store.has(cid).await
231    }
232
233    /// Delete a stored item by CID
234    pub async fn delete(&self, cid: &Cid) -> Result<()> {
235        self.store.delete(cid).await
236    }
237
238    /// Add a fact to the knowledge base
239    pub fn add_fact(&self, fact: Predicate) -> Result<()> {
240        let mut kb = self.knowledge_base.write().map_err(|_| {
241            ipfrs_core::Error::Storage("KB write lock poisoned in add_fact".to_string())
242        })?;
243        kb.add_fact(fact);
244        self.dirty.store(true, Ordering::Release);
245        drop(kb);
246        self.bump_kb_version_and_invalidate();
247        Ok(())
248    }
249
250    /// Add a rule to the knowledge base
251    pub fn add_rule(&self, rule: Rule) -> Result<()> {
252        let mut kb = self.knowledge_base.write().map_err(|_| {
253            ipfrs_core::Error::Storage("KB write lock poisoned in add_rule".to_string())
254        })?;
255        kb.add_rule(rule);
256        self.dirty.store(true, Ordering::Release);
257        drop(kb);
258        self.bump_kb_version_and_invalidate();
259        Ok(())
260    }
261
262    /// Retract a fact from the knowledge base (removes first matching fact).
263    pub fn retract_fact(&self, fact: &Predicate) -> Result<bool> {
264        let mut kb = self.knowledge_base.write().map_err(|_| {
265            ipfrs_core::Error::Storage("KB write lock poisoned in retract_fact".to_string())
266        })?;
267        let before = kb.facts.len();
268        kb.facts.retain(|f| f != fact);
269        let removed = kb.facts.len() < before;
270        if removed {
271            self.dirty.store(true, Ordering::Release);
272            drop(kb);
273            self.bump_kb_version_and_invalidate();
274        }
275        Ok(removed)
276    }
277
278    /// Run inference query on the knowledge base
279    ///
280    /// Records the wall-clock duration of each call in an internal rolling
281    /// window of the last 100 durations so that `avg_inference_ms` can report
282    /// a meaningful average.
283    pub fn infer(&self, goal: &Predicate) -> Result<Vec<Substitution>> {
284        let t0 = Instant::now();
285        let kb = self.knowledge_base.read().map_err(|_| {
286            ipfrs_core::Error::Storage("KB read lock poisoned in infer".to_string())
287        })?;
288        let result = self.engine.query(goal, &kb)?;
289        let elapsed = t0.elapsed();
290        drop(kb);
291        let mut times = self.inference_times.lock();
292        if times.len() >= 100 {
293            times.pop_front();
294        }
295        times.push_back(elapsed);
296        Ok(result)
297    }
298
299    /// Average inference duration in milliseconds over the last (up to 100) calls.
300    ///
301    /// Returns `None` when no inference has been performed yet.
302    pub fn avg_inference_ms(&self) -> Option<f64> {
303        let times = self.inference_times.lock();
304        if times.is_empty() {
305            return None;
306        }
307        let total_us: u128 = times.iter().map(|d| d.as_micros()).sum();
308        Some(total_us as f64 / times.len() as f64 / 1000.0)
309    }
310
311    /// Generate a proof for a goal
312    pub fn prove(&self, goal: &Predicate) -> Result<Option<Proof>> {
313        let kb = self.knowledge_base.read().map_err(|_| {
314            ipfrs_core::Error::Storage("KB read lock poisoned in prove".to_string())
315        })?;
316        self.engine.prove(goal, &kb)
317    }
318
319    /// Store a proof and return its CID
320    pub async fn store_proof(&self, proof: &Proof) -> Result<Cid> {
321        let json = serde_json::to_vec(proof)
322            .map_err(|e| ipfrs_core::Error::Serialization(format!("Proof serialization: {}", e)))?;
323
324        let block = Block::new(json.into())?;
325        let cid = *block.cid();
326
327        self.store.put(&block).await?;
328
329        Ok(cid)
330    }
331
332    /// Retrieve a proof by CID
333    pub async fn get_proof(&self, cid: &Cid) -> Result<Option<Proof>> {
334        match self.store.get(cid).await? {
335            Some(block) => {
336                let proof = serde_json::from_slice(block.data()).map_err(|e| {
337                    ipfrs_core::Error::Deserialization(format!("Proof deserialization: {}", e))
338                })?;
339                Ok(Some(proof))
340            }
341            None => Ok(None),
342        }
343    }
344
345    /// Verify that a proof is valid against the current knowledge base
346    pub fn verify_proof(&self, proof: &Proof) -> Result<bool> {
347        let kb = self.knowledge_base.read().map_err(|_| {
348            ipfrs_core::Error::Storage("KB read lock poisoned in verify_proof".to_string())
349        })?;
350        self.engine.verify(proof, &kb)
351    }
352
353    /// Get knowledge base statistics
354    pub fn kb_stats(&self) -> crate::ir::KnowledgeBaseStats {
355        match self.knowledge_base.read() {
356            Ok(kb) => kb.stats(),
357            Err(_) => KnowledgeBase::new().stats(),
358        }
359    }
360
361    /// Estimated memory usage in bytes for the in-memory knowledge base.
362    ///
363    /// Uses a conservative heuristic:
364    /// - Each rule  ≈ 500 bytes (head + body terms + serialised form)
365    /// - Each fact  ≈ 200 bytes (predicate name + argument terms)
366    pub fn estimated_memory_bytes(&self) -> usize {
367        let stats = self.kb_stats();
368        stats.num_rules * 500 + stats.num_facts * 200
369    }
370
371    /// Return a snapshot (clone) of the current in-memory knowledge base.
372    ///
373    /// The returned value is independent of the store; subsequent mutations are
374    /// not reflected in the snapshot.
375    pub fn snapshot_kb(&self) -> Result<KnowledgeBase> {
376        let kb = self
377            .knowledge_base
378            .read()
379            .map_err(|_| ipfrs_core::Error::Storage("KB lock poisoned".to_string()))?;
380        Ok(kb.clone())
381    }
382
383    // ─── IPLD codec integration ───────────────────────────────────────────────
384
385    /// Store a rule as a content-addressed IPLD block using the DAG-CBOR codec.
386    ///
387    /// Unlike `store_rule` which serialises via JSON directly, this method
388    /// goes through the [`crate::ipld_codec`] pipeline:
389    ///
390    /// ```text
391    /// Rule → RuleIpld → Block (DAG-CBOR codec, SHA-256 CID)
392    /// ```
393    ///
394    /// Identical rules always produce the same CID, enabling deduplication
395    /// across the network without reading the block contents.
396    pub async fn store_rule_as_block(&self, rule: &Rule) -> Result<Cid> {
397        use crate::ipld_codec::{rule_to_block, rule_to_rule_ipld};
398
399        let rule_ipld = rule_to_rule_ipld(rule)?;
400        let block = rule_to_block(&rule_ipld)?;
401        let cid = *block.cid();
402
403        self.store.put(&block).await?;
404
405        Ok(cid)
406    }
407
408    /// Load a rule that was stored via `store_rule_as_block` (or any IPLD
409    /// block whose content is a valid `RuleIpld` JSON blob).
410    ///
411    /// Returns [`ipfrs_core::Error::BlockNotFound`] when the CID is absent.
412    pub async fn load_rule_from_block(&self, cid: &Cid) -> Result<Rule> {
413        use crate::ipld_codec::{block_to_rule, rule_ipld_to_rule};
414
415        let block = self
416            .store
417            .get(cid)
418            .await?
419            .ok_or_else(|| ipfrs_core::Error::BlockNotFound(cid.to_string()))?;
420
421        let rule_ipld = block_to_rule(&block)?;
422        rule_ipld_to_rule(&rule_ipld)
423    }
424
425    /// Snapshot the in-memory knowledge base as an IPLD DAG.
426    ///
427    /// Each rule is stored as an individual block (via `store_rule_as_block`)
428    /// so that rules already present in the store are not re-written.  Facts
429    /// are stored inline in the root `KnowledgeBaseIpld` block.
430    ///
431    /// Returns the CID of the root KB block.  The caller can pin this CID to
432    /// prevent garbage-collection of the whole DAG.
433    pub async fn store_kb_as_ipld(&self) -> Result<Cid> {
434        use crate::ipld_codec::{kb_to_block, predicate_to_fact_ipld, KnowledgeBaseIpld};
435
436        // Snapshot the in-memory KB (hold the lock only long enough to clone)
437        let (rules, facts) = {
438            let kb = self
439                .knowledge_base
440                .read()
441                .map_err(|_| ipfrs_core::Error::Storage("KB lock poisoned".to_string()))?;
442            (kb.rules.clone(), kb.facts.clone())
443        };
444
445        // Store each rule as a separate block; collect CID strings for the
446        // root node.  We deliberately call store_rule_as_block so that the
447        // idempotent put semantics of the underlying BlockStore handle
448        // deduplication for us.
449        let mut rule_cids: Vec<String> = Vec::with_capacity(rules.len());
450        for rule in &rules {
451            let cid = self.store_rule_as_block(rule).await?;
452            rule_cids.push(cid.to_string());
453        }
454
455        // Convert facts to IPLD representation (inline in the root block)
456        let fact_iplds = facts
457            .iter()
458            .map(predicate_to_fact_ipld)
459            .collect::<std::result::Result<Vec<_>, _>>()?;
460
461        let kb_ipld = KnowledgeBaseIpld {
462            rules: rule_cids,
463            facts: fact_iplds,
464            version: "1.0.0".to_string(),
465        };
466
467        let root_block = kb_to_block(&kb_ipld)?;
468        let root_cid = *root_block.cid();
469        self.store.put(&root_block).await?;
470
471        Ok(root_cid)
472    }
473
474    /// Check whether a rule is already present in the block store by computing
475    /// its content-addressed CID and calling `has()`.
476    ///
477    /// This is a pure deduplication check: it does **not** add the rule to the
478    /// in-memory knowledge base or write any block.  The conversion is the same
479    /// pipeline used by `store_rule_as_block`, so the CID is guaranteed to
480    /// match.
481    pub async fn rule_exists_by_cid(&self, rule: &Rule) -> Result<bool> {
482        use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
483
484        let rule_ipld = rule_to_rule_ipld(rule)?;
485        let cid = rule_cid(&rule_ipld)?;
486        self.store.has(&cid).await
487    }
488
489    /// Extended knowledge base statistics that include content-addressed block
490    /// accounting.
491    ///
492    /// `cid_indexed_rules` reflects how many rules in the in-memory KB are
493    /// already persisted as DAG-CBOR blocks (i.e., `has()` returns `true` for
494    /// their computed CID).  The async check is done concurrently for all
495    /// rules so this method scales acceptably for large knowledge bases.
496    pub async fn kb_stats_with_cids(&self) -> Result<TensorLogicStoreStats> {
497        use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
498        use futures::future;
499
500        // Snapshot
501        let (rules, facts) = {
502            let kb = self
503                .knowledge_base
504                .read()
505                .map_err(|_| ipfrs_core::Error::Storage("KB lock poisoned".to_string()))?;
506            (kb.rules.clone(), kb.facts.clone())
507        };
508
509        // Fire off concurrent `has()` checks for every rule
510        let has_futures: Vec<_> = rules
511            .iter()
512            .map(|rule| async move {
513                let rule_ipld = rule_to_rule_ipld(rule)?;
514                let cid = rule_cid(&rule_ipld)?;
515                self.store.has(&cid).await
516            })
517            .collect();
518
519        let has_results = future::join_all(has_futures).await;
520
521        let mut cid_indexed_rules = 0usize;
522        for res in has_results {
523            if res? {
524                cid_indexed_rules += 1;
525            }
526        }
527
528        Ok(TensorLogicStoreStats {
529            rule_count: rules.len(),
530            fact_count: facts.len(),
531            cid_indexed_rules,
532        })
533    }
534
535    /// Build a predicate-name → CID index for all rules currently in the
536    /// knowledge base by computing each rule's DAG-CBOR CID.
537    ///
538    /// Rules that fail CID computation are silently skipped.  The index is
539    /// built from the current in-memory snapshot; changes made after this call
540    /// are not reflected.
541    ///
542    /// This method is used by `DistributedBackwardChainer` to find DHT
543    /// providers for relevant predicates without scanning all rules on every
544    /// query.
545    pub async fn index_rules_by_predicate(
546        &self,
547    ) -> Result<std::collections::HashMap<String, Vec<Cid>>> {
548        use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
549
550        let rules = {
551            let kb = self
552                .knowledge_base
553                .read()
554                .map_err(|_| ipfrs_core::Error::Storage("KB lock poisoned".to_string()))?;
555            kb.rules.clone()
556        };
557
558        let mut cid_map: std::collections::HashMap<usize, Cid> =
559            std::collections::HashMap::with_capacity(rules.len());
560
561        for (idx, rule) in rules.iter().enumerate() {
562            if let Ok(rule_ipld) = rule_to_rule_ipld(rule) {
563                if let Ok(cid) = rule_cid(&rule_ipld) {
564                    cid_map.insert(idx, cid);
565                }
566            }
567        }
568
569        // Re-acquire to build the final index using KnowledgeBase::index_rules_by_predicate
570        let kb = self
571            .knowledge_base
572            .read()
573            .map_err(|_| ipfrs_core::Error::Storage("KB lock poisoned".to_string()))?;
574
575        Ok(kb.index_rules_by_predicate(&cid_map))
576    }
577
578    // ─── Snapshot persistence ─────────────────────────────────────────────────
579
580    /// Save all rules and facts to a snapshot file.
581    ///
582    /// The snapshot is stored as pretty-printed JSON so it is human-readable
583    /// and portable across restarts.  After a successful save the dirty flag
584    /// is cleared.
585    pub fn save_snapshot(
586        &self,
587        path: &std::path::Path,
588    ) -> std::result::Result<(), TensorLogicError> {
589        use std::io::Write;
590
591        let (rules, facts) = {
592            let kb = self
593                .knowledge_base
594                .read()
595                .map_err(|e| TensorLogicError::LockPoisoned(e.to_string()))?;
596            (kb.rules.clone(), kb.facts.clone())
597        };
598
599        let rule_snapshots: Vec<RuleSnapshot> = rules
600            .iter()
601            .map(|r| RuleSnapshot {
602                head_predicate: r.head.name.clone(),
603                head_args: r.head.args.iter().map(|a| format!("{:?}", a)).collect(),
604                body_goals: r
605                    .body
606                    .iter()
607                    .map(|g| format!("{}({:?})", g.name, g.args))
608                    .collect(),
609                cid: None,
610            })
611            .collect();
612
613        let fact_snapshots: Vec<FactSnapshot> = facts
614            .iter()
615            .map(|f| FactSnapshot {
616                predicate: f.name.clone(),
617                args: f.args.iter().map(|a| format!("{:?}", a)).collect(),
618            })
619            .collect();
620
621        let rule_count = rule_snapshots.len();
622        let fact_count = fact_snapshots.len();
623
624        let snapshot = KnowledgeBaseSnapshot {
625            version: 1,
626            rules: rule_snapshots,
627            facts: fact_snapshots,
628            created_at: std::time::SystemTime::now()
629                .duration_since(std::time::UNIX_EPOCH)
630                .unwrap_or_default()
631                .as_secs(),
632            rule_count,
633            fact_count,
634        };
635
636        let json = serde_json::to_vec_pretty(&snapshot)
637            .map_err(|e| TensorLogicError::Serialization(e.to_string()))?;
638
639        let mut file = std::fs::File::create(path)?;
640        file.write_all(&json)?;
641
642        self.dirty.store(false, Ordering::Release);
643        Ok(())
644    }
645
646    /// Load rules and facts from a previously saved snapshot file.
647    ///
648    /// The snapshot metadata (counts, timestamp, version) is returned.
649    /// Note: the snapshot stores rules/facts as debug strings for
650    /// human-readable audit trails.  The raw [`KnowledgeBase`] is
651    /// persisted separately via `save_kb` / `load_kb` for full
652    /// round-trip fidelity; this method populates snapshot metadata.
653    ///
654    /// After a successful load the dirty flag is cleared.
655    pub fn load_snapshot(
656        &mut self,
657        path: &std::path::Path,
658    ) -> std::result::Result<KnowledgeBaseSnapshot, TensorLogicError> {
659        use std::io::Read;
660
661        let mut file = std::fs::File::open(path)?;
662        let mut buf = Vec::new();
663        file.read_to_end(&mut buf)?;
664
665        let snapshot: KnowledgeBaseSnapshot = serde_json::from_slice(&buf)
666            .map_err(|e| TensorLogicError::Serialization(e.to_string()))?;
667
668        self.dirty.store(false, Ordering::Release);
669        Ok(snapshot)
670    }
671
672    /// Whether the store has unsaved changes since the last snapshot save.
673    #[inline]
674    pub fn is_dirty(&self) -> bool {
675        self.dirty.load(Ordering::Acquire)
676    }
677
678    /// Save the knowledge base to a file
679    ///
680    /// Serializes the entire knowledge base (facts and rules) to a file
681    /// for later loading.
682    ///
683    /// # Arguments
684    /// * `path` - Path to save the knowledge base file
685    pub async fn save_kb<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
686        use std::fs::File;
687        use std::io::Write;
688
689        let kb = self.knowledge_base.read().map_err(|_| {
690            ipfrs_core::Error::Storage("KB read lock poisoned in save_kb".to_string())
691        })?;
692
693        // Serialize to oxicode
694        let encoded =
695            oxicode::serde::encode_to_vec(&*kb, oxicode::config::standard()).map_err(|e| {
696                ipfrs_core::Error::Serialization(format!("Failed to serialize KB: {}", e))
697            })?;
698
699        // Write to file
700        let mut file = File::create(path.as_ref())
701            .map_err(|e| ipfrs_core::Error::Storage(format!("Failed to create KB file: {}", e)))?;
702
703        file.write_all(&encoded)
704            .map_err(|e| ipfrs_core::Error::Storage(format!("Failed to write KB file: {}", e)))?;
705
706        Ok(())
707    }
708
709    /// Load a knowledge base from a file
710    ///
711    /// Loads a previously saved knowledge base from disk, replacing the current KB.
712    ///
713    /// # Arguments
714    /// * `path` - Path to the saved knowledge base file
715    pub async fn load_kb<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
716        use std::fs::File;
717        use std::io::Read;
718
719        // Read file
720        let mut file = File::open(path.as_ref())
721            .map_err(|e| ipfrs_core::Error::Storage(format!("Failed to open KB file: {}", e)))?;
722
723        let mut buffer = Vec::new();
724        file.read_to_end(&mut buffer)
725            .map_err(|e| ipfrs_core::Error::Storage(format!("Failed to read KB file: {}", e)))?;
726
727        // Deserialize
728        let kb: KnowledgeBase =
729            oxicode::serde::decode_owned_from_slice(&buffer, oxicode::config::standard())
730                .map(|(v, _)| v)
731                .map_err(|e| {
732                    ipfrs_core::Error::Deserialization(format!("Failed to deserialize KB: {}", e))
733                })?;
734
735        // Replace current KB
736        let mut guard = self.knowledge_base.write().map_err(|_| {
737            ipfrs_core::Error::Storage("KB write lock poisoned in load_kb".to_string())
738        })?;
739        *guard = kb;
740        drop(guard);
741        self.bump_kb_version_and_invalidate();
742
743        Ok(())
744    }
745}
746
747#[cfg(test)]
748mod ipld_integration_tests {
749    use super::*;
750    use crate::ir::{Constant, Predicate, Rule, Term};
751    use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
752
753    fn make_store(suffix: &str) -> TensorLogicStore<SledBlockStore> {
754        let path = std::env::temp_dir().join(format!("ipfrs-test-tl-ipld-{}", suffix));
755        let _ = std::fs::remove_dir_all(&path);
756        let config = BlockStoreConfig {
757            path,
758            cache_size: 32 * 1024 * 1024,
759        };
760        let store = Arc::new(SledBlockStore::new(config).expect("test: should succeed"));
761        TensorLogicStore::new(store).expect("test: should succeed")
762    }
763
764    fn grandparent_rule() -> Rule {
765        // grandparent(X, Z) :- parent(X, Y), parent(Y, Z)
766        let head = Predicate::new(
767            "grandparent".to_string(),
768            vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
769        );
770        let body = vec![
771            Predicate::new(
772                "parent".to_string(),
773                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
774            ),
775            Predicate::new(
776                "parent".to_string(),
777                vec![Term::Var("Y".to_string()), Term::Var("Z".to_string())],
778            ),
779        ];
780        Rule::new(head, body)
781    }
782
783    #[tokio::test]
784    async fn test_store_and_load_rule_as_block() {
785        let tl = make_store("store-load");
786        let rule = grandparent_rule();
787
788        let cid = tl
789            .store_rule_as_block(&rule)
790            .await
791            .expect("test: should succeed");
792
793        // Round-trip: load by CID and verify structure
794        let loaded = tl
795            .load_rule_from_block(&cid)
796            .await
797            .expect("test: should succeed");
798        assert_eq!(loaded.head.name, rule.head.name);
799        assert_eq!(loaded.head.args.len(), rule.head.args.len());
800        assert_eq!(loaded.body.len(), rule.body.len());
801        assert_eq!(loaded.body[0].name, rule.body[0].name);
802    }
803
804    #[tokio::test]
805    async fn test_rule_deduplication_by_cid() {
806        let tl = make_store("dedup");
807        let rule = grandparent_rule();
808
809        // Before storing: rule should not be in the block store
810        let exists_before = tl
811            .rule_exists_by_cid(&rule)
812            .await
813            .expect("test: should succeed");
814        assert!(!exists_before, "Rule must not exist before first store");
815
816        // Store once
817        let cid1 = tl
818            .store_rule_as_block(&rule)
819            .await
820            .expect("test: should succeed");
821
822        // After first store: rule is present
823        let exists_after = tl
824            .rule_exists_by_cid(&rule)
825            .await
826            .expect("test: should succeed");
827        assert!(exists_after, "Rule must exist after first store");
828
829        // Store again: identical CID must be returned without error
830        let cid2 = tl
831            .store_rule_as_block(&rule)
832            .await
833            .expect("test: should succeed");
834        assert_eq!(cid1, cid2, "Storing same rule twice must yield same CID");
835    }
836
837    #[tokio::test]
838    async fn test_load_rule_from_block_not_found() {
839        use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
840
841        let tl = make_store("not-found");
842        let rule = grandparent_rule();
843
844        // Compute CID without storing
845        let rule_ipld = rule_to_rule_ipld(&rule).expect("test: should succeed");
846        let cid = rule_cid(&rule_ipld).expect("test: should succeed");
847
848        let result = tl.load_rule_from_block(&cid).await;
849        assert!(result.is_err(), "Loading unstored CID must return Err");
850    }
851
852    #[tokio::test]
853    async fn test_kb_snapshot_as_ipld() {
854        let tl = make_store("kb-snapshot");
855
856        // Populate in-memory KB
857        let rule = grandparent_rule();
858        tl.add_rule(rule).expect("test: should succeed");
859        tl.add_fact(Predicate::new(
860            "parent".to_string(),
861            vec![
862                Term::Const(Constant::String("alice".to_string())),
863                Term::Const(Constant::String("bob".to_string())),
864            ],
865        ))
866        .expect("test: should succeed");
867        tl.add_fact(Predicate::new(
868            "parent".to_string(),
869            vec![
870                Term::Const(Constant::String("bob".to_string())),
871                Term::Const(Constant::String("charlie".to_string())),
872            ],
873        ))
874        .expect("test: should succeed");
875
876        // Snapshot to IPLD DAG
877        let root_cid = tl.store_kb_as_ipld().await.expect("test: should succeed");
878
879        // Root block must be present
880        let root_block = tl.store.get(&root_cid).await.expect("test: should succeed");
881        assert!(root_block.is_some(), "Root KB block must be stored");
882
883        // The rule block must also be present (individual rule blocks are stored)
884        let stats = tl.kb_stats_with_cids().await.expect("test: should succeed");
885        assert_eq!(stats.rule_count, 1);
886        assert_eq!(stats.fact_count, 2);
887        assert_eq!(
888            stats.cid_indexed_rules, 1,
889            "After kb_snapshot, all rules should have CID blocks"
890        );
891    }
892
893    #[tokio::test]
894    async fn test_kb_stats_with_cids_no_blocks() {
895        let tl = make_store("stats-no-blocks");
896
897        // Add rules to in-memory KB but do NOT store them as blocks
898        tl.add_rule(grandparent_rule())
899            .expect("test: should succeed");
900
901        let stats = tl.kb_stats_with_cids().await.expect("test: should succeed");
902        assert_eq!(stats.rule_count, 1);
903        assert_eq!(stats.fact_count, 0);
904        assert_eq!(
905            stats.cid_indexed_rules, 0,
906            "Rules added to KB but not as blocks should not be counted"
907        );
908    }
909
910    #[tokio::test]
911    async fn test_multiple_rules_partial_cid_coverage() {
912        let tl = make_store("partial-cids");
913
914        let rule_a = grandparent_rule();
915        let rule_b = Rule::fact(Predicate::new(
916            "mortal".to_string(),
917            vec![Term::Var("X".to_string())],
918        ));
919
920        tl.add_rule(rule_a.clone()).expect("test: should succeed");
921        tl.add_rule(rule_b.clone()).expect("test: should succeed");
922
923        // Only store rule_a as a block
924        tl.store_rule_as_block(&rule_a)
925            .await
926            .expect("test: should succeed");
927
928        let stats = tl.kb_stats_with_cids().await.expect("test: should succeed");
929        assert_eq!(stats.rule_count, 2);
930        assert_eq!(
931            stats.cid_indexed_rules, 1,
932            "Only one rule is block-stored; cid_indexed_rules must be 1"
933        );
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940    use crate::ir::Constant;
941    use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
942
943    #[tokio::test]
944    async fn test_term_storage() {
945        let config = BlockStoreConfig {
946            path: std::path::PathBuf::from("/tmp/ipfrs-test-tensorlogic-term"),
947            cache_size: 100 * 1024 * 1024,
948        };
949        let _ = std::fs::remove_dir_all(&config.path);
950        let store = Arc::new(SledBlockStore::new(config).expect("test: should succeed"));
951        let tl_store = TensorLogicStore::new(store).expect("test: should succeed");
952
953        let term = Term::Const(Constant::String("Alice".to_string()));
954        let cid = tl_store
955            .store_term(&term)
956            .await
957            .expect("test: should succeed");
958
959        let retrieved = tl_store.get_term(&cid).await.expect("test: should succeed");
960        assert_eq!(retrieved, Some(term));
961    }
962
963    #[tokio::test]
964    async fn test_predicate_storage() {
965        let config = BlockStoreConfig {
966            path: std::path::PathBuf::from("/tmp/ipfrs-test-tensorlogic-pred"),
967            cache_size: 100 * 1024 * 1024,
968        };
969        let _ = std::fs::remove_dir_all(&config.path);
970        let store = Arc::new(SledBlockStore::new(config).expect("test: should succeed"));
971        let tl_store = TensorLogicStore::new(store).expect("test: should succeed");
972
973        let predicate = Predicate::new(
974            "parent".to_string(),
975            vec![
976                Term::Const(Constant::String("Alice".to_string())),
977                Term::Const(Constant::String("Bob".to_string())),
978            ],
979        );
980
981        let cid = tl_store
982            .store_predicate(&predicate)
983            .await
984            .expect("test: should succeed");
985        let retrieved = tl_store
986            .get_predicate(&cid)
987            .await
988            .expect("test: should succeed");
989        assert_eq!(retrieved, Some(predicate));
990    }
991
992    #[tokio::test]
993    async fn test_rule_storage() {
994        let config = BlockStoreConfig {
995            path: std::path::PathBuf::from("/tmp/ipfrs-test-tensorlogic-rule"),
996            cache_size: 100 * 1024 * 1024,
997        };
998        let _ = std::fs::remove_dir_all(&config.path);
999        let store = Arc::new(SledBlockStore::new(config).expect("test: should succeed"));
1000        let tl_store = TensorLogicStore::new(store).expect("test: should succeed");
1001
1002        let rule = Rule::fact(Predicate::new(
1003            "parent".to_string(),
1004            vec![
1005                Term::Const(Constant::String("Alice".to_string())),
1006                Term::Const(Constant::String("Bob".to_string())),
1007            ],
1008        ));
1009
1010        let cid = tl_store
1011            .store_rule(&rule)
1012            .await
1013            .expect("test: should succeed");
1014        let retrieved = tl_store.get_rule(&cid).await.expect("test: should succeed");
1015        assert!(retrieved.is_some());
1016    }
1017}
1018
1019#[cfg(test)]
1020mod snapshot_tests {
1021    use super::*;
1022    use crate::ir::{Constant, Predicate, Rule, Term};
1023    use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
1024
1025    fn make_store(suffix: &str) -> TensorLogicStore<SledBlockStore> {
1026        let path = std::env::temp_dir().join(format!("ipfrs-snap-test-{}", suffix));
1027        let _ = std::fs::remove_dir_all(&path);
1028        let config = BlockStoreConfig {
1029            path,
1030            cache_size: 8 * 1024 * 1024,
1031        };
1032        let store = Arc::new(SledBlockStore::new(config).expect("test: should succeed"));
1033        TensorLogicStore::new(store).expect("test: should succeed")
1034    }
1035
1036    fn grandparent_rule() -> Rule {
1037        let head = Predicate::new(
1038            "grandparent".to_string(),
1039            vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
1040        );
1041        let body = vec![
1042            Predicate::new(
1043                "parent".to_string(),
1044                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
1045            ),
1046            Predicate::new(
1047                "parent".to_string(),
1048                vec![Term::Var("Y".to_string()), Term::Var("Z".to_string())],
1049            ),
1050        ];
1051        Rule::new(head, body)
1052    }
1053
1054    #[test]
1055    fn test_snapshot_save_load_roundtrip() {
1056        let store = make_store("roundtrip");
1057
1058        store
1059            .add_rule(grandparent_rule())
1060            .expect("test: should succeed");
1061        store
1062            .add_fact(Predicate::new(
1063                "parent".to_string(),
1064                vec![
1065                    Term::Const(Constant::String("alice".to_string())),
1066                    Term::Const(Constant::String("bob".to_string())),
1067                ],
1068            ))
1069            .expect("test: should succeed");
1070
1071        let snap_path = std::env::temp_dir().join("ipfrs-snap-roundtrip.json");
1072        store
1073            .save_snapshot(&snap_path)
1074            .expect("save_snapshot failed");
1075
1076        // Dirty flag must be cleared after save
1077        assert!(!store.is_dirty(), "Dirty flag should be cleared after save");
1078
1079        // Load into a fresh store instance (note: load_snapshot returns metadata)
1080        let mut store2 = make_store("roundtrip-load");
1081        let snapshot = store2
1082            .load_snapshot(&snap_path)
1083            .expect("load_snapshot failed");
1084
1085        assert_eq!(snapshot.version, 1);
1086        assert_eq!(snapshot.rule_count, 1);
1087        assert_eq!(snapshot.fact_count, 1);
1088        assert_eq!(snapshot.rules[0].head_predicate, "grandparent");
1089        assert_eq!(snapshot.facts[0].predicate, "parent");
1090    }
1091
1092    #[test]
1093    fn test_snapshot_empty_kb() {
1094        let store = make_store("empty");
1095        let snap_path = std::env::temp_dir().join("ipfrs-snap-empty.json");
1096
1097        store
1098            .save_snapshot(&snap_path)
1099            .expect("save empty snapshot");
1100        let mut store2 = make_store("empty-load");
1101        let snapshot = store2
1102            .load_snapshot(&snap_path)
1103            .expect("load empty snapshot");
1104
1105        assert_eq!(snapshot.rule_count, 0);
1106        assert_eq!(snapshot.fact_count, 0);
1107        assert!(snapshot.rules.is_empty());
1108        assert!(snapshot.facts.is_empty());
1109    }
1110
1111    #[test]
1112    fn test_is_dirty_tracking() {
1113        let store = make_store("dirty");
1114
1115        // Fresh store: not dirty
1116        assert!(!store.is_dirty(), "Fresh store should not be dirty");
1117
1118        // Adding a fact marks it dirty
1119        store
1120            .add_fact(Predicate::new(
1121                "test".to_string(),
1122                vec![Term::Const(Constant::String("x".to_string()))],
1123            ))
1124            .expect("test: should succeed");
1125        assert!(store.is_dirty(), "Store should be dirty after add_fact");
1126
1127        // Saving clears the dirty flag
1128        let snap_path = std::env::temp_dir().join("ipfrs-snap-dirty.json");
1129        store.save_snapshot(&snap_path).expect("save snapshot");
1130        assert!(!store.is_dirty(), "Store should not be dirty after save");
1131
1132        // Adding a rule marks it dirty again
1133        store
1134            .add_rule(grandparent_rule())
1135            .expect("test: should succeed");
1136        assert!(store.is_dirty(), "Store should be dirty after add_rule");
1137    }
1138}
1139
1140#[cfg(test)]
1141mod inference_tracking_tests {
1142    use super::*;
1143    use crate::ir::{Constant, Predicate, Term};
1144    use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
1145    use std::sync::Arc;
1146
1147    fn make_store(suffix: &str) -> TensorLogicStore<SledBlockStore> {
1148        let path = std::env::temp_dir().join(format!("ipfrs-inf-track-{}", suffix));
1149        let _ = std::fs::remove_dir_all(&path);
1150        let config = BlockStoreConfig {
1151            path,
1152            cache_size: 8 * 1024 * 1024,
1153        };
1154        let store = Arc::new(SledBlockStore::new(config).expect("test: should succeed"));
1155        TensorLogicStore::new(store).expect("test: should succeed")
1156    }
1157
1158    fn parent_fact(a: &str, b: &str) -> Predicate {
1159        Predicate::new(
1160            "parent".to_string(),
1161            vec![
1162                Term::Const(Constant::String(a.to_string())),
1163                Term::Const(Constant::String(b.to_string())),
1164            ],
1165        )
1166    }
1167
1168    #[test]
1169    fn test_inference_time_tracking() {
1170        let store = make_store("infer-time");
1171
1172        // No inferences yet — avg should be None.
1173        assert!(
1174            store.avg_inference_ms().is_none(),
1175            "avg_inference_ms should be None before any infer() call"
1176        );
1177
1178        // Add a fact and run an inference.
1179        store
1180            .add_fact(parent_fact("alice", "bob"))
1181            .expect("test: should succeed");
1182
1183        let goal = Predicate::new(
1184            "parent".to_string(),
1185            vec![
1186                Term::Const(Constant::String("alice".to_string())),
1187                Term::Var("X".to_string()),
1188            ],
1189        );
1190        let solutions = store.infer(&goal).expect("test: should succeed");
1191        assert!(!solutions.is_empty(), "should find at least one solution");
1192
1193        // avg_inference_ms should now be Some.
1194        let avg = store.avg_inference_ms();
1195        assert!(
1196            avg.is_some(),
1197            "avg_inference_ms should be Some after infer() call"
1198        );
1199        assert!(
1200            avg.expect("test: should succeed") >= 0.0,
1201            "avg_inference_ms should be non-negative"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_memory_bytes_nonzero() {
1207        let store = make_store("mem-bytes");
1208
1209        // Empty KB → 0 estimated bytes.
1210        assert_eq!(
1211            store.estimated_memory_bytes(),
1212            0,
1213            "empty KB should report 0 estimated bytes"
1214        );
1215
1216        // Add a fact → estimate should be > 0.
1217        store
1218            .add_fact(parent_fact("alice", "bob"))
1219            .expect("test: should succeed");
1220        let estimate = store.estimated_memory_bytes();
1221        assert!(
1222            estimate > 0,
1223            "estimated_memory_bytes should be > 0 after adding a fact (got {})",
1224            estimate
1225        );
1226    }
1227}