Skip to main content

ipfrs_tensorlogic/
provenance_tracker.rs

1//! Tensor Provenance Tracker
2//!
3//! Tracks the full provenance of tensor values and inference results, recording
4//! which rules fired, which facts were used, and how values were derived.
5//!
6//! # Overview
7//!
8//! [`TensorProvenanceTracker`] accumulates [`ProvenanceRecord`] entries keyed by
9//! both `record_id` (globally unique) and `tensor_id` (the tensor/value being
10//! described). Each record carries a [`ProvenanceKind`] that explains how the
11//! tensor value came to exist, a confidence score, and the simulation tick at
12//! which the record was created.
13//!
14//! [`ProvenanceChain`] presents all records for a single tensor ordered
15//! oldest-first, and exposes convenience accessors (root, tip, avg_confidence).
16//!
17//! [`ProvenanceStats`] gives high-level counters across the whole tracker.
18//!
19//! # Examples
20//!
21//! ```
22//! use ipfrs_tensorlogic::provenance_tracker::{
23//!     ProvenanceKind, TensorProvenanceTracker,
24//! };
25//!
26//! let mut tracker = TensorProvenanceTracker::new();
27//!
28//! let id = tracker.record(1, ProvenanceKind::FactAssertion, 1.0, 0);
29//! assert_eq!(id, 0);
30//!
31//! let chain = tracker.chain_for(1);
32//! assert_eq!(chain.len(), 1);
33//! assert!((chain.avg_confidence() - 1.0).abs() < f64::EPSILON);
34//! ```
35
36use std::collections::HashMap;
37
38// ─── ProvenanceKind ──────────────────────────────────────────────────────────
39
40/// Describes the origin or derivation mechanism for a tensor value.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum ProvenanceKind {
43    /// Value was derived by firing a named rule.
44    RuleFired { rule_id: u64 },
45
46    /// Value was directly asserted as a fact (ground truth).
47    FactAssertion,
48
49    /// Value is the result of a numbered inference step.
50    InferenceStep { step: u32 },
51
52    /// Value was imported from an external source (e.g., a dataset file).
53    ExternalInput { source: String },
54
55    /// Value was computed from one or more other tensor values.
56    Derived { parent_ids: Vec<u64> },
57}
58
59// ─── ProvenanceRecord ────────────────────────────────────────────────────────
60
61/// A single record describing how one tensor value was produced.
62#[derive(Clone, Debug)]
63pub struct ProvenanceRecord {
64    /// Globally unique identifier for this record.
65    pub record_id: u64,
66
67    /// Identifier of the tensor/value this record describes.
68    pub tensor_id: u64,
69
70    /// How this value was derived.
71    pub kind: ProvenanceKind,
72
73    /// Simulation/inference tick at which this record was created.
74    pub created_at_tick: u64,
75
76    /// Confidence in the value, in [0.0, 1.0].
77    pub confidence: f64,
78}
79
80impl ProvenanceRecord {
81    /// Returns `true` when the value originates entirely from outside the
82    /// inference engine (i.e., a fact assertion or external import).
83    pub fn is_externally_sourced(&self) -> bool {
84        matches!(
85            self.kind,
86            ProvenanceKind::FactAssertion | ProvenanceKind::ExternalInput { .. }
87        )
88    }
89}
90
91// ─── ProvenanceChain ─────────────────────────────────────────────────────────
92
93/// Ordered sequence of provenance records for a single tensor, oldest first.
94#[derive(Clone, Debug, Default)]
95pub struct ProvenanceChain {
96    /// Records ordered oldest → newest (insertion order).
97    pub chain: Vec<ProvenanceRecord>,
98}
99
100impl ProvenanceChain {
101    /// Returns a reference to the oldest record in the chain, or `None` if
102    /// the chain is empty.
103    pub fn root(&self) -> Option<&ProvenanceRecord> {
104        self.chain.first()
105    }
106
107    /// Returns a reference to the newest record in the chain, or `None` if
108    /// the chain is empty.
109    pub fn tip(&self) -> Option<&ProvenanceRecord> {
110        self.chain.last()
111    }
112
113    /// Returns the arithmetic mean of all record confidence values, or `0.0`
114    /// when the chain is empty.
115    pub fn avg_confidence(&self) -> f64 {
116        if self.chain.is_empty() {
117            return 0.0;
118        }
119        let sum: f64 = self.chain.iter().map(|r| r.confidence).sum();
120        sum / self.chain.len() as f64
121    }
122
123    /// Returns the number of records in the chain.
124    pub fn len(&self) -> usize {
125        self.chain.len()
126    }
127
128    /// Returns `true` when the chain contains no records.
129    pub fn is_empty(&self) -> bool {
130        self.chain.is_empty()
131    }
132}
133
134// ─── ProvenanceStats ─────────────────────────────────────────────────────────
135
136/// High-level statistics across the entire [`TensorProvenanceTracker`].
137#[derive(Clone, Debug)]
138pub struct ProvenanceStats {
139    /// Total number of provenance records stored.
140    pub total_records: usize,
141
142    /// Number of distinct tensor IDs that have at least one record.
143    pub unique_tensors: usize,
144
145    /// Number of records whose kind is [`ProvenanceKind::FactAssertion`].
146    pub fact_count: usize,
147
148    /// Number of records whose kind is [`ProvenanceKind::RuleFired`].
149    pub rule_fired_count: usize,
150
151    /// Arithmetic mean of `confidence` across all records (0.0 if no records).
152    pub avg_confidence: f64,
153}
154
155// ─── TensorProvenanceTracker ─────────────────────────────────────────────────
156
157/// Tracks the full provenance of tensor values and inference results.
158///
159/// Records are keyed by a globally unique `record_id` and can also be looked up
160/// by `tensor_id` (returning all records for that tensor in insertion order).
161#[derive(Debug, Default)]
162pub struct TensorProvenanceTracker {
163    /// All records, keyed by `record_id`.
164    pub records: HashMap<u64, ProvenanceRecord>,
165
166    /// Maps each `tensor_id` to the ordered list of `record_id`s for that tensor.
167    pub tensor_records: HashMap<u64, Vec<u64>>,
168
169    /// Monotonically increasing counter used to assign record IDs.
170    pub next_record_id: u64,
171}
172
173impl TensorProvenanceTracker {
174    /// Creates a new, empty tracker.
175    pub fn new() -> Self {
176        Self {
177            records: HashMap::new(),
178            tensor_records: HashMap::new(),
179            next_record_id: 0,
180        }
181    }
182
183    /// Records a new provenance entry for `tensor_id`.
184    ///
185    /// Returns the newly assigned `record_id`.
186    pub fn record(
187        &mut self,
188        tensor_id: u64,
189        kind: ProvenanceKind,
190        confidence: f64,
191        tick: u64,
192    ) -> u64 {
193        let record_id = self.next_record_id;
194        self.next_record_id += 1;
195
196        let entry = ProvenanceRecord {
197            record_id,
198            tensor_id,
199            kind,
200            created_at_tick: tick,
201            confidence,
202        };
203
204        self.records.insert(record_id, entry);
205        self.tensor_records
206            .entry(tensor_id)
207            .or_default()
208            .push(record_id);
209
210        record_id
211    }
212
213    /// Returns the [`ProvenanceChain`] for `tensor_id` in insertion order.
214    ///
215    /// Returns an empty chain when `tensor_id` is not known.
216    pub fn chain_for(&self, tensor_id: u64) -> ProvenanceChain {
217        let chain = self
218            .tensor_records
219            .get(&tensor_id)
220            .map(|ids| {
221                ids.iter()
222                    .filter_map(|id| self.records.get(id).cloned())
223                    .collect()
224            })
225            .unwrap_or_default();
226        ProvenanceChain { chain }
227    }
228
229    /// Returns a reference to the record with the given `record_id`, or `None`.
230    pub fn get_record(&self, record_id: u64) -> Option<&ProvenanceRecord> {
231        self.records.get(&record_id)
232    }
233
234    /// Returns all records for `tensor_id` in insertion order.
235    pub fn records_for_tensor(&self, tensor_id: u64) -> Vec<&ProvenanceRecord> {
236        self.tensor_records
237            .get(&tensor_id)
238            .map(|ids| ids.iter().filter_map(|id| self.records.get(id)).collect())
239            .unwrap_or_default()
240    }
241
242    /// Returns a sorted list of tensor IDs for which at least one record is
243    /// externally sourced (i.e., [`ProvenanceRecord::is_externally_sourced`]
244    /// returns `true`).
245    pub fn externally_sourced_tensors(&self) -> Vec<u64> {
246        let mut result: Vec<u64> = self
247            .tensor_records
248            .iter()
249            .filter(|(_tid, ids)| {
250                ids.iter()
251                    .filter_map(|id| self.records.get(id))
252                    .any(|r| r.is_externally_sourced())
253            })
254            .map(|(tid, _)| *tid)
255            .collect();
256        result.sort_unstable();
257        result
258    }
259
260    /// Removes all records associated with `tensor_id`.
261    ///
262    /// Returns `true` on success, `false` if `tensor_id` was not found.
263    pub fn delete_tensor(&mut self, tensor_id: u64) -> bool {
264        match self.tensor_records.remove(&tensor_id) {
265            None => false,
266            Some(ids) => {
267                for id in ids {
268                    self.records.remove(&id);
269                }
270                true
271            }
272        }
273    }
274
275    /// Returns aggregate statistics across all stored records.
276    pub fn stats(&self) -> ProvenanceStats {
277        let total_records = self.records.len();
278        let unique_tensors = self.tensor_records.len();
279
280        let mut fact_count = 0usize;
281        let mut rule_fired_count = 0usize;
282        let mut confidence_sum = 0.0_f64;
283
284        for record in self.records.values() {
285            match &record.kind {
286                ProvenanceKind::FactAssertion => fact_count += 1,
287                ProvenanceKind::RuleFired { .. } => rule_fired_count += 1,
288                _ => {}
289            }
290            confidence_sum += record.confidence;
291        }
292
293        let avg_confidence = if total_records == 0 {
294            0.0
295        } else {
296            confidence_sum / total_records as f64
297        };
298
299        ProvenanceStats {
300            total_records,
301            unique_tensors,
302            fact_count,
303            rule_fired_count,
304            avg_confidence,
305        }
306    }
307}
308
309// ─── Tests ───────────────────────────────────────────────────────────────────
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    // ── construction ──────────────────────────────────────────────────────────
316
317    #[test]
318    fn new_starts_empty() {
319        let tracker = TensorProvenanceTracker::new();
320        assert!(tracker.records.is_empty());
321        assert!(tracker.tensor_records.is_empty());
322        assert_eq!(tracker.next_record_id, 0);
323    }
324
325    // ── record ────────────────────────────────────────────────────────────────
326
327    #[test]
328    fn record_stores_and_returns_id() {
329        let mut t = TensorProvenanceTracker::new();
330        let id = t.record(42, ProvenanceKind::FactAssertion, 1.0, 0);
331        assert_eq!(id, 0);
332        assert!(t.records.contains_key(&0));
333    }
334
335    #[test]
336    fn record_increments_id() {
337        let mut t = TensorProvenanceTracker::new();
338        let a = t.record(1, ProvenanceKind::FactAssertion, 1.0, 0);
339        let b = t.record(2, ProvenanceKind::FactAssertion, 1.0, 1);
340        assert_eq!(a, 0);
341        assert_eq!(b, 1);
342    }
343
344    #[test]
345    fn record_appends_to_tensor_records() {
346        let mut t = TensorProvenanceTracker::new();
347        t.record(10, ProvenanceKind::FactAssertion, 1.0, 0);
348        assert_eq!(t.tensor_records[&10], vec![0u64]);
349    }
350
351    #[test]
352    fn multiple_records_for_same_tensor() {
353        let mut t = TensorProvenanceTracker::new();
354        t.record(5, ProvenanceKind::FactAssertion, 0.8, 0);
355        t.record(5, ProvenanceKind::RuleFired { rule_id: 99 }, 0.9, 1);
356        assert_eq!(t.tensor_records[&5], vec![0u64, 1u64]);
357        assert_eq!(t.records.len(), 2);
358    }
359
360    // ── chain_for ─────────────────────────────────────────────────────────────
361
362    #[test]
363    fn chain_for_returns_in_insertion_order() {
364        let mut t = TensorProvenanceTracker::new();
365        t.record(7, ProvenanceKind::FactAssertion, 0.5, 0);
366        t.record(7, ProvenanceKind::InferenceStep { step: 1 }, 0.7, 1);
367        t.record(7, ProvenanceKind::RuleFired { rule_id: 3 }, 0.9, 2);
368        let chain = t.chain_for(7);
369        assert_eq!(chain.len(), 3);
370        assert_eq!(chain.chain[0].record_id, 0);
371        assert_eq!(chain.chain[1].record_id, 1);
372        assert_eq!(chain.chain[2].record_id, 2);
373    }
374
375    #[test]
376    fn chain_for_empty_chain_for_unknown_tensor() {
377        let t = TensorProvenanceTracker::new();
378        let chain = t.chain_for(999);
379        assert!(chain.is_empty());
380    }
381
382    // ── ProvenanceChain ───────────────────────────────────────────────────────
383
384    #[test]
385    fn provenance_chain_root_and_tip() {
386        let mut t = TensorProvenanceTracker::new();
387        t.record(3, ProvenanceKind::FactAssertion, 0.4, 0);
388        t.record(3, ProvenanceKind::RuleFired { rule_id: 1 }, 0.6, 1);
389        let chain = t.chain_for(3);
390        assert_eq!(chain.root().map(|r| r.record_id), Some(0));
391        assert_eq!(chain.tip().map(|r| r.record_id), Some(1));
392    }
393
394    #[test]
395    fn provenance_chain_root_none_when_empty() {
396        let chain = ProvenanceChain::default();
397        assert!(chain.root().is_none());
398        assert!(chain.tip().is_none());
399    }
400
401    #[test]
402    fn provenance_chain_avg_confidence() {
403        let mut t = TensorProvenanceTracker::new();
404        t.record(8, ProvenanceKind::FactAssertion, 0.4, 0);
405        t.record(8, ProvenanceKind::InferenceStep { step: 2 }, 0.6, 1);
406        let chain = t.chain_for(8);
407        let avg = chain.avg_confidence();
408        assert!((avg - 0.5).abs() < 1e-10);
409    }
410
411    #[test]
412    fn provenance_chain_avg_confidence_empty() {
413        let chain = ProvenanceChain::default();
414        assert_eq!(chain.avg_confidence(), 0.0);
415    }
416
417    #[test]
418    fn provenance_chain_len_and_is_empty() {
419        let mut t = TensorProvenanceTracker::new();
420        let empty = t.chain_for(0);
421        assert_eq!(empty.len(), 0);
422        assert!(empty.is_empty());
423
424        t.record(0, ProvenanceKind::FactAssertion, 1.0, 0);
425        let non_empty = t.chain_for(0);
426        assert_eq!(non_empty.len(), 1);
427        assert!(!non_empty.is_empty());
428    }
429
430    // ── get_record ────────────────────────────────────────────────────────────
431
432    #[test]
433    fn get_record_some() {
434        let mut t = TensorProvenanceTracker::new();
435        let id = t.record(1, ProvenanceKind::FactAssertion, 1.0, 5);
436        let rec = t.get_record(id);
437        assert!(rec.is_some());
438        assert_eq!(rec.expect("test: should succeed").tensor_id, 1);
439        assert_eq!(rec.expect("test: should succeed").created_at_tick, 5);
440    }
441
442    #[test]
443    fn get_record_none_for_unknown() {
444        let t = TensorProvenanceTracker::new();
445        assert!(t.get_record(9999).is_none());
446    }
447
448    // ── records_for_tensor ────────────────────────────────────────────────────
449
450    #[test]
451    fn records_for_tensor_insertion_order() {
452        let mut t = TensorProvenanceTracker::new();
453        t.record(20, ProvenanceKind::FactAssertion, 0.3, 0);
454        t.record(20, ProvenanceKind::RuleFired { rule_id: 7 }, 0.7, 1);
455        let recs = t.records_for_tensor(20);
456        assert_eq!(recs.len(), 2);
457        assert_eq!(recs[0].record_id, 0);
458        assert_eq!(recs[1].record_id, 1);
459    }
460
461    #[test]
462    fn records_for_tensor_empty_for_unknown() {
463        let t = TensorProvenanceTracker::new();
464        assert!(t.records_for_tensor(404).is_empty());
465    }
466
467    // ── is_externally_sourced ─────────────────────────────────────────────────
468
469    #[test]
470    fn is_externally_sourced_fact_assertion_true() {
471        let rec = ProvenanceRecord {
472            record_id: 0,
473            tensor_id: 0,
474            kind: ProvenanceKind::FactAssertion,
475            created_at_tick: 0,
476            confidence: 1.0,
477        };
478        assert!(rec.is_externally_sourced());
479    }
480
481    #[test]
482    fn is_externally_sourced_external_input_true() {
483        let rec = ProvenanceRecord {
484            record_id: 0,
485            tensor_id: 0,
486            kind: ProvenanceKind::ExternalInput {
487                source: "file.csv".to_string(),
488            },
489            created_at_tick: 0,
490            confidence: 0.9,
491        };
492        assert!(rec.is_externally_sourced());
493    }
494
495    #[test]
496    fn is_externally_sourced_rule_fired_false() {
497        let rec = ProvenanceRecord {
498            record_id: 0,
499            tensor_id: 0,
500            kind: ProvenanceKind::RuleFired { rule_id: 1 },
501            created_at_tick: 0,
502            confidence: 0.8,
503        };
504        assert!(!rec.is_externally_sourced());
505    }
506
507    #[test]
508    fn is_externally_sourced_inference_step_false() {
509        let rec = ProvenanceRecord {
510            record_id: 0,
511            tensor_id: 0,
512            kind: ProvenanceKind::InferenceStep { step: 3 },
513            created_at_tick: 0,
514            confidence: 0.7,
515        };
516        assert!(!rec.is_externally_sourced());
517    }
518
519    #[test]
520    fn is_externally_sourced_derived_false() {
521        let rec = ProvenanceRecord {
522            record_id: 0,
523            tensor_id: 0,
524            kind: ProvenanceKind::Derived {
525                parent_ids: vec![1, 2],
526            },
527            created_at_tick: 0,
528            confidence: 0.6,
529        };
530        assert!(!rec.is_externally_sourced());
531    }
532
533    // ── externally_sourced_tensors ────────────────────────────────────────────
534
535    #[test]
536    fn externally_sourced_tensors_correct_and_sorted() {
537        let mut t = TensorProvenanceTracker::new();
538        // tensor 5: external
539        t.record(5, ProvenanceKind::FactAssertion, 1.0, 0);
540        // tensor 2: external via ExternalInput
541        t.record(
542            2,
543            ProvenanceKind::ExternalInput {
544                source: "db".to_string(),
545            },
546            0.9,
547            1,
548        );
549        // tensor 9: NOT external
550        t.record(9, ProvenanceKind::RuleFired { rule_id: 0 }, 0.5, 2);
551        // tensor 1: mixed; one external record → qualifies
552        t.record(1, ProvenanceKind::InferenceStep { step: 0 }, 0.3, 3);
553        t.record(1, ProvenanceKind::FactAssertion, 0.8, 4);
554
555        let ext = t.externally_sourced_tensors();
556        assert_eq!(ext, vec![1u64, 2u64, 5u64]);
557    }
558
559    // ── delete_tensor ─────────────────────────────────────────────────────────
560
561    #[test]
562    fn delete_tensor_removes_all_records() {
563        let mut t = TensorProvenanceTracker::new();
564        t.record(4, ProvenanceKind::FactAssertion, 1.0, 0);
565        t.record(4, ProvenanceKind::RuleFired { rule_id: 2 }, 0.5, 1);
566        assert_eq!(t.records.len(), 2);
567
568        let ok = t.delete_tensor(4);
569        assert!(ok);
570        assert!(t.records.is_empty());
571        assert!(!t.tensor_records.contains_key(&4));
572    }
573
574    #[test]
575    fn delete_tensor_returns_false_for_unknown() {
576        let mut t = TensorProvenanceTracker::new();
577        assert!(!t.delete_tensor(123));
578    }
579
580    // ── stats ─────────────────────────────────────────────────────────────────
581
582    #[test]
583    fn stats_total_records_and_unique_tensors() {
584        let mut t = TensorProvenanceTracker::new();
585        t.record(1, ProvenanceKind::FactAssertion, 1.0, 0);
586        t.record(1, ProvenanceKind::RuleFired { rule_id: 0 }, 0.8, 1);
587        t.record(2, ProvenanceKind::FactAssertion, 0.5, 2);
588        let s = t.stats();
589        assert_eq!(s.total_records, 3);
590        assert_eq!(s.unique_tensors, 2);
591    }
592
593    #[test]
594    fn stats_fact_count_and_rule_fired_count() {
595        let mut t = TensorProvenanceTracker::new();
596        t.record(1, ProvenanceKind::FactAssertion, 1.0, 0);
597        t.record(2, ProvenanceKind::FactAssertion, 1.0, 1);
598        t.record(3, ProvenanceKind::RuleFired { rule_id: 7 }, 0.9, 2);
599        t.record(4, ProvenanceKind::InferenceStep { step: 0 }, 0.6, 3);
600        let s = t.stats();
601        assert_eq!(s.fact_count, 2);
602        assert_eq!(s.rule_fired_count, 1);
603    }
604
605    #[test]
606    fn stats_avg_confidence() {
607        let mut t = TensorProvenanceTracker::new();
608        t.record(1, ProvenanceKind::FactAssertion, 0.8, 0);
609        t.record(2, ProvenanceKind::RuleFired { rule_id: 0 }, 0.4, 1);
610        let s = t.stats();
611        assert!((s.avg_confidence - 0.6).abs() < 1e-10);
612    }
613
614    #[test]
615    fn stats_empty_tracker() {
616        let t = TensorProvenanceTracker::new();
617        let s = t.stats();
618        assert_eq!(s.total_records, 0);
619        assert_eq!(s.unique_tensors, 0);
620        assert_eq!(s.fact_count, 0);
621        assert_eq!(s.rule_fired_count, 0);
622        assert_eq!(s.avg_confidence, 0.0);
623    }
624}