Skip to main content

core_api/
explain_digest.rs

1//! Why are these two related — as lines.
2//!
3//! One renderer, because there are two callers: the `explain_association` MCP
4//! tool and `mushroomdb why`. The CLI had no door to `GraphDb::explain` at
5//! all until 0.7, and the code-graph `why` it borrowed the name from answered
6//! a different question out of a code graph.
7
8use 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
14/// Every rule-derived edge between `a` and `b`, each with the values that made
15/// its predicate true.
16///
17/// The matched values are read here, off the same handle the edges came from,
18/// so the evidence cannot describe a graph that has since moved. An error from
19/// `explain` (an unknown key, for one) is the engine's answer and is returned
20/// as it came.
21pub 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            // A via-hop rule evaluates its predicate between the *via*
31            // node and the destination, not between the two keys the
32            // caller asked about, so there is no pair of nodes here whose
33            // values would be the evidence — the line still names the hop.
34            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/// One explained edge, with the values that made the predicate true.
69///
70/// The `Explanation` fields are flattened, so `json: true` hands back the
71/// array it always did with one `evidence` object added per relationship.
72#[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/// What the two nodes actually had in common, per predicate kind.
81///
82/// Naming the rule and the threshold was never the answer to "why are these
83/// two related" — the shared values are. Without them an assistant fetches
84/// both nodes' raw property lists and reads them out, which names every
85/// value either node holds rather than the ones they share.
86#[derive(serde::Serialize)]
87#[serde(untagged)]
88pub enum Evidence {
89    /// `overlap` — the intersection of the two lists, sorted.
90    Shared { field: String, shared: Vec<Js> },
91    /// `field_equal` / `key_match` — the one value both carry.
92    Value { field: String, value: Js },
93    /// `geo_radius` — both points and the distance between them.
94    Geo {
95        field: String,
96        a: Js,
97        b: Js,
98        km: f64,
99    },
100    /// `numeric_within` / `vector_similar` — the two sides. For
101    /// `vector_similar` the vectors themselves are useless to read, so `a`
102    /// and `b` are omitted and only the score stands.
103    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    /// `all` / `any` — one entry per branch that contributed.
113    Parts { parts: Vec<Evidence> },
114}
115
116/// Mean Earth radius, as the rules engine uses for `geo_radius`.
117const EARTH_RADIUS_KM: f64 = 6371.0088;
118
119/// Evidence for one predicate, recursing through `all` / `any`.
120///
121/// `score` is the edge's weight and is passed only at the top level: `all`
122/// takes the minimum of its branches and `any` the maximum, so a branch's own
123/// score is not recoverable from the edge and a nested `vector_similar` has
124/// no similarity to report.
125///
126/// # Only what matched
127///
128/// A branch reports evidence **only when that branch is itself satisfied**,
129/// thresholds applied: an `overlap` under its `min`, a `numeric_within` past
130/// its `tolerance`, a `geo_radius` past its `km`, a `key_match` whose field
131/// does not name the other node. Under `any` that is the whole point — one
132/// branch carries the edge and the others did not — and printing an unmatched
133/// branch stated a reason the engine had rejected (`size_bucket: 1 vs 9` on a
134/// `±2` tolerance). Under `all` every branch matched by construction, so the
135/// checks change nothing there.
136fn 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            // Compared as the rules engine compares them: only a scalar
158            // element is a token, and its type is part of its identity, so a
159            // `1` and a `1.0` in two lists are not an overlap.
160            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            // The rule's own test: the Jaccard ratio, against the `min` the
173            // predicate declares. Inside an `any`, a list that overlaps but
174            // not enough is a branch the engine rejected.
175            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        // A key-match rule reads a foreign key off the source; the value they
192        // share is the destination's own key — when the field really does name
193        // it, directly or as one element of a list of foreign keys.
194        "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            // The rule's own test. A zero tolerance asks for equality.
212            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        // The two vectors say nothing a reader can use; the cosine the rule
239        // scored does, and that is the edge's weight.
240        "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
250/// The comparable token of one list element, as `(type tag, text)`.
251///
252/// Mirrors `ValueKey::from_value`: a nested list or map is not a token and
253/// cannot overlap, and two tokens of different types never match however
254/// alike they read.
255fn 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
265/// A `[lat, lon]` pair, as `geo_radius` reads it.
266fn 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
276/// A finite number, as the rules engine reads one: an integer or a finite
277/// float, and nothing else.
278fn 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
295/// Great-circle distance in km — the same formula `geo_radius` scores with,
296/// so the printed distance and the edge's score agree.
297fn 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
307/// One evidence clause, rendered for the digest line.
308fn 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
348/// A JSON scalar as the digest prints it: a string without its quotes,
349/// anything else as-is. Property values are graph content, so every string
350/// goes through [`sanitize`].
351fn json_scalar_text(v: &Js) -> String {
352    match v {
353        Js::String(s) => sanitize(s),
354        other => other.to_string(),
355    }
356}
357
358/// Characters of evidence one digest line carries before it is cut with `…`.
359///
360/// The bracket lists the values two nodes share, and two nodes can share a
361/// long list: sixty shared values printed about a kilobyte on one line, twice,
362/// because a symmetric rule explains each direction. 240 is three terminal
363/// lines — enough to show what kind of thing matched and roughly how much of
364/// it. The report a caller gets with `json: true` is not cut.
365pub const MAX_EVIDENCE_CHARS: usize = 240;
366
367/// `s`, or its first `max` characters and an ellipsis.
368fn 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
377/// One header, then one line per rule-derived edge, capped like every other
378/// task digest.
379///
380/// Rule names, edge types and predicate fields are all graph content — a rule
381/// is named by whoever created it — so each goes through
382/// [`sanitize`] before it reaches a line-structured digest.
383pub 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        // The matched values, in brackets, after the threshold that admitted
408        // them: "overlap on specialties >= 0.2 [specialties: hospitality,
409        // residential]". This is the line that stops an assistant fetching
410        // both nodes' raw lists and reading out everything either one holds.
411        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
422/// A predicate in one clause: what it compares, on which fields, and the
423/// threshold it had to clear.
424pub 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    /// Binding: an explanation's line carries the score and the hop a via-rule
454    /// went over, and the digest never runs past the line budget.
455    ///
456    /// `crates/server/tests/mcp.rs` covers the plain rule and the empty case end to end; what
457    /// is only reachable from here is a via-hop rule and a report longer than
458    /// [`MAX_TOOL_LINES`], neither of which a two-node fixture
459    /// produces.
460    #[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            // A via-hop rule matched between the via node and the
481            // destination, so there is no pair here to show evidence from.
482            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}