use std::collections::HashSet;
use std::error::Error;
use crate::dataset::{Category, Qa};
use crate::eval::EvalCfg;
use crate::ingest::Store;
#[derive(Clone, Copy, Default)]
struct Coverage {
n: u32,
vector_sum: f64,
fused_sum: f64,
graph_helped: u32,
}
impl Coverage {
fn add(&mut self, vector: f64, fused: f64) {
self.n += 1;
self.vector_sum += vector;
self.fused_sum += fused;
if fused > vector + f64::EPSILON {
self.graph_helped += 1;
}
}
fn merge(self, other: Self) -> Self {
Self {
n: self.n + other.n,
vector_sum: self.vector_sum + other.vector_sum,
fused_sum: self.fused_sum + other.fused_sum,
graph_helped: self.graph_helped + other.graph_helped,
}
}
}
#[derive(Default)]
pub struct ExplainReport {
cells: [Coverage; 5],
}
impl ExplainReport {
pub fn record(&mut self, store: &Store, qa: &Qa, cfg: EvalCfg) -> Result<(), Box<dyn Error>> {
if qa.evidence.len() < 2 {
return Ok(());
}
let evidence: HashSet<&str> = qa.evidence.iter().map(String::as_str).collect();
let vector = coverage(&vector_dia_ids(store, qa, cfg.k)?, &evidence);
let mut fused_ids = vector_dia_ids(store, qa, cfg.k)?;
fused_ids.extend(graph_dia_ids(store, qa, cfg.hops)?);
let fused = coverage(&fused_ids, &evidence);
self.cells[qa.category.index()].add(vector, fused);
Ok(())
}
pub fn print(&self, cfg: EvalCfg, samples: usize) {
println!(
"\nVelesDB-memory — LoCoMo explanation benchmark (graph connects scattered evidence)"
);
println!(
"embedder: ollama / all-minilm · {samples} conversation(s) · \
multi-evidence questions only, k={}, {} hops\n",
cfg.k, cfg.hops
);
println!(
" {:<13}{:>5} {:>10} {:>10} {:>9}",
"category", "n", "vector-cov", "fused-cov", "graph+"
);
for category in Category::ALL {
self.print_row(category.label(), self.cells[category.index()]);
}
let total = self
.cells
.iter()
.fold(Coverage::default(), |a, c| a.merge(*c));
self.print_row("ALL", total);
println!(
"\n→ evidence coverage: vector {:.0}% → +graph {:.0}% ({:+.0} pp) · \
graph completed missing evidence on {}/{} questions",
pct(total.vector_sum, total.n),
pct(total.fused_sum, total.n),
pct(total.fused_sum, total.n) - pct(total.vector_sum, total.n),
total.graph_helped,
total.n,
);
println!(" (coverage = share of a question's gold evidence dia_ids present in the retrieved facts)");
}
#[allow(clippy::unused_self)]
fn print_row(&self, label: &str, cell: Coverage) {
println!(
" {:<13}{:>5} {:>9.0}% {:>9.0}% {:>9}",
label,
cell.n,
pct(cell.vector_sum, cell.n),
pct(cell.fused_sum, cell.n),
cell.graph_helped,
);
}
}
fn vector_dia_ids(store: &Store, qa: &Qa, k: usize) -> Result<HashSet<String>, Box<dyn Error>> {
let hits = store
.svc
.recall(&qa.question, k.saturating_mul(3).saturating_add(8), None)?;
let mut ids = HashSet::new();
let mut facts = 0;
for hit in hits {
if !store.is_fact(hit.id) {
continue;
}
ids.extend(store.dia_ids(hit.id).iter().cloned());
facts += 1;
if facts == k {
break;
}
}
Ok(ids)
}
fn graph_dia_ids(store: &Store, qa: &Qa, hops: usize) -> Result<HashSet<String>, Box<dyn Error>> {
let explanation = store.svc.why(&qa.question, hops, None)?;
let mut ids = HashSet::new();
for node in explanation.nodes {
if node.hop >= 1 && store.is_fact(node.id) {
ids.extend(store.dia_ids(node.id).iter().cloned());
}
}
Ok(ids)
}
fn coverage(covered: &HashSet<String>, evidence: &HashSet<&str>) -> f64 {
let hit = evidence.iter().filter(|id| covered.contains(**id)).count();
ratio(hit, evidence.len())
}
fn ratio(num: usize, den: usize) -> f64 {
if den == 0 {
return 0.0;
}
let num = u32::try_from(num).unwrap_or(u32::MAX);
let den = u32::try_from(den).unwrap_or(u32::MAX);
f64::from(num) / f64::from(den)
}
fn pct(sum: f64, n: u32) -> f64 {
if n == 0 {
return 0.0;
}
100.0 * sum / f64::from(n)
}