1use crate::digest::{cap_lines, sanitize, MAX_TOOL_LINES};
9use crate::{Explanation, GraphDb, NodeInfo, PredicateSummary, Result, Value};
10use core_storage::fs::Fs;
11use serde_json::{json, Value as Js};
12use std::collections::BTreeSet;
13
14pub fn explain_with_evidence<F: Fs>(
22 db: &GraphDb<F>,
23 a: &str,
24 b: &str,
25) -> Result<Vec<ExplainedEdge>> {
26 let found = db.explain(a, b)?;
27 Ok(found
28 .into_iter()
29 .map(|e| {
30 let evidence = if e.via_edge.is_some() {
35 None
36 } else {
37 match (db.node_info(&e.src_key), db.node_info(&e.dst_key)) {
38 (Some(src), Some(dst)) => {
39 predicate_evidence(&e.predicate, &src, &dst, e.weight)
40 }
41 _ => None,
42 }
43 };
44 ExplainedEdge { edge: e, evidence }
45 })
46 .collect())
47}
48
49fn value_to_json(v: &Value) -> Js {
50 match v {
51 Value::Int(i) => json!(i),
52 Value::Float(f) => serde_json::Number::from_f64(*f)
53 .map(Js::Number)
54 .unwrap_or(Js::Null),
55 Value::Str(s) => json!(s),
56 Value::Bool(b) => json!(b),
57 Value::List(xs) => Js::Array(xs.iter().map(value_to_json).collect()),
58 Value::Map(m) => {
59 let obj: serde_json::Map<String, Js> = m
60 .iter()
61 .map(|(k, v)| (k.clone(), value_to_json(v)))
62 .collect();
63 Js::Object(obj)
64 }
65 }
66}
67
68#[derive(serde::Serialize)]
73pub struct ExplainedEdge {
74 #[serde(flatten)]
75 pub edge: Explanation,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub evidence: Option<Evidence>,
78}
79
80#[derive(serde::Serialize)]
87#[serde(untagged)]
88pub enum Evidence {
89 Shared { field: String, shared: Vec<Js> },
91 Value { field: String, value: Js },
93 Geo {
95 field: String,
96 a: Js,
97 b: Js,
98 km: f64,
99 },
100 Pair {
104 field: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 a: Option<Js>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 b: Option<Js>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 similarity: Option<f64>,
111 },
112 Parts { parts: Vec<Evidence> },
114}
115
116const EARTH_RADIUS_KM: f64 = 6371.0088;
118
119fn predicate_evidence(
137 p: &PredicateSummary,
138 src: &NodeInfo,
139 dst: &NodeInfo,
140 score: Option<f64>,
141) -> Option<Evidence> {
142 if let Some(parts) = &p.parts {
143 let parts: Vec<Evidence> = parts
144 .iter()
145 .filter_map(|q| predicate_evidence(q, src, dst, None))
146 .collect();
147 return (!parts.is_empty()).then_some(Evidence::Parts { parts });
148 }
149 let field = p.fields.first()?.clone();
150 match p.kind.as_str() {
151 "overlap" => {
152 let (Some(Value::List(a)), Some(Value::List(b))) =
153 (src.props.get(&field), dst.props.get(&field))
154 else {
155 return None;
156 };
157 let left: BTreeSet<(u8, String)> = a.iter().filter_map(scalar_token).collect();
161 let right: BTreeSet<(u8, String)> = b.iter().filter_map(scalar_token).collect();
162 let union = left.union(&right).count();
163 let mut shared: Vec<String> = left
164 .intersection(&right)
165 .map(|(_, text)| text.clone())
166 .collect();
167 shared.sort();
168 shared.dedup();
169 if shared.is_empty() || union == 0 {
170 return None;
171 }
172 let jaccard = shared.len() as f64 / union as f64;
176 if p.min.is_some_and(|min| jaccard < min) {
177 return None;
178 }
179 Some(Evidence::Shared {
180 field,
181 shared: shared.into_iter().map(Js::String).collect(),
182 })
183 }
184 "field_equal" => {
185 let v = src.props.get(&field)?;
186 (dst.props.get(&field) == Some(v)).then(|| Evidence::Value {
187 field,
188 value: value_to_json(v),
189 })
190 }
191 "key_match" => {
195 let names_dst = match src.props.get(&field)? {
196 Value::Str(s) => s == &dst.key,
197 Value::List(items) => items
198 .iter()
199 .any(|v| matches!(v, Value::Str(s) if s == &dst.key)),
200 _ => false,
201 };
202 names_dst.then(|| Evidence::Value {
203 field,
204 value: Js::String(dst.key.clone()),
205 })
206 }
207 "numeric_within" => {
208 let (a, b) = (src.props.get(&field)?, dst.props.get(&field)?);
209 let (x, y) = (numeric(a)?, numeric(b)?);
210 let delta = (x - y).abs();
211 let within = match p.tolerance {
213 Some(0.0) => delta == 0.0,
214 Some(t) => delta <= t,
215 None => true,
216 };
217 within.then(|| Evidence::Pair {
218 field,
219 a: Some(value_to_json(a)),
220 b: Some(value_to_json(b)),
221 similarity: None,
222 })
223 }
224 "geo_radius" => {
225 let (alat, alon) = lat_lon(src.props.get(&field)?)?;
226 let (blat, blon) = lat_lon(dst.props.get(&field)?)?;
227 let km = haversine_km(alat, alon, blat, blon);
228 if p.km.is_some_and(|radius| km > radius) {
229 return None;
230 }
231 Some(Evidence::Geo {
232 field,
233 a: Js::String(format_lat_lon(alat, alon)),
234 b: Js::String(format_lat_lon(blat, blon)),
235 km: round2(km),
236 })
237 }
238 "vector_similar" => score.map(|sim| Evidence::Pair {
241 field,
242 a: None,
243 b: None,
244 similarity: Some(sim),
245 }),
246 _ => None,
247 }
248}
249
250fn scalar_token(v: &Value) -> Option<(u8, String)> {
256 match v {
257 Value::Str(s) => Some((0, s.clone())),
258 Value::Int(i) => Some((1, i.to_string())),
259 Value::Float(f) => Some((2, format!("{f}"))),
260 Value::Bool(b) => Some((3, b.to_string())),
261 Value::List(_) | Value::Map(_) => None,
262 }
263}
264
265fn lat_lon(v: &Value) -> Option<(f64, f64)> {
267 let Value::List(items) = v else {
268 return None;
269 };
270 if items.len() != 2 {
271 return None;
272 }
273 Some((numeric(&items[0])?, numeric(&items[1])?))
274}
275
276fn numeric(v: &Value) -> Option<f64> {
279 match v {
280 #[allow(clippy::cast_precision_loss)]
281 Value::Int(i) => Some(*i as f64),
282 Value::Float(f) if f.is_finite() => Some(*f),
283 _ => None,
284 }
285}
286
287fn format_lat_lon(lat: f64, lon: f64) -> String {
288 format!("{:.4},{:.4}", lat, lon)
289}
290
291fn round2(km: f64) -> f64 {
292 (km * 100.0).round() / 100.0
293}
294
295fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
298 let phi1 = lat1.to_radians();
299 let phi2 = lat2.to_radians();
300 let dphi = (lat2 - lat1).to_radians();
301 let dlam = (lon2 - lon1).to_radians();
302 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
303 .clamp(0.0, 1.0);
304 EARTH_RADIUS_KM * 2.0 * a.sqrt().atan2((1.0 - a).sqrt())
305}
306
307fn evidence_summary(e: &Evidence) -> String {
309 match e {
310 Evidence::Shared { field, shared } => {
311 let vals: Vec<String> = shared.iter().map(json_scalar_text).collect();
312 format!("{}: {}", sanitize(field), vals.join(", "))
313 }
314 Evidence::Value { field, value } => {
315 format!("{}: {}", sanitize(field), json_scalar_text(value))
316 }
317 Evidence::Geo { field, a, b, km } => format!(
318 "{}: {} vs {}, {km} km apart",
319 sanitize(field),
320 json_scalar_text(a),
321 json_scalar_text(b)
322 ),
323 Evidence::Pair {
324 field,
325 a: Some(a),
326 b: Some(b),
327 ..
328 } => format!(
329 "{}: {} vs {}",
330 sanitize(field),
331 json_scalar_text(a),
332 json_scalar_text(b)
333 ),
334 Evidence::Pair {
335 field,
336 similarity: Some(sim),
337 ..
338 } => format!("{}: similarity {sim:.2}", sanitize(field)),
339 Evidence::Pair { field, .. } => sanitize(field),
340 Evidence::Parts { parts } => parts
341 .iter()
342 .map(evidence_summary)
343 .collect::<Vec<_>>()
344 .join("; "),
345 }
346}
347
348fn json_scalar_text(v: &Js) -> String {
352 match v {
353 Js::String(s) => sanitize(s),
354 other => other.to_string(),
355 }
356}
357
358pub const MAX_EVIDENCE_CHARS: usize = 240;
366
367fn cut_chars(s: &str, max: usize) -> String {
369 if s.chars().count() <= max {
370 return s.to_string();
371 }
372 let mut cut: String = s.chars().take(max).collect();
373 cut.push('…');
374 cut
375}
376
377pub fn render_explain(a: &str, b: &str, found: &[ExplainedEdge]) -> String {
384 let mut out = format!(
385 "mushroomdb explain — {} ↔ {}: {} relationship(s)\n",
386 sanitize(a),
387 sanitize(b),
388 found.len()
389 );
390 if found.is_empty() {
391 out.push_str(" none\n");
392 return out;
393 }
394 for ExplainedEdge { edge: e, evidence } in found {
395 out.push_str(&format!(
396 " {} via rule {}",
397 sanitize(&e.edge_type),
398 sanitize(&e.rule)
399 ));
400 if let Some(weight) = e.weight {
401 out.push_str(&format!(" (score {weight:.2})"));
402 }
403 if let Some(via) = &e.via_edge {
404 out.push_str(&format!(" via {}", sanitize(via)));
405 }
406 out.push_str(&format!(" — {}", predicate_summary(&e.predicate)));
407 if let Some(ev) = evidence {
412 out.push_str(&format!(
413 " [{}]",
414 cut_chars(&evidence_summary(ev), MAX_EVIDENCE_CHARS)
415 ));
416 }
417 out.push('\n');
418 }
419 cap_lines(&out, MAX_TOOL_LINES)
420}
421
422pub fn predicate_summary(p: &PredicateSummary) -> String {
425 let mut out = sanitize(&p.kind);
426 if !p.fields.is_empty() {
427 let fields: Vec<String> = p.fields.iter().map(|f| sanitize(f)).collect();
428 out.push_str(&format!(" on {}", fields.join(", ")));
429 }
430 if let Some(min) = p.min {
431 out.push_str(&format!(" >= {min}"));
432 }
433 if let Some(tolerance) = p.tolerance {
434 out.push_str(&format!(" +/- {tolerance}"));
435 }
436 if let Some(km) = p.km {
437 out.push_str(&format!(" within {km} km"));
438 }
439 if let Some(parts) = &p.parts {
440 let inner: Vec<String> = parts.iter().map(predicate_summary).collect();
441 out.push_str(&format!(" ({})", inner.join("; ")));
442 }
443 if p.approximate {
444 out.push_str(" (approximate)");
445 }
446 out
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
461 fn an_explanation_line_names_the_score_the_hop_and_the_predicate() {
462 let one = |rule: &str, via: Option<&str>| ExplainedEdge {
463 edge: Explanation {
464 rule: rule.to_string(),
465 edge_type: "SIMILAR".to_string(),
466 src_key: "a".to_string(),
467 dst_key: "b".to_string(),
468 weight: Some(0.9625),
469 predicate: PredicateSummary {
470 kind: "vector_similar".to_string(),
471 fields: vec!["emb".to_string()],
472 min: Some(0.85),
473 tolerance: None,
474 km: None,
475 parts: None,
476 approximate: false,
477 },
478 via_edge: via.map(str::to_string),
479 },
480 evidence: None,
483 };
484
485 let text = render_explain("a", "b", &[one("close", Some("WORKS_AT"))]);
486 assert_eq!(
487 text,
488 "mushroomdb explain — a ↔ b: 1 relationship(s)\n SIMILAR via rule close (score 0.96) \
489 via WORKS_AT — vector_similar on emb >= 0.85\n"
490 );
491
492 let many: Vec<ExplainedEdge> = (0..40).map(|i| one(&format!("r{i}"), None)).collect();
493 let capped = render_explain("a", "b", &many);
494 assert_eq!(
495 capped.lines().count(),
496 MAX_TOOL_LINES,
497 "the digest is capped like every other one"
498 );
499 assert!(
500 capped.starts_with("mushroomdb explain — a ↔ b: 40 relationship(s)"),
501 "and the header still says how many there were: {capped}"
502 );
503 }
504}