1use std::collections::HashMap;
7
8#[derive(Clone, Debug, PartialEq)]
10pub enum AttributionSource {
11 Document { doc_id: u64, relevance: f32 },
13 Embedding { embedding_id: u64, similarity: f32 },
15 InferenceResult { result_id: u64, confidence: f32 },
17}
18
19impl AttributionSource {
20 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#[derive(Clone, Debug)]
32pub struct AttributionRecord {
33 pub record_id: u64,
35 pub output_id: u64,
37 pub sources: Vec<AttributionSource>,
39 pub tick: u64,
41 pub session_id: Option<u64>,
43}
44
45impl AttributionRecord {
46 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 pub fn source_count(&self) -> usize {
55 self.sources.len()
56 }
57}
58
59#[derive(Clone, Debug, PartialEq)]
61pub struct AttributionStats {
62 pub total_records: usize,
64 pub total_sources: usize,
66 pub document_attributions: u64,
68 pub embedding_attributions: u64,
70 pub inference_attributions: u64,
72 pub avg_sources_per_record: f64,
74}
75
76pub struct SemanticAttributionTracker {
79 pub records: HashMap<u64, AttributionRecord>,
81 pub output_index: HashMap<u64, Vec<u64>>,
83 pub session_index: HashMap<u64, Vec<u64>>,
85 next_record_id: u64,
86}
87
88impl SemanticAttributionTracker {
89 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
255mod tests {
256 use super::*;
257
258 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 #[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 #[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 #[test]
357 fn records_for_session_sorted_by_tick_then_record_id() {
358 let mut tracker = SemanticAttributionTracker::new();
359 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)); let recs = tracker.records_for_session(1);
365 let ids: Vec<u64> = recs.iter().map(|r| r.record_id).collect();
366 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 #[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 #[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 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 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 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 #[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 #[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); tracker.record(2, vec![emb(1, 0.7)], 0, None); 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}