use crate::digest::{cap_lines, sanitize, MAX_TOOL_LINES};
use crate::{Explanation, GraphDb, NodeInfo, PredicateSummary, Result, Value};
use core_storage::fs::Fs;
use serde_json::{json, Value as Js};
use std::collections::BTreeSet;
pub fn explain_with_evidence<F: Fs>(
db: &GraphDb<F>,
a: &str,
b: &str,
) -> Result<Vec<ExplainedEdge>> {
let found = db.explain(a, b)?;
Ok(found
.into_iter()
.map(|e| {
let evidence = if e.via_edge.is_some() {
None
} else {
match (db.node_info(&e.src_key), db.node_info(&e.dst_key)) {
(Some(src), Some(dst)) => {
predicate_evidence(&e.predicate, &src, &dst, e.weight)
}
_ => None,
}
};
ExplainedEdge { edge: e, evidence }
})
.collect())
}
fn value_to_json(v: &Value) -> Js {
match v {
Value::Int(i) => json!(i),
Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Js::Number)
.unwrap_or(Js::Null),
Value::Str(s) => json!(s),
Value::Bool(b) => json!(b),
Value::List(xs) => Js::Array(xs.iter().map(value_to_json).collect()),
Value::Map(m) => {
let obj: serde_json::Map<String, Js> = m
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
Js::Object(obj)
}
}
}
#[derive(serde::Serialize)]
pub struct ExplainedEdge {
#[serde(flatten)]
pub edge: Explanation,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<Evidence>,
}
#[derive(serde::Serialize)]
#[serde(untagged)]
pub enum Evidence {
Shared { field: String, shared: Vec<Js> },
Value { field: String, value: Js },
Geo {
field: String,
a: Js,
b: Js,
km: f64,
},
Pair {
field: String,
#[serde(skip_serializing_if = "Option::is_none")]
a: Option<Js>,
#[serde(skip_serializing_if = "Option::is_none")]
b: Option<Js>,
#[serde(skip_serializing_if = "Option::is_none")]
similarity: Option<f64>,
},
Parts { parts: Vec<Evidence> },
}
const EARTH_RADIUS_KM: f64 = 6371.0088;
fn predicate_evidence(
p: &PredicateSummary,
src: &NodeInfo,
dst: &NodeInfo,
score: Option<f64>,
) -> Option<Evidence> {
if let Some(parts) = &p.parts {
let parts: Vec<Evidence> = parts
.iter()
.filter_map(|q| predicate_evidence(q, src, dst, None))
.collect();
return (!parts.is_empty()).then_some(Evidence::Parts { parts });
}
let field = p.fields.first()?.clone();
match p.kind.as_str() {
"overlap" => {
let (Some(Value::List(a)), Some(Value::List(b))) =
(src.props.get(&field), dst.props.get(&field))
else {
return None;
};
let left: BTreeSet<(u8, String)> = a.iter().filter_map(scalar_token).collect();
let right: BTreeSet<(u8, String)> = b.iter().filter_map(scalar_token).collect();
let union = left.union(&right).count();
let mut shared: Vec<String> = left
.intersection(&right)
.map(|(_, text)| text.clone())
.collect();
shared.sort();
shared.dedup();
if shared.is_empty() || union == 0 {
return None;
}
let jaccard = shared.len() as f64 / union as f64;
if p.min.is_some_and(|min| jaccard < min) {
return None;
}
Some(Evidence::Shared {
field,
shared: shared.into_iter().map(Js::String).collect(),
})
}
"field_equal" => {
let v = src.props.get(&field)?;
(dst.props.get(&field) == Some(v)).then(|| Evidence::Value {
field,
value: value_to_json(v),
})
}
"key_match" => {
let names_dst = match src.props.get(&field)? {
Value::Str(s) => s == &dst.key,
Value::List(items) => items
.iter()
.any(|v| matches!(v, Value::Str(s) if s == &dst.key)),
_ => false,
};
names_dst.then(|| Evidence::Value {
field,
value: Js::String(dst.key.clone()),
})
}
"numeric_within" => {
let (a, b) = (src.props.get(&field)?, dst.props.get(&field)?);
let (x, y) = (numeric(a)?, numeric(b)?);
let delta = (x - y).abs();
let within = match p.tolerance {
Some(0.0) => delta == 0.0,
Some(t) => delta <= t,
None => true,
};
within.then(|| Evidence::Pair {
field,
a: Some(value_to_json(a)),
b: Some(value_to_json(b)),
similarity: None,
})
}
"geo_radius" => {
let (alat, alon) = lat_lon(src.props.get(&field)?)?;
let (blat, blon) = lat_lon(dst.props.get(&field)?)?;
let km = haversine_km(alat, alon, blat, blon);
if p.km.is_some_and(|radius| km > radius) {
return None;
}
Some(Evidence::Geo {
field,
a: Js::String(format_lat_lon(alat, alon)),
b: Js::String(format_lat_lon(blat, blon)),
km: round2(km),
})
}
"vector_similar" => score.map(|sim| Evidence::Pair {
field,
a: None,
b: None,
similarity: Some(sim),
}),
_ => None,
}
}
fn scalar_token(v: &Value) -> Option<(u8, String)> {
match v {
Value::Str(s) => Some((0, s.clone())),
Value::Int(i) => Some((1, i.to_string())),
Value::Float(f) => Some((2, format!("{f}"))),
Value::Bool(b) => Some((3, b.to_string())),
Value::List(_) | Value::Map(_) => None,
}
}
fn lat_lon(v: &Value) -> Option<(f64, f64)> {
let Value::List(items) = v else {
return None;
};
if items.len() != 2 {
return None;
}
Some((numeric(&items[0])?, numeric(&items[1])?))
}
fn numeric(v: &Value) -> Option<f64> {
match v {
#[allow(clippy::cast_precision_loss)]
Value::Int(i) => Some(*i as f64),
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn format_lat_lon(lat: f64, lon: f64) -> String {
format!("{:.4},{:.4}", lat, lon)
}
fn round2(km: f64) -> f64 {
(km * 100.0).round() / 100.0
}
fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
let phi1 = lat1.to_radians();
let phi2 = lat2.to_radians();
let dphi = (lat2 - lat1).to_radians();
let dlam = (lon2 - lon1).to_radians();
let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
.clamp(0.0, 1.0);
EARTH_RADIUS_KM * 2.0 * a.sqrt().atan2((1.0 - a).sqrt())
}
fn evidence_summary(e: &Evidence) -> String {
match e {
Evidence::Shared { field, shared } => {
let vals: Vec<String> = shared.iter().map(json_scalar_text).collect();
format!("{}: {}", sanitize(field), vals.join(", "))
}
Evidence::Value { field, value } => {
format!("{}: {}", sanitize(field), json_scalar_text(value))
}
Evidence::Geo { field, a, b, km } => format!(
"{}: {} vs {}, {km} km apart",
sanitize(field),
json_scalar_text(a),
json_scalar_text(b)
),
Evidence::Pair {
field,
a: Some(a),
b: Some(b),
..
} => format!(
"{}: {} vs {}",
sanitize(field),
json_scalar_text(a),
json_scalar_text(b)
),
Evidence::Pair {
field,
similarity: Some(sim),
..
} => format!("{}: similarity {sim:.2}", sanitize(field)),
Evidence::Pair { field, .. } => sanitize(field),
Evidence::Parts { parts } => parts
.iter()
.map(evidence_summary)
.collect::<Vec<_>>()
.join("; "),
}
}
fn json_scalar_text(v: &Js) -> String {
match v {
Js::String(s) => sanitize(s),
other => other.to_string(),
}
}
pub const MAX_EVIDENCE_CHARS: usize = 240;
fn cut_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut cut: String = s.chars().take(max).collect();
cut.push('…');
cut
}
pub fn render_explain(a: &str, b: &str, found: &[ExplainedEdge]) -> String {
let mut out = format!(
"mushroomdb explain — {} ↔ {}: {} relationship(s)\n",
sanitize(a),
sanitize(b),
found.len()
);
if found.is_empty() {
out.push_str(" none\n");
return out;
}
for ExplainedEdge { edge: e, evidence } in found {
out.push_str(&format!(
" {} via rule {}",
sanitize(&e.edge_type),
sanitize(&e.rule)
));
if let Some(weight) = e.weight {
out.push_str(&format!(" (score {weight:.2})"));
}
if let Some(via) = &e.via_edge {
out.push_str(&format!(" via {}", sanitize(via)));
}
out.push_str(&format!(" — {}", predicate_summary(&e.predicate)));
if let Some(ev) = evidence {
out.push_str(&format!(
" [{}]",
cut_chars(&evidence_summary(ev), MAX_EVIDENCE_CHARS)
));
}
out.push('\n');
}
cap_lines(&out, MAX_TOOL_LINES)
}
pub fn predicate_summary(p: &PredicateSummary) -> String {
let mut out = sanitize(&p.kind);
if !p.fields.is_empty() {
let fields: Vec<String> = p.fields.iter().map(|f| sanitize(f)).collect();
out.push_str(&format!(" on {}", fields.join(", ")));
}
if let Some(min) = p.min {
out.push_str(&format!(" >= {min}"));
}
if let Some(tolerance) = p.tolerance {
out.push_str(&format!(" +/- {tolerance}"));
}
if let Some(km) = p.km {
out.push_str(&format!(" within {km} km"));
}
if let Some(parts) = &p.parts {
let inner: Vec<String> = parts.iter().map(predicate_summary).collect();
out.push_str(&format!(" ({})", inner.join("; ")));
}
if p.approximate {
out.push_str(" (approximate)");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_explanation_line_names_the_score_the_hop_and_the_predicate() {
let one = |rule: &str, via: Option<&str>| ExplainedEdge {
edge: Explanation {
rule: rule.to_string(),
edge_type: "SIMILAR".to_string(),
src_key: "a".to_string(),
dst_key: "b".to_string(),
weight: Some(0.9625),
predicate: PredicateSummary {
kind: "vector_similar".to_string(),
fields: vec!["emb".to_string()],
min: Some(0.85),
tolerance: None,
km: None,
parts: None,
approximate: false,
},
via_edge: via.map(str::to_string),
},
evidence: None,
};
let text = render_explain("a", "b", &[one("close", Some("WORKS_AT"))]);
assert_eq!(
text,
"mushroomdb explain — a ↔ b: 1 relationship(s)\n SIMILAR via rule close (score 0.96) \
via WORKS_AT — vector_similar on emb >= 0.85\n"
);
let many: Vec<ExplainedEdge> = (0..40).map(|i| one(&format!("r{i}"), None)).collect();
let capped = render_explain("a", "b", &many);
assert_eq!(
capped.lines().count(),
MAX_TOOL_LINES,
"the digest is capped like every other one"
);
assert!(
capped.starts_with("mushroomdb explain — a ↔ b: 40 relationship(s)"),
"and the header still says how many there were: {capped}"
);
}
}