use crate::db::{EdgeTypeCensus, GraphDb};
use crate::digest::{sanitize, SEP, UNTRUSTED_FRAMING};
use core_storage::fs::Fs;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::time::{Duration, Instant};
const MAX_LABEL_PROPS: usize = 12;
const MAX_END_LABELS: usize = 3;
const ROLE_PROBE_ROWS: usize = 20;
const HOW_MANY_MIN: usize = 3;
const LINKED_BY_ALL_MAX_TYPES: usize = 3;
const IDENTITY_PROPS: [&str; 2] = ["id", "key"];
const HIDDEN_PROPS: [&str; 1] = ["embedding"];
pub(crate) const RANK_BUDGET: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BriefOptions {
pub budget: Duration,
}
impl Default for BriefOptions {
fn default() -> Self {
Self {
budget: RANK_BUDGET,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LabelBrief {
pub label: String,
pub nodes: usize,
pub props: Vec<String>,
pub hidden_props: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EdgeTypeBrief {
pub edge_type: String,
pub rule: Option<String>,
pub hidden_rules: usize,
pub src: Vec<String>,
pub dst: Vec<String>,
pub edges: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Recipe {
pub question: String,
pub call: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SchemaBrief {
pub nodes: usize,
pub edges: usize,
pub labels: Vec<LabelBrief>,
pub edge_types: Vec<EdgeTypeBrief>,
pub commits: Option<u64>,
pub roles: Vec<(String, Vec<String>)>,
pub recipes: Vec<Recipe>,
pub partial: bool,
}
fn spent(deadline: Option<Instant>) -> bool {
deadline.is_some_and(|dl| Instant::now() >= dl)
}
#[must_use]
pub fn brief<F: Fs>(db: &GraphDb<F>, opts: &BriefOptions) -> SchemaBrief {
let deadline = Instant::now().checked_add(opts.budget);
let nodes = db.all_nodes_for_export();
let mut partial = false;
let mut by_label: BTreeMap<String, (usize, BTreeSet<String>)> = BTreeMap::new();
let mut counted = 0usize;
for n in &nodes {
if spent(deadline) {
partial = true;
break;
}
counted += 1;
let entry = by_label.entry(n.label.clone()).or_default();
entry.0 += 1;
entry.1.extend(
n.props
.keys()
.filter(|p| !HIDDEN_PROPS.contains(&p.as_str()))
.cloned(),
);
}
let mut by_type: BTreeMap<String, EdgeTypeCensus> = BTreeMap::new();
if spent(deadline) {
partial = true;
} else {
for c in db.edge_type_census() {
if spent(deadline) {
partial = true;
break;
}
by_type.insert(c.edge_type.clone(), c);
}
}
let mut labels: Vec<LabelBrief> = by_label
.iter()
.map(|(label, (count, props))| {
let all: Vec<String> = props.iter().map(|p| sanitize(p)).collect();
let hidden = all.len().saturating_sub(MAX_LABEL_PROPS);
LabelBrief {
label: sanitize(label),
nodes: *count,
props: all.into_iter().take(MAX_LABEL_PROPS).collect(),
hidden_props: hidden,
}
})
.collect();
labels.sort_by(|a, b| b.nodes.cmp(&a.nodes).then(a.label.cmp(&b.label)));
let mut edge_types: Vec<EdgeTypeBrief> = by_type
.values()
.map(|c| EdgeTypeBrief {
edge_type: sanitize(&c.edge_type),
rule: c.rules.first().map(|r| sanitize(r)),
hidden_rules: c.rules.len().saturating_sub(1),
src: ends(&c.src_labels),
dst: ends(&c.dst_labels),
edges: usize::try_from(c.edges).unwrap_or(usize::MAX),
})
.collect();
edge_types.sort_by(|a, b| b.edges.cmp(&a.edges).then(a.edge_type.cmp(&b.edge_type)));
let mut roles: Vec<(String, Vec<String>)> = db
.roles()
.into_iter()
.map(|r| {
(
sanitize(&r.name),
r.labels.iter().map(|l| sanitize(l)).collect(),
)
})
.collect();
roles.sort();
let mut rule_fields: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for rule in db.rules() {
let mut fields = BTreeSet::new();
predicate_fields(&rule.predicate, &mut fields);
for label in [&rule.src_label, &rule.dst_label] {
rule_fields
.entry(label.clone())
.or_default()
.extend(fields.iter().cloned());
}
}
let (commits, latest_commit) = if spent(deadline) {
partial = true;
(None, None)
} else {
let floor = db.wal_horizon_floor();
let total = db.wal_total_commits().unwrap_or(floor);
(
Some(total.saturating_sub(floor)),
(total > floor).then(|| total - 1),
)
};
let recipes = recipes(
&nodes,
&by_type,
&labels,
&edge_types,
&roles,
&rule_fields,
latest_commit,
);
SchemaBrief {
nodes: counted,
edges: usize::try_from(db.edge_count()).unwrap_or(usize::MAX),
labels,
edge_types,
commits,
roles,
recipes,
partial,
}
}
fn predicate_fields(p: &core_rules::Predicate, out: &mut BTreeSet<String>) {
use core_rules::Predicate as P;
match p {
P::KeyMatch { field }
| P::FieldEqual { field }
| P::Overlap { field, .. }
| P::NumericWithin { field, .. }
| P::GeoRadius { field, .. }
| P::VectorSimilar { field, .. } => {
out.insert(field.clone());
}
P::All(parts) | P::Any(parts) => {
for part in parts {
predicate_fields(part, out);
}
}
}
}
fn ends(labels: &[String]) -> Vec<String> {
labels
.iter()
.take(MAX_END_LABELS)
.map(|l| sanitize(l))
.collect()
}
fn recipes(
nodes: &[crate::db::NodeInfo],
by_type: &BTreeMap<String, EdgeTypeCensus>,
labels: &[LabelBrief],
edge_types: &[EdgeTypeBrief],
roles: &[(String, Vec<String>)],
rule_fields: &BTreeMap<String, BTreeSet<String>>,
latest_commit: Option<u64>,
) -> Vec<Recipe> {
let sample_of = |t: &EdgeTypeBrief| by_type.get(&t.edge_type).and_then(|c| c.sample.clone());
let pair = edge_types
.iter()
.filter(|t| t.rule.is_some())
.find_map(sample_of)
.or_else(|| edge_types.iter().find_map(sample_of));
let (a, b) = match &pair {
Some((a, b)) => (sanitize(a), sanitize(b)),
None => ("<a>".to_string(), "<b>".to_string()),
};
let key = match &pair {
Some((a, _)) => sanitize(a),
None => nodes
.first()
.map_or_else(|| "<key>".to_string(), |n| sanitize(&n.key)),
};
let key_node = nodes.iter().find(|n| n.key == key);
let field = key_node
.and_then(|n| {
let watched = rule_fields.get(&n.label);
let mut usable = n.props.keys().filter(|f| {
!IDENTITY_PROPS.contains(&f.as_str()) && !HIDDEN_PROPS.contains(&f.as_str())
});
usable
.clone()
.find(|f| watched.is_some_and(|w| w.contains(*f)))
.or_else(|| usable.next())
})
.map_or_else(|| "<field>".to_string(), |f| sanitize(f));
let intersection = key_node.and_then(|n| types_from(&n.label, edge_types));
let (role, probe_label) = roles
.iter()
.find_map(|(name, visible)| {
labels
.iter()
.find(|l| visible.contains(&l.label))
.map(|l| (name.clone(), l.label.clone()))
})
.unwrap_or_else(|| {
(
roles
.first()
.map_or_else(|| "<name>".to_string(), |(n, _)| n.clone()),
labels
.first()
.map_or_else(|| "<label>".to_string(), |l| l.label.clone()),
)
});
let (l1, etype, l2) = edge_types.first().map_or_else(
|| ("<L1>".to_string(), "<TYPE>".to_string(), "<L2>".to_string()),
|t| {
(
t.src.first().cloned().unwrap_or_else(|| "<L1>".to_string()),
t.edge_type.clone(),
t.dst.first().cloned().unwrap_or_else(|| "<L2>".to_string()),
)
},
);
let mut out = vec![
Recipe {
question: "why".to_string(),
call: format!(
"explain_association {a} {b} — returns each relationship's rule and \
the values the two share, so there is no need to fetch raw lists to \
compare by hand"
),
},
Recipe {
question: "relationships".to_string(),
call: relationships_call(&key, &intersection),
},
];
if let Some(at) = latest_commit {
out.push(Recipe {
question: "as of".to_string(),
call: as_of_call(&key, at, &intersection),
});
}
out.extend([
Recipe {
question: "what if".to_string(),
call: what_if_call(&key, &field, &intersection),
},
Recipe {
question: "who may see".to_string(),
call: format!(
"query 'MATCH (n:{probe_label}) RETURN key(n) LIMIT {ROLE_PROBE_ROWS}' role: {role}"
),
},
Recipe {
question: "how many".to_string(),
call: format!(
"MATCH (a:{l1})-[:{etype}]->(b:{l2}) WITH key(b) AS b_key, count(a) AS n \
WHERE n >= {HOW_MANY_MIN} RETURN b_key, n"
),
},
]);
if edge_types.len() > 1 {
if let Some(recipe) = linked_by_all_recipe(labels, edge_types) {
out.push(recipe);
}
}
out
}
fn linked_by_all_recipe(labels: &[LabelBrief], edge_types: &[EdgeTypeBrief]) -> Option<Recipe> {
let src = &labels
.iter()
.find(|l| edge_types.iter().any(|t| t.src.contains(&l.label)))?
.label;
let (types, dst) = types_from(src, edge_types)?;
let mut pattern = format!("(a:{src})-[:{}]->(b:{dst})", types[0]);
for t in &types[1..] {
pattern.push_str(&format!(", (a)-[:{t}]->(b)"));
}
Some(Recipe {
question: "linked by all of".to_string(),
call: format!(
"MATCH {pattern} WITH b, count(DISTINCT a) AS n WHERE n >= 1 RETURN key(b), n \
ORDER BY n DESC LIMIT 20 — add `WHERE a.<field> = …` before WITH to filter \
the source side; one MATCH with comma-separated patterns intersects, \
separate MATCHes do not"
),
})
}
fn types_from(src: &str, edge_types: &[EdgeTypeBrief]) -> Option<(Vec<String>, String)> {
let mut by_dst: BTreeMap<&str, usize> = BTreeMap::new();
for t in edge_types.iter().filter(|t| t.src.iter().any(|s| s == src)) {
for dst in &t.dst {
*by_dst.entry(dst.as_str()).or_default() += t.edges;
}
}
let (dst, _) = by_dst
.into_iter()
.max_by_key(|(name, n)| (*n, std::cmp::Reverse(*name)))?;
let types: Vec<String> = edge_types
.iter()
.filter(|t| t.src.iter().any(|s| s == src) && t.dst.iter().any(|d| d.as_str() == dst))
.take(LINKED_BY_ALL_MAX_TYPES)
.map(|t| t.edge_type.clone())
.collect();
(!types.is_empty()).then(|| (types, dst.to_string()))
}
fn relationships_call(key: &str, intersection: &Option<(Vec<String>, String)>) -> String {
match intersection {
Some((types, dst)) => format!(
"node_edges {key} all_of: [{}] label: {dst} — or edge_type: {} for one \
type's partner keys",
types.join(", "),
types[0]
),
None => format!("node_edges {key}"),
}
}
fn as_of_call(key: &str, at: u64, intersection: &Option<(Vec<String>, String)>) -> String {
let note = "— or pass a date in place of the number: `2026-06-19`, \
`2026-06-19T12:00:00Z`. It resolves to the last commit at or \
before that instant, so there is no commit to go and find";
match intersection {
Some((types, dst)) if types.len() >= 2 => format!(
"edges_at {key} {at} all_of: [{}] label: {dst} {note}",
types.join(", ")
),
Some((types, _)) => format!("edges_at {key} {at} edge_type: {} {note}", types[0]),
None => format!("edges_at {key} {at}"),
}
}
fn what_if_call(key: &str, field: &str, intersection: &Option<(Vec<String>, String)>) -> String {
match intersection {
Some((types, _)) => format!(
"what_if {key} {field} <value> edge_type: {} — the partners that would be \
lost or gained under that type",
types[0]
),
None => format!("what_if {key} {field} <value>"),
}
}
pub const MAX_BRIEF_BYTES: usize = 4_000;
pub const EMPTY_BRIEF: &str =
"mushroomdb brief — empty store; offer to fill it: `remember` for a fact, \
`upsert_entity` for one entity, `ingest_json` for a batch of rows\n";
const BRIEF_LABELS_HEADING: &str = "labels:\n";
const BRIEF_EDGE_TYPES_HEADING: &str = "edge types:\n";
const BRIEF_RECIPES_HEADING: &str = "ask in one call:\n";
fn thousands(n: usize) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
fn plural(n: usize, word: &str) -> String {
if n == 1 {
format!("{n} {word}")
} else {
format!("{} {word}s", thousands(n))
}
}
fn cap_bytes(text: &str, max: usize) -> String {
let mut out = String::with_capacity(text.len().min(max));
for line in text.lines() {
if out.len() + line.len() + 1 > max {
break;
}
out.push_str(line);
out.push('\n');
}
out
}
#[must_use]
pub fn render(s: &SchemaBrief, reach: &str) -> String {
if s.nodes == 0 && s.edges == 0 {
return EMPTY_BRIEF.to_string();
}
let tail = format!("reach the graph: {}\n", sanitize(reach));
let budget = MAX_BRIEF_BYTES.saturating_sub(tail.len());
let at_least = |n: usize| {
if s.partial {
format!("≥ {}", thousands(n))
} else {
thousands(n)
}
};
let header = format!(
"{UNTRUSTED_FRAMING}mushroomdb brief — {}{}\n",
[
if s.partial {
format!("≥ {}", plural(s.nodes, "node"))
} else {
plural(s.nodes, "node")
},
plural(s.edges, "edge"),
plural(s.labels.len(), "label"),
]
.join(SEP),
if s.partial { " (partial)" } else { "" }
);
let label_lines = |cap: usize| -> Vec<String> {
s.labels
.iter()
.map(|l| {
let mut line = format!(
" {} ({})",
cap_name(&sanitize(&l.label), cap),
at_least(l.nodes)
);
if !l.props.is_empty() {
let props: Vec<String> = l.props.iter().map(|p| cap_name(p, cap)).collect();
let _ = write!(line, " — {}", props.join(", "));
}
if l.hidden_props > 0 {
let _ = write!(line, ", … +{}", l.hidden_props);
}
line.push('\n');
line
})
.collect()
};
let edge_type_lines = |cap: usize| -> Vec<String> {
s.edge_types
.iter()
.map(|t| {
let mut line = format!(
" {} ({})",
cap_name(&sanitize(&t.edge_type), cap),
at_least(t.edges)
);
if let Some(rule) = &t.rule {
let _ = write!(line, " — rule {}", cap_name(&sanitize(rule), cap));
if t.hidden_rules > 0 {
let _ = write!(line, " +{}", t.hidden_rules);
}
}
let _ = writeln!(
line,
" — {} → {}",
end_labels(&t.src, cap),
end_labels(&t.dst, cap)
);
line
})
.collect()
};
let mut prefix = match s.commits {
Some(n) => format!("history: {n} commits\n"),
None => "history: unknown\n".to_string(),
};
if !s.roles.is_empty() {
let roles: Vec<String> = s
.roles
.iter()
.map(|(name, labels)| {
if labels.is_empty() {
sanitize(name)
} else {
format!("{} ({})", sanitize(name), labels.join(", "))
}
})
.collect();
let _ = writeln!(prefix, "roles: {}", roles.join(SEP));
}
let recipes: Vec<String> = s
.recipes
.iter()
.map(|r| format!(" {}: {}\n", sanitize(&r.question), sanitize(&r.call)))
.collect();
let with_recipes = |kept: usize| -> String {
let mut fixed = prefix.clone();
if kept > 0 {
fixed.push_str(BRIEF_RECIPES_HEADING);
fixed.extend(recipes[..kept].iter().map(String::as_str));
}
fixed
};
let fixed = with_recipes(recipes.len());
let whole = memory_body(
&header,
&label_lines(usize::MAX),
&edge_type_lines(usize::MAX),
&fixed,
0,
);
if whole.len() <= budget {
return whole + &tail;
}
let mut labels = label_lines(BRIEF_NAME_CAP);
let mut edge_types = edge_type_lines(BRIEF_NAME_CAP);
let mut dropped = 0;
loop {
let body = memory_body(&header, &labels, &edge_types, &fixed, dropped);
if body.len() <= budget {
return body + &tail;
}
if edge_types.pop().is_none() && labels.pop().is_none() {
break;
}
dropped += 1;
}
let truncated = format!(
"(brief truncated at {} bytes)\n",
thousands(MAX_BRIEF_BYTES)
);
let mut kept = recipes.len();
loop {
let body = memory_body(&header, &[], &[], &with_recipes(kept), dropped) + &truncated;
if body.len() <= budget {
return body + &tail;
}
if kept == 0 {
return cap_bytes(&body, budget) + &tail;
}
kept -= 1;
}
}
const BRIEF_NAME_CAP: usize = 60;
fn cap_name(name: &str, cap: usize) -> String {
if cap == 0 || name.chars().count() <= cap {
return name.to_string();
}
let end = name
.char_indices()
.nth(cap - 1)
.map_or(name.len(), |(i, _)| i);
format!("{}…", &name[..end])
}
fn memory_body(
header: &str,
labels: &[String],
edge_types: &[String],
fixed: &str,
dropped: usize,
) -> String {
let mut out = String::from(header);
if !labels.is_empty() {
out.push_str(BRIEF_LABELS_HEADING);
out.extend(labels.iter().map(String::as_str));
}
if !edge_types.is_empty() {
out.push_str(BRIEF_EDGE_TYPES_HEADING);
out.extend(edge_types.iter().map(String::as_str));
}
if dropped > 0 {
let _ = writeln!(out, " … and {dropped} more");
}
out.push_str(fixed);
out
}
fn end_labels(labels: &[String], cap: usize) -> String {
if labels.is_empty() {
"?".to_string()
} else {
labels
.iter()
.map(|l| cap_name(&sanitize(l), cap))
.collect::<Vec<_>>()
.join("|")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cap_bytes_keeps_whole_lines_and_never_half_of_one() {
let text = "aaaa\nbbbb\ncccc\n"; assert_eq!(cap_bytes(text, 15), text, "the whole text fits exactly");
assert_eq!(
cap_bytes(text, 14),
"aaaa\nbbbb\n",
"the last line is whole"
);
assert_eq!(cap_bytes(text, 10), "aaaa\nbbbb\n");
assert_eq!(cap_bytes(text, 9), "aaaa\n");
assert_eq!(
cap_bytes(text, 4),
"",
"a first line too long yields nothing, never a fragment"
);
assert_eq!(cap_bytes(text, 0), "");
assert_eq!(cap_bytes("abc", 4), "abc\n");
assert_eq!(cap_bytes("abc", 3), "");
}
#[test]
fn thousands_groups_from_the_right() {
for (n, want) in [
(0, "0"),
(7, "7"),
(999, "999"),
(1_000, "1,000"),
(1_204, "1,204"),
(999_999, "999,999"),
(1_830_412, "1,830,412"),
] {
assert_eq!(thousands(n), want, "{n}");
}
}
#[test]
fn plural_says_one_file_and_two_files() {
assert_eq!(plural(1, "file"), "1 file");
assert_eq!(plural(0, "file"), "0 files");
assert_eq!(plural(1_204, "commit"), "1,204 commits");
}
}