Skip to main content

ipfrs_semantic/
attribution_tracker.rs

1//! # Semantic Attribution Tracker
2//!
3//! Tracks which source documents contributed to search results and inference outputs,
4//! providing attribution chains for explainability and audit.
5
6use std::collections::HashMap;
7
8/// Identifies the type and origin of a contributing source.
9#[derive(Clone, Debug, PartialEq)]
10pub enum AttributionSource {
11    /// A source document contribution with a relevance score.
12    Document { doc_id: u64, relevance: f32 },
13    /// An embedding contribution with a cosine-similarity score.
14    Embedding { embedding_id: u64, similarity: f32 },
15    /// An inference result contribution with a confidence score.
16    InferenceResult { result_id: u64, confidence: f32 },
17}
18
19impl AttributionSource {
20    /// Returns the scalar weight for this source (relevance / similarity / confidence).
21    pub fn weight(&self) -> f32 {
22        match self {
23            Self::Document { relevance, .. } => *relevance,
24            Self::Embedding { similarity, .. } => *similarity,
25            Self::InferenceResult { confidence, .. } => *confidence,
26        }
27    }
28}
29
30/// A single attribution record linking an output to its contributing sources.
31#[derive(Clone, Debug)]
32pub struct AttributionRecord {
33    /// Unique id of this record.
34    pub record_id: u64,
35    /// The output being attributed (query result id, inference output id, etc.).
36    pub output_id: u64,
37    /// Ordered list of contributing sources.
38    pub sources: Vec<AttributionSource>,
39    /// Logical clock tick at which this record was created.
40    pub tick: u64,
41    /// Optional session that produced this output.
42    pub session_id: Option<u64>,
43}
44
45impl AttributionRecord {
46    /// Returns the source with the highest weight, or `None` when sources is empty.
47    pub fn top_source(&self) -> Option<&AttributionSource> {
48        self.sources
49            .iter()
50            .reduce(|best, s| if s.weight() > best.weight() { s } else { best })
51    }
52
53    /// Returns the number of contributing sources.
54    pub fn source_count(&self) -> usize {
55        self.sources.len()
56    }
57}
58
59/// Aggregate statistics over all records held by a [`SemanticAttributionTracker`].
60#[derive(Clone, Debug, PartialEq)]
61pub struct AttributionStats {
62    /// Total number of attribution records.
63    pub total_records: usize,
64    /// Sum of all source counts across every record.
65    pub total_sources: usize,
66    /// Number of `AttributionSource::Document` entries across all records.
67    pub document_attributions: u64,
68    /// Number of `AttributionSource::Embedding` entries across all records.
69    pub embedding_attributions: u64,
70    /// Number of `AttributionSource::InferenceResult` entries across all records.
71    pub inference_attributions: u64,
72    /// Average number of sources per record (0.0 when there are no records).
73    pub avg_sources_per_record: f64,
74}
75
76/// Tracks attribution chains between outputs and their source documents / embeddings /
77/// inference results, enabling explainability and audit.
78pub struct SemanticAttributionTracker {
79    /// Primary store: record_id → record.
80    pub records: HashMap<u64, AttributionRecord>,
81    /// Secondary index: output_id → list of record_ids.
82    pub output_index: HashMap<u64, Vec<u64>>,
83    /// Secondary index: session_id → list of record_ids.
84    pub session_index: HashMap<u64, Vec<u64>>,
85    next_record_id: u64,
86}
87
88impl SemanticAttributionTracker {
89    /// Creates an empty tracker.
90    pub fn new() -> Self {
91        Self {
92            records: HashMap::new(),
93            output_index: HashMap::new(),
94            session_index: HashMap::new(),
95            next_record_id: 0,
96        }
97    }
98
99    /// Records an attribution event and returns the new `record_id`.
100    pub fn record(
101        &mut self,
102        output_id: u64,
103        sources: Vec<AttributionSource>,
104        tick: u64,
105        session_id: Option<u64>,
106    ) -> u64 {
107        let record_id = self.next_record_id;
108        self.next_record_id += 1;
109
110        let rec = AttributionRecord {
111            record_id,
112            output_id,
113            sources,
114            tick,
115            session_id,
116        };
117
118        self.output_index
119            .entry(output_id)
120            .or_default()
121            .push(record_id);
122
123        if let Some(sid) = session_id {
124            self.session_index.entry(sid).or_default().push(record_id);
125        }
126
127        self.records.insert(record_id, rec);
128        record_id
129    }
130
131    /// Returns all records attributed to `output_id`, sorted by `record_id` ascending.
132    pub fn records_for_output(&self, output_id: u64) -> Vec<&AttributionRecord> {
133        let ids = match self.output_index.get(&output_id) {
134            Some(v) => v,
135            None => return Vec::new(),
136        };
137
138        let mut recs: Vec<&AttributionRecord> =
139            ids.iter().filter_map(|id| self.records.get(id)).collect();
140
141        recs.sort_by_key(|r| r.record_id);
142        recs
143    }
144
145    /// Returns all records belonging to `session_id`, sorted by `tick` ascending then
146    /// `record_id` ascending.
147    pub fn records_for_session(&self, session_id: u64) -> Vec<&AttributionRecord> {
148        let ids = match self.session_index.get(&session_id) {
149            Some(v) => v,
150            None => return Vec::new(),
151        };
152
153        let mut recs: Vec<&AttributionRecord> =
154            ids.iter().filter_map(|id| self.records.get(id)).collect();
155
156        recs.sort_by(|a, b| a.tick.cmp(&b.tick).then(a.record_id.cmp(&b.record_id)));
157        recs
158    }
159
160    /// Returns the `n` document ids most frequently appearing as
161    /// [`AttributionSource::Document`] across all records.
162    ///
163    /// Ties are broken by `doc_id` ascending.  The result is truncated to `n` entries.
164    pub fn top_documents(&self, n: usize) -> Vec<u64> {
165        let mut freq: HashMap<u64, usize> = HashMap::new();
166
167        for rec in self.records.values() {
168            for src in &rec.sources {
169                if let AttributionSource::Document { doc_id, .. } = src {
170                    *freq.entry(*doc_id).or_insert(0) += 1;
171                }
172            }
173        }
174
175        let mut pairs: Vec<(u64, usize)> = freq.into_iter().collect();
176        // Sort descending by frequency, ascending by doc_id for ties.
177        pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
178        pairs.into_iter().take(n).map(|(id, _)| id).collect()
179    }
180
181    /// Removes a record and cleans up all secondary indices.
182    /// Returns `true` if the record existed.
183    pub fn remove_record(&mut self, record_id: u64) -> bool {
184        let rec = match self.records.remove(&record_id) {
185            Some(r) => r,
186            None => return false,
187        };
188
189        // Clean output_index.
190        if let Some(ids) = self.output_index.get_mut(&rec.output_id) {
191            ids.retain(|id| *id != record_id);
192            if ids.is_empty() {
193                self.output_index.remove(&rec.output_id);
194            }
195        }
196
197        // Clean session_index.
198        if let Some(sid) = rec.session_id {
199            if let Some(ids) = self.session_index.get_mut(&sid) {
200                ids.retain(|id| *id != record_id);
201                if ids.is_empty() {
202                    self.session_index.remove(&sid);
203                }
204            }
205        }
206
207        true
208    }
209
210    /// Computes aggregate statistics over all held records.
211    pub fn stats(&self) -> AttributionStats {
212        let total_records = self.records.len();
213        let mut total_sources: usize = 0;
214        let mut document_attributions: u64 = 0;
215        let mut embedding_attributions: u64 = 0;
216        let mut inference_attributions: u64 = 0;
217
218        for rec in self.records.values() {
219            total_sources += rec.sources.len();
220            for src in &rec.sources {
221                match src {
222                    AttributionSource::Document { .. } => document_attributions += 1,
223                    AttributionSource::Embedding { .. } => embedding_attributions += 1,
224                    AttributionSource::InferenceResult { .. } => inference_attributions += 1,
225                }
226            }
227        }
228
229        let avg_sources_per_record = if total_records == 0 {
230            0.0
231        } else {
232            total_sources as f64 / total_records as f64
233        };
234
235        AttributionStats {
236            total_records,
237            total_sources,
238            document_attributions,
239            embedding_attributions,
240            inference_attributions,
241            avg_sources_per_record,
242        }
243    }
244}
245
246impl Default for SemanticAttributionTracker {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252// ─── Tests ────────────────────────────────────────────────────────────────────
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // ── helpers ───────────────────────────────────────────────────────────────
259
260    fn doc(doc_id: u64, relevance: f32) -> AttributionSource {
261        AttributionSource::Document { doc_id, relevance }
262    }
263
264    fn emb(embedding_id: u64, similarity: f32) -> AttributionSource {
265        AttributionSource::Embedding {
266            embedding_id,
267            similarity,
268        }
269    }
270
271    fn inf(result_id: u64, confidence: f32) -> AttributionSource {
272        AttributionSource::InferenceResult {
273            result_id,
274            confidence,
275        }
276    }
277
278    // ── record creation ───────────────────────────────────────────────────────
279
280    #[test]
281    fn record_creates_attribution_record_with_correct_fields() {
282        let mut tracker = SemanticAttributionTracker::new();
283        let rid = tracker.record(42, vec![doc(1, 0.9)], 10, Some(99));
284        assert_eq!(rid, 0);
285
286        let rec = tracker.records.get(&rid).expect("record should exist");
287        assert_eq!(rec.record_id, 0);
288        assert_eq!(rec.output_id, 42);
289        assert_eq!(rec.tick, 10);
290        assert_eq!(rec.session_id, Some(99));
291        assert_eq!(rec.sources.len(), 1);
292    }
293
294    #[test]
295    fn record_ids_are_monotonically_increasing() {
296        let mut tracker = SemanticAttributionTracker::new();
297        let r0 = tracker.record(1, vec![], 0, None);
298        let r1 = tracker.record(1, vec![], 1, None);
299        let r2 = tracker.record(1, vec![], 2, None);
300        assert!(r0 < r1 && r1 < r2);
301    }
302
303    #[test]
304    fn record_without_session_id_not_in_session_index() {
305        let mut tracker = SemanticAttributionTracker::new();
306        tracker.record(1, vec![], 0, None);
307        assert!(tracker.session_index.is_empty());
308    }
309
310    #[test]
311    fn record_populates_output_index() {
312        let mut tracker = SemanticAttributionTracker::new();
313        let rid = tracker.record(5, vec![], 0, None);
314        assert_eq!(tracker.output_index.get(&5), Some(&vec![rid]));
315    }
316
317    #[test]
318    fn record_populates_session_index() {
319        let mut tracker = SemanticAttributionTracker::new();
320        let rid = tracker.record(5, vec![], 0, Some(7));
321        assert_eq!(tracker.session_index.get(&7), Some(&vec![rid]));
322    }
323
324    // ── records_for_output ────────────────────────────────────────────────────
325
326    #[test]
327    fn records_for_output_sorted_by_record_id_ascending() {
328        let mut tracker = SemanticAttributionTracker::new();
329        let r0 = tracker.record(10, vec![], 5, None);
330        let r1 = tracker.record(10, vec![], 3, None);
331        let r2 = tracker.record(10, vec![], 7, None);
332
333        let recs = tracker.records_for_output(10);
334        let ids: Vec<u64> = recs.iter().map(|r| r.record_id).collect();
335        assert_eq!(ids, vec![r0, r1, r2]);
336    }
337
338    #[test]
339    fn records_for_output_returns_empty_for_unknown_output() {
340        let tracker = SemanticAttributionTracker::new();
341        assert!(tracker.records_for_output(999).is_empty());
342    }
343
344    #[test]
345    fn records_for_output_only_returns_matching_output() {
346        let mut tracker = SemanticAttributionTracker::new();
347        tracker.record(1, vec![], 0, None);
348        tracker.record(2, vec![], 0, None);
349        let recs = tracker.records_for_output(1);
350        assert!(recs.iter().all(|r| r.output_id == 1));
351        assert_eq!(recs.len(), 1);
352    }
353
354    // ── records_for_session ───────────────────────────────────────────────────
355
356    #[test]
357    fn records_for_session_sorted_by_tick_then_record_id() {
358        let mut tracker = SemanticAttributionTracker::new();
359        // Deliberately insert in non-sorted tick order.
360        let r_tick5 = tracker.record(10, vec![], 5, Some(1));
361        let r_tick2 = tracker.record(11, vec![], 2, Some(1));
362        let r_tick5b = tracker.record(12, vec![], 5, Some(1)); // same tick as r_tick5
363
364        let recs = tracker.records_for_session(1);
365        let ids: Vec<u64> = recs.iter().map(|r| r.record_id).collect();
366        // Expected: tick=2 first, then tick=5 (r_tick5 < r_tick5b by record_id).
367        assert_eq!(ids, vec![r_tick2, r_tick5, r_tick5b]);
368    }
369
370    #[test]
371    fn records_for_session_returns_empty_for_unknown_session() {
372        let tracker = SemanticAttributionTracker::new();
373        assert!(tracker.records_for_session(42).is_empty());
374    }
375
376    #[test]
377    fn records_for_session_isolates_sessions() {
378        let mut tracker = SemanticAttributionTracker::new();
379        tracker.record(1, vec![], 0, Some(10));
380        tracker.record(2, vec![], 0, Some(20));
381
382        assert_eq!(tracker.records_for_session(10).len(), 1);
383        assert_eq!(tracker.records_for_session(20).len(), 1);
384    }
385
386    // ── top_source ────────────────────────────────────────────────────────────
387
388    #[test]
389    fn top_source_returns_none_for_empty_sources() {
390        let rec = AttributionRecord {
391            record_id: 0,
392            output_id: 0,
393            sources: vec![],
394            tick: 0,
395            session_id: None,
396        };
397        assert!(rec.top_source().is_none());
398    }
399
400    #[test]
401    fn top_source_document_highest_relevance() {
402        let rec = AttributionRecord {
403            record_id: 0,
404            output_id: 0,
405            sources: vec![doc(1, 0.3), doc(2, 0.95), doc(3, 0.5)],
406            tick: 0,
407            session_id: None,
408        };
409        assert_eq!(rec.top_source(), Some(&doc(2, 0.95)));
410    }
411
412    #[test]
413    fn top_source_embedding_highest_similarity() {
414        let rec = AttributionRecord {
415            record_id: 0,
416            output_id: 0,
417            sources: vec![emb(1, 0.4), emb(2, 0.85)],
418            tick: 0,
419            session_id: None,
420        };
421        assert_eq!(rec.top_source(), Some(&emb(2, 0.85)));
422    }
423
424    #[test]
425    fn top_source_inference_highest_confidence() {
426        let rec = AttributionRecord {
427            record_id: 0,
428            output_id: 0,
429            sources: vec![inf(1, 0.6), inf(2, 0.99), inf(3, 0.7)],
430            tick: 0,
431            session_id: None,
432        };
433        assert_eq!(rec.top_source(), Some(&inf(2, 0.99)));
434    }
435
436    #[test]
437    fn top_source_mixed_types_picks_global_max() {
438        let rec = AttributionRecord {
439            record_id: 0,
440            output_id: 0,
441            sources: vec![doc(1, 0.7), emb(2, 0.8), inf(3, 0.75)],
442            tick: 0,
443            session_id: None,
444        };
445        assert_eq!(rec.top_source(), Some(&emb(2, 0.8)));
446    }
447
448    // ── top_documents ─────────────────────────────────────────────────────────
449
450    #[test]
451    fn top_documents_frequency_ranking() {
452        let mut tracker = SemanticAttributionTracker::new();
453        tracker.record(1, vec![doc(10, 0.9), doc(20, 0.8)], 0, None);
454        tracker.record(2, vec![doc(10, 0.7)], 0, None);
455
456        let top = tracker.top_documents(5);
457        // doc 10 appears 2 times, doc 20 appears 1 time.
458        assert_eq!(top[0], 10);
459        assert_eq!(top[1], 20);
460    }
461
462    #[test]
463    fn top_documents_tie_breaking_by_doc_id_ascending() {
464        let mut tracker = SemanticAttributionTracker::new();
465        // doc 30 and doc 5 each appear once.
466        tracker.record(1, vec![doc(30, 0.5)], 0, None);
467        tracker.record(2, vec![doc(5, 0.5)], 0, None);
468
469        let top = tracker.top_documents(5);
470        // Both have freq 1; lower doc_id should come first.
471        assert_eq!(top[0], 5);
472        assert_eq!(top[1], 30);
473    }
474
475    #[test]
476    fn top_documents_truncated_to_n() {
477        let mut tracker = SemanticAttributionTracker::new();
478        tracker.record(1, vec![doc(1, 0.9), doc(2, 0.8), doc(3, 0.7)], 0, None);
479        assert_eq!(tracker.top_documents(2).len(), 2);
480    }
481
482    #[test]
483    fn top_documents_ignores_non_document_sources() {
484        let mut tracker = SemanticAttributionTracker::new();
485        tracker.record(1, vec![emb(99, 0.9), inf(88, 0.9)], 0, None);
486        assert!(tracker.top_documents(10).is_empty());
487    }
488
489    // ── remove_record ─────────────────────────────────────────────────────────
490
491    #[test]
492    fn remove_record_returns_false_for_unknown_id() {
493        let mut tracker = SemanticAttributionTracker::new();
494        assert!(!tracker.remove_record(999));
495    }
496
497    #[test]
498    fn remove_record_removes_from_all_indices() {
499        let mut tracker = SemanticAttributionTracker::new();
500        let rid = tracker.record(1, vec![doc(10, 0.9)], 0, Some(5));
501
502        assert!(tracker.remove_record(rid));
503
504        assert!(!tracker.records.contains_key(&rid));
505        assert!(!tracker.output_index.contains_key(&1));
506        assert!(!tracker.session_index.contains_key(&5));
507    }
508
509    #[test]
510    fn remove_record_partial_output_index_cleanup() {
511        let mut tracker = SemanticAttributionTracker::new();
512        let r0 = tracker.record(1, vec![], 0, None);
513        let r1 = tracker.record(1, vec![], 1, None);
514
515        tracker.remove_record(r0);
516
517        let ids = tracker.output_index.get(&1).expect("index should remain");
518        assert_eq!(ids, &vec![r1]);
519    }
520
521    #[test]
522    fn remove_record_partial_session_index_cleanup() {
523        let mut tracker = SemanticAttributionTracker::new();
524        let r0 = tracker.record(1, vec![], 0, Some(3));
525        let r1 = tracker.record(2, vec![], 1, Some(3));
526
527        tracker.remove_record(r0);
528
529        let ids = tracker.session_index.get(&3).expect("index should remain");
530        assert_eq!(ids, &vec![r1]);
531    }
532
533    // ── stats ─────────────────────────────────────────────────────────────────
534
535    #[test]
536    fn stats_empty_tracker() {
537        let tracker = SemanticAttributionTracker::new();
538        let s = tracker.stats();
539        assert_eq!(s.total_records, 0);
540        assert_eq!(s.total_sources, 0);
541        assert_eq!(s.avg_sources_per_record, 0.0);
542    }
543
544    #[test]
545    fn stats_total_records() {
546        let mut tracker = SemanticAttributionTracker::new();
547        tracker.record(1, vec![], 0, None);
548        tracker.record(2, vec![], 0, None);
549        assert_eq!(tracker.stats().total_records, 2);
550    }
551
552    #[test]
553    fn stats_total_sources() {
554        let mut tracker = SemanticAttributionTracker::new();
555        tracker.record(1, vec![doc(1, 0.9), emb(2, 0.8)], 0, None);
556        tracker.record(2, vec![inf(3, 0.7)], 0, None);
557        assert_eq!(tracker.stats().total_sources, 3);
558    }
559
560    #[test]
561    fn stats_by_type() {
562        let mut tracker = SemanticAttributionTracker::new();
563        tracker.record(
564            1,
565            vec![doc(1, 0.9), doc(2, 0.8), emb(1, 0.7), inf(1, 0.6)],
566            0,
567            None,
568        );
569        let s = tracker.stats();
570        assert_eq!(s.document_attributions, 2);
571        assert_eq!(s.embedding_attributions, 1);
572        assert_eq!(s.inference_attributions, 1);
573    }
574
575    #[test]
576    fn stats_avg_sources_per_record() {
577        let mut tracker = SemanticAttributionTracker::new();
578        tracker.record(1, vec![doc(1, 0.9), doc(2, 0.8)], 0, None); // 2 sources
579        tracker.record(2, vec![emb(1, 0.7)], 0, None); // 1 source
580                                                       // avg = 3 / 2 = 1.5
581        let s = tracker.stats();
582        assert!((s.avg_sources_per_record - 1.5).abs() < f64::EPSILON);
583    }
584
585    #[test]
586    fn stats_session_index_tracks_correctly() {
587        let mut tracker = SemanticAttributionTracker::new();
588        tracker.record(1, vec![], 0, Some(42));
589        tracker.record(2, vec![], 0, Some(42));
590        tracker.record(3, vec![], 0, Some(99));
591
592        assert_eq!(tracker.records_for_session(42).len(), 2);
593        assert_eq!(tracker.records_for_session(99).len(), 1);
594    }
595
596    #[test]
597    fn source_count_correct() {
598        let rec = AttributionRecord {
599            record_id: 0,
600            output_id: 0,
601            sources: vec![doc(1, 0.9), emb(2, 0.8), inf(3, 0.7)],
602            tick: 0,
603            session_id: None,
604        };
605        assert_eq!(rec.source_count(), 3);
606    }
607
608    #[test]
609    fn top_documents_n_zero_returns_empty() {
610        let mut tracker = SemanticAttributionTracker::new();
611        tracker.record(1, vec![doc(1, 0.9)], 0, None);
612        assert!(tracker.top_documents(0).is_empty());
613    }
614}