use std::cmp::Ordering;
use std::collections::HashMap;
use amari_holographic::BindingAlgebra;
use crate::error::{MinuetError, Result};
fn inner_product<A: BindingAlgebra>(a: &A, b: &A) -> f64 {
a.similarity(b) * a.norm() * b.norm()
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AttributionResult {
pub contributions: HashMap<u64, f64>,
pub top_contributors: Vec<(u64, f64)>,
pub total_mass: f64,
pub is_approximate: bool,
}
impl AttributionResult {
#[must_use]
pub fn contribution(&self, store_id: u64) -> Option<f64> {
self.contributions.get(&store_id).copied()
}
#[must_use]
pub fn top_n(&self, n: usize) -> &[(u64, f64)] {
&self.top_contributors[..n.min(self.top_contributors.len())]
}
#[must_use]
pub fn is_significant(&self, store_id: u64, threshold: f64) -> bool {
self.contributions
.get(&store_id)
.is_some_and(|&c| c >= threshold)
}
#[must_use]
pub fn above_threshold(&self, threshold: f64) -> Vec<u64> {
self.contributions
.iter()
.filter(|(_, &v)| v >= threshold)
.map(|(&k, _)| k)
.collect()
}
}
pub struct Attribution<A: BindingAlgebra> {
bindings: Vec<(u64, A)>,
approximate: bool,
threshold: f64,
max_attributions: usize,
}
impl<A: BindingAlgebra> Attribution<A> {
#[must_use]
pub fn new() -> Self {
Self {
bindings: Vec::new(),
approximate: true,
threshold: 0.01,
max_attributions: 100,
}
}
#[must_use]
pub fn exact(mut self) -> Self {
self.approximate = false;
self
}
#[must_use]
pub fn with_threshold(mut self, threshold: f64) -> Self {
self.threshold = threshold;
self
}
#[must_use]
pub fn with_max(mut self, max: usize) -> Self {
self.max_attributions = max;
self
}
pub fn register(&mut self, store_id: u64, binding: A) {
self.bindings.push((store_id, binding));
}
pub fn clear(&mut self) {
self.bindings.clear();
}
pub fn compute(&self, query: &A, result: &A) -> Result<AttributionResult> {
if self.bindings.is_empty() {
return Ok(AttributionResult {
contributions: HashMap::new(),
top_contributors: Vec::new(),
total_mass: 0.0,
is_approximate: self.approximate,
});
}
let mut contributions = HashMap::new();
let mut total_mass = 0.0;
for (store_id, binding) in &self.bindings {
let unbound = query.unbind(binding).map_err(MinuetError::algebra)?;
let sim = unbound.similarity(result);
if sim > self.threshold {
contributions.insert(*store_id, sim);
total_mass += sim;
}
}
if total_mass > 0.0 {
for v in contributions.values_mut() {
*v /= total_mass;
}
}
let mut top_contributors: Vec<(u64, f64)> =
contributions.iter().map(|(&k, &v)| (k, v)).collect();
top_contributors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
top_contributors.truncate(self.max_attributions);
Ok(AttributionResult {
contributions,
top_contributors,
total_mass: 1.0, is_approximate: self.approximate,
})
}
pub fn compute_gradient(&self, query: &A, result: &A) -> Result<AttributionResult> {
if self.bindings.is_empty() {
return Ok(AttributionResult {
contributions: HashMap::new(),
top_contributors: Vec::new(),
total_mass: 0.0,
is_approximate: false,
});
}
let result_norm_sq = {
let n = result.norm();
n * n
};
if result_norm_sq <= 0.0 {
return Ok(AttributionResult {
contributions: HashMap::new(),
top_contributors: Vec::new(),
total_mass: 0.0,
is_approximate: false,
});
}
let mut contributions = HashMap::new();
let mut total_mass = 0.0;
for (store_id, binding) in &self.bindings {
let r_i = query.unbind(binding).map_err(MinuetError::algebra)?;
let proj = inner_product(&r_i, result) / result_norm_sq;
contributions.insert(*store_id, proj);
total_mass += proj;
}
let mut top_contributors: Vec<(u64, f64)> =
contributions.iter().map(|(&k, &v)| (k, v)).collect();
top_contributors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
top_contributors.truncate(self.max_attributions);
Ok(AttributionResult {
contributions,
top_contributors,
total_mass,
is_approximate: false,
})
}
#[must_use]
pub fn binding_count(&self) -> usize {
self.bindings.len()
}
}
impl<A: BindingAlgebra> Default for Attribution<A> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct AttributionQuery<A: BindingAlgebra> {
query: A,
result: A,
store_ids: Option<Vec<u64>>,
threshold: f64,
}
impl<A: BindingAlgebra> AttributionQuery<A> {
#[must_use]
pub fn new(query: A, result: A) -> Self {
Self {
query,
result,
store_ids: None,
threshold: 0.01,
}
}
#[must_use]
pub fn for_stores(mut self, ids: Vec<u64>) -> Self {
self.store_ids = Some(ids);
self
}
#[must_use]
pub fn threshold(mut self, t: f64) -> Self {
self.threshold = t;
self
}
#[must_use]
pub fn query(&self) -> &A {
&self.query
}
#[must_use]
pub fn result(&self) -> &A {
&self.result
}
}
#[derive(Debug, Clone)]
pub struct RetrievalExplanation {
pub query_description: String,
pub result_description: String,
pub factors: Vec<ExplanationFactor>,
pub confidence: f64,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ExplanationFactor {
pub store_id: u64,
pub description: Option<String>,
pub weight: f64,
pub relation: FactorRelation,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FactorRelation {
Direct,
Analogical,
Transform,
Partial,
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
use amari_holographic::ProductCliffordAlgebra;
type TestAlgebra = ProductCliffordAlgebra<8>;
#[test]
fn attribution_empty() {
let attr = Attribution::<TestAlgebra>::new();
let query = TestAlgebra::random_versor(2);
let result = TestAlgebra::random_versor(2);
let attribution = attr.compute(&query, &result).unwrap();
assert!(attribution.contributions.is_empty());
}
#[test]
fn attribution_with_bindings() {
let mut attr = Attribution::<TestAlgebra>::new().with_threshold(0.0);
let key1 = TestAlgebra::random_versor(2);
let val1 = TestAlgebra::random_versor(2);
let binding1 = key1.bind(&val1);
attr.register(1, binding1);
let key2 = TestAlgebra::random_versor(2);
let val2 = TestAlgebra::random_versor(2);
let binding2 = key2.bind(&val2);
attr.register(2, binding2);
let result = val1.clone();
let attribution = attr.compute(&key1, &result).unwrap();
assert!(!attribution.contributions.is_empty());
}
#[test]
fn attribution_filters_to_matched_binding() {
let mut attr = Attribution::<TestAlgebra>::new().with_threshold(0.0);
let key1 = TestAlgebra::random_versor(2);
let val1 = TestAlgebra::random_versor(2);
attr.register(1, key1.bind(&val1));
let key2 = TestAlgebra::random_versor(2);
let val2 = TestAlgebra::random_versor(2);
attr.register(2, key2.bind(&val2));
let attribution = attr.compute(&key1, &val1).unwrap();
let c1 = attribution.contribution(1).unwrap_or(0.0);
let c2 = attribution.contribution(2).unwrap_or(0.0);
assert!(c1 > c2, "matched binding should dominate: c1={c1} c2={c2}");
}
#[test]
fn attribution_filtering() {
let result = AttributionResult {
contributions: [(1, 0.5), (2, 0.3), (3, 0.1), (4, 0.05), (5, 0.05)]
.into_iter()
.collect(),
top_contributors: vec![(1, 0.5), (2, 0.3), (3, 0.1), (4, 0.05), (5, 0.05)],
total_mass: 1.0,
is_approximate: false,
};
let above = result.above_threshold(0.2);
assert_eq!(above.len(), 2);
assert!(above.contains(&1));
assert!(above.contains(&2));
assert_eq!(result.top_n(3).len(), 3);
assert_eq!(result.top_n(10).len(), 5);
}
#[cfg(feature = "serde")]
#[test]
fn attribution_result_serde_roundtrip() {
let original = AttributionResult {
contributions: [(1, 0.6), (2, 0.4)].into_iter().collect(),
top_contributors: vec![(1, 0.6), (2, 0.4)],
total_mass: 1.0,
is_approximate: false,
};
let encoded = bincode::serialize(&original).expect("serialize");
let decoded: AttributionResult = bincode::deserialize(&encoded).expect("deserialize");
assert_eq!(original.contributions, decoded.contributions);
assert_eq!(original.top_contributors, decoded.top_contributors);
assert_eq!(original.total_mass, decoded.total_mass);
assert_eq!(original.is_approximate, decoded.is_approximate);
}
fn register_random(
attr: &mut Attribution<TestAlgebra>,
n: usize,
) -> (Vec<TestAlgebra>, Vec<TestAlgebra>) {
let mut keys = Vec::new();
let mut vals = Vec::new();
for i in 0..n {
let k = TestAlgebra::random_versor(2);
let v = TestAlgebra::random_versor(2);
attr.register(i as u64, k.bind(&v));
keys.push(k);
vals.push(v);
}
(keys, vals)
}
#[test]
fn gradient_attribution_sums_to_one() {
let mut attr = Attribution::<TestAlgebra>::new();
let (keys, _vals) = register_random(&mut attr, 5);
let query = keys[0].clone();
let mut r = TestAlgebra::zero();
for (_id, binding) in &attr.bindings {
let r_i = query.unbind(binding).unwrap();
r = r.component_add(&r_i);
}
let result = attr.compute_gradient(&query, &r).unwrap();
assert!(
(result.total_mass - 1.0).abs() < 1e-6,
"gradient attributions must sum to 1.0 against the pure-sum superposition, got {}",
result.total_mass
);
}
#[test]
fn gradient_single_binding_is_total() {
let mut attr = Attribution::<TestAlgebra>::new();
let key = TestAlgebra::random_versor(2);
let val = TestAlgebra::random_versor(2);
attr.register(0, key.bind(&val));
let r = key.unbind(&attr.bindings[0].1).unwrap();
let result = attr.compute_gradient(&key, &r).unwrap();
assert!((result.contribution(0).unwrap() - 1.0).abs() < 1e-6);
}
#[test]
fn gradient_attributes_larger_contribution_more() {
let mut attr = Attribution::<TestAlgebra>::new();
let key1 = TestAlgebra::random_versor(2);
let val1 = TestAlgebra::random_versor(2).component_scale(10.0);
attr.register(1, key1.bind(&val1));
let key2 = TestAlgebra::random_versor(2);
let val2 = TestAlgebra::random_versor(2).component_scale(0.1);
attr.register(2, key2.bind(&val2));
let query = TestAlgebra::identity();
let mut r = TestAlgebra::zero();
for (_id, binding) in &attr.bindings {
r = r.component_add(&query.unbind(binding).unwrap());
}
let result = attr.compute_gradient(&query, &r).unwrap();
let c1 = result.contribution(1).unwrap_or(0.0);
let c2 = result.contribution(2).unwrap_or(0.0);
assert!(
c1 > c2,
"larger-magnitude store must attribute more (energy-based): c1={c1} c2={c2}"
);
}
#[test]
fn gradient_matches_independent_projection() {
let mut attr = Attribution::<TestAlgebra>::new();
register_random(&mut attr, 4);
let query = TestAlgebra::random_versor(2);
let mut r = TestAlgebra::zero();
let mut r_is = Vec::new();
for (_id, binding) in &attr.bindings {
let r_i = query.unbind(binding).unwrap();
r_is.push(r_i.clone());
r = r.bundle(&r_i, 1.0).unwrap();
}
let r_norm_sq = r.norm() * r.norm();
let result = attr.compute_gradient(&query, &r).unwrap();
for (i, r_i) in r_is.iter().enumerate() {
let expected = inner_product(r_i, &r) / r_norm_sq;
let got = result.contribution(i as u64).unwrap();
assert!(
(got - expected).abs() < 1e-9,
"store {i}: gradient {got} must match projection {expected}"
);
}
}
#[test]
fn gradient_zero_result_is_empty() {
let mut attr = Attribution::<TestAlgebra>::new();
register_random(&mut attr, 3);
let query = TestAlgebra::random_versor(2);
let zero = TestAlgebra::zero();
let result = attr.compute_gradient(&query, &zero).unwrap();
assert!(result.contributions.is_empty());
assert_eq!(result.total_mass, 0.0);
}
}