core_api/memory/brief.rs
1//! `brief` — a memory store in one block, computed once per session.
2//!
3//! A `SessionStart` hook runs before the assistant has asked anything, so the
4//! brief cannot be about a question: it is the orientation every question
5//! afterwards starts from. On a memory store that is the schema — its labels,
6//! its edge types, how deep its history runs, who may read it — and one worked
7//! call per question kind.
8//!
9//! # Why it is byte-stable
10//!
11//! The host caches the hook's output for the whole session, so the same store
12//! must render the same bytes however often it is asked. Nothing here reads a
13//! clock for content: the budget decides how much is counted, never what is
14//! printed for what was. Every collection is sorted with the ties broken on
15//! the key, so hash iteration order cannot reach the output either.
16
17use crate::db::{EdgeTypeCensus, GraphDb};
18use crate::digest::{sanitize, SEP, UNTRUSTED_FRAMING};
19use core_storage::fs::Fs;
20use serde::Serialize;
21use std::collections::{BTreeMap, BTreeSet};
22use std::fmt::Write as _;
23use std::time::{Duration, Instant};
24
25/// Property names one label's line may name before the rest are counted off.
26/// A line is the unit the byte budget drops, so one wide label must not be
27/// able to spend the whole brief.
28const MAX_LABEL_PROPS: usize = 12;
29/// Labels one edge type's line may name on either end. An edge type whose
30/// sources are of four labels is telling the reader it is polymorphic, not
31/// which four.
32const MAX_END_LABELS: usize = 3;
33/// Rows the `who may see` recipe asks for — enough to see whether a role can
34/// see anything at all, few enough to be free.
35const ROLE_PROBE_ROWS: usize = 20;
36/// The threshold the `how many` recipe filters on. Three is the smallest
37/// count that reads as a pattern rather than a coincidence.
38const HOW_MANY_MIN: usize = 3;
39/// Edge types the `linked by all of` recipe intersects in one `MATCH`. Three
40/// is enough to demonstrate the shape — a fourth pattern would not teach a
41/// reader anything a third has not already shown, and every one past the
42/// first costs a `, (a)-[:TYPE]->(b)` the byte budget pays for.
43const LINKED_BY_ALL_MAX_TYPES: usize = 3;
44/// Property names that name a node rather than describe it, and so are never
45/// what a `what_if` is about. `id` is the identity prop Cypher `CREATE`
46/// writes; `key` is what the store calls the same thing.
47const IDENTITY_PROPS: [&str; 2] = ["id", "key"];
48/// Property names the schema listing does not print. `embedding` is the vector
49/// payload `hybrid_search` and `find_similar` read: hundreds of floats, never
50/// a question target, and naming it in a schema a session is meant to write
51/// queries from invites a query that returns a wall of numbers. The tools that
52/// use it do not need to be told it is there.
53const HIDDEN_PROPS: [&str; 1] = ["embedding"];
54
55/// What the brief may spend. The `SessionStart` hook has five seconds, and a
56/// store too large to describe inside three of them yields what it had reached
57/// — a partial ranking is still a valid ordering, partial counts are still
58/// lower bounds, and a partial brief is worth more at the start of a session
59/// than none.
60///
61/// It needs the budget: its work is
62/// [`GraphDb::wal_total_commits`], which re-reads the WAL — seconds on a store
63/// nobody has snapshotted — plus two passes whose length is the store's.
64pub(crate) const RANK_BUDGET: Duration = Duration::from_secs(3);
65
66/// How long the brief may take.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct BriefOptions {
69 /// Wall-clock the whole brief may spend, [`RANK_BUDGET`] by default.
70 /// [`Duration::ZERO`] is a budget already spent — every count comes back
71 /// as the lower bound reached, which is what the tests use. A budget too
72 /// large to add to the clock is no budget at all.
73 pub budget: Duration,
74}
75
76impl Default for BriefOptions {
77 fn default() -> Self {
78 Self {
79 budget: RANK_BUDGET,
80 }
81 }
82}
83
84/// One label of a memory store's schema: what it is called, how many nodes
85/// carry it, and every property name any of them has.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct LabelBrief {
88 pub label: String,
89 pub nodes: usize,
90 /// The union of the property names across the label's nodes, sorted, cut
91 /// at [`MAX_LABEL_PROPS`] with `hidden` counting the rest.
92 pub props: Vec<String>,
93 pub hidden_props: usize,
94}
95
96/// One edge type of a memory store's schema: what derives it, what it runs
97/// between, and how many there are.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct EdgeTypeBrief {
100 pub edge_type: String,
101 /// The first rule that declares it, sorted. `None` for an edge type
102 /// written by hand.
103 pub rule: Option<String>,
104 /// How many further rules also derive it — two rules deriving one type is
105 /// normal, and naming one while implying it is the only one would be a
106 /// half-truth.
107 pub hidden_rules: usize,
108 /// The labels seen on each end, sorted, cut at [`MAX_END_LABELS`].
109 pub src: Vec<String>,
110 pub dst: Vec<String>,
111 pub edges: usize,
112}
113
114/// One question kind and the single call that answers it, with the store's
115/// own keys, labels and edge types already substituted in.
116///
117/// The point is that the call is *worked*: an assistant that copies the line
118/// reaches an answer without first probing the store for its schema, which is
119/// the round trip this section exists to remove.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121pub struct Recipe {
122 /// The question kind, as a reader would name it — `why`, `as of`.
123 pub question: String,
124 /// The call that answers it.
125 pub call: String,
126}
127
128/// What a memory store *is*: its labels, its edge types, how deep its history
129/// runs, who may read it, and the call that answers each kind of question.
130///
131/// Present on a store no repository was ingested into — the same
132/// `GitSync`-marker test the MCP server's tool listing splits on — and absent
133/// on a code graph, which is described by its rankings instead.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct SchemaBrief {
136 /// Live nodes, all labels together.
137 pub nodes: usize,
138 /// Every edge in the store, whatever its type — the store's own count,
139 /// not a sum over [`edge_types`](Self::edge_types), so it is exact even
140 /// when the budget cut the census short.
141 pub edges: usize,
142 /// Labels, most populous first, ties on the name.
143 pub labels: Vec<LabelBrief>,
144 /// Edge types, most numerous first, ties on the name.
145 pub edge_types: Vec<EdgeTypeBrief>,
146 /// How many commits of history are still reachable — `total` above the
147 /// horizon floor. A *count*, not an index: the newest commit `edges_at`,
148 /// `node_history` and `was_linked` accept is one below it, which is what
149 /// the `as of` recipe names. `Some(0)` on a store whose WAL was truncated
150 /// and whose archives are gone, and then there is no `as of` recipe at all.
151 ///
152 /// `None` when the budget ran out before it could be counted: the scan
153 /// that answers it re-reads the WAL, so it is the first thing a spent
154 /// budget drops. Unknown and zero are different answers, which is why this
155 /// is an `Option` and not a zero.
156 pub commits: Option<u64>,
157 /// `(role name, the labels it may see)`, sorted by name.
158 pub roles: Vec<(String, Vec<String>)>,
159 /// One worked call per question kind, in a fixed order.
160 pub recipes: Vec<Recipe>,
161 /// The budget ran out while counting: every count above is a *lower
162 /// bound*, the listings may be short of entries, and `commits` is
163 /// `None`. Rendered, so a reader never mistakes a partial count for a
164 /// complete one.
165 pub partial: bool,
166}
167
168/// Whether the deadline has passed. `None` is a run with no budget.
169fn spent(deadline: Option<Instant>) -> bool {
170 deadline.is_some_and(|dl| Instant::now() >= dl)
171}
172
173/// What a memory store is, in two passes and no more.
174///
175/// # Why neither pass is per edge
176///
177/// The counts here are per *label* and per *edge type*, and there are two ways
178/// to get them expensively. One is to ask the store about each edge in turn —
179/// `explain` per edge is a rule evaluation per edge. The other is to
180/// materialise every edge first: [`GraphDb::all_edges_for_export`] gives the
181/// rule names, the ends and the counts in one sweep, but it pays three
182/// `String`s and a provenance entry per edge to do it, which on the 1.3 M-edge
183/// association store is seven seconds and hundreds of megabytes spent to print
184/// nine lines.
185///
186/// So edges go through [`GraphDb::edge_type_census`], which walks the topology
187/// and sums neighbour slice lengths without building a record per edge, and
188/// nodes through [`GraphDb::all_nodes_for_export`], which is one record per
189/// node — on a memory store there are thousands of those, not millions.
190///
191/// Both come back sorted, and the census's sample edge is the first of its
192/// type in the store's own id order, so the worked calls name the same keys on
193/// every run.
194///
195/// # The budget
196///
197/// Neither pass is bounded by anything but the store, and the history count
198/// after them re-reads the WAL. `deadline` is checked inside both loops and
199/// before the WAL scan, so what a spent budget costs is *completeness*, not
200/// the brief: the counts reached become lower bounds, the history goes
201/// uncounted, and the `as of` recipe — which needs a commit index the scan
202/// would have supplied — is not shown at all rather than shown wrong.
203#[must_use]
204pub fn brief<F: Fs>(db: &GraphDb<F>, opts: &BriefOptions) -> SchemaBrief {
205 // One deadline for the whole brief, taken before the first read.
206 let deadline = Instant::now().checked_add(opts.budget);
207
208 let nodes = db.all_nodes_for_export();
209 let mut partial = false;
210
211 // Pass one: label → how many nodes, and every property name any of them
212 // has. `props` is a `BTreeMap`, so the union arrives sorted.
213 let mut by_label: BTreeMap<String, (usize, BTreeSet<String>)> = BTreeMap::new();
214 let mut counted = 0usize;
215 for n in &nodes {
216 if spent(deadline) {
217 partial = true;
218 break;
219 }
220 counted += 1;
221 let entry = by_label.entry(n.label.clone()).or_default();
222 entry.0 += 1;
223 entry.1.extend(
224 n.props
225 .keys()
226 .filter(|p| !HIDDEN_PROPS.contains(&p.as_str()))
227 .cloned(),
228 );
229 }
230
231 // Pass two: the per-type census, keyed for the recipes to read back.
232 let mut by_type: BTreeMap<String, EdgeTypeCensus> = BTreeMap::new();
233 if spent(deadline) {
234 partial = true;
235 } else {
236 for c in db.edge_type_census() {
237 if spent(deadline) {
238 partial = true;
239 break;
240 }
241 by_type.insert(c.edge_type.clone(), c);
242 }
243 }
244
245 let mut labels: Vec<LabelBrief> = by_label
246 .iter()
247 .map(|(label, (count, props))| {
248 let all: Vec<String> = props.iter().map(|p| sanitize(p)).collect();
249 let hidden = all.len().saturating_sub(MAX_LABEL_PROPS);
250 LabelBrief {
251 label: sanitize(label),
252 nodes: *count,
253 props: all.into_iter().take(MAX_LABEL_PROPS).collect(),
254 hidden_props: hidden,
255 }
256 })
257 .collect();
258 labels.sort_by(|a, b| b.nodes.cmp(&a.nodes).then(a.label.cmp(&b.label)));
259
260 let mut edge_types: Vec<EdgeTypeBrief> = by_type
261 .values()
262 .map(|c| EdgeTypeBrief {
263 edge_type: sanitize(&c.edge_type),
264 rule: c.rules.first().map(|r| sanitize(r)),
265 hidden_rules: c.rules.len().saturating_sub(1),
266 src: ends(&c.src_labels),
267 dst: ends(&c.dst_labels),
268 edges: usize::try_from(c.edges).unwrap_or(usize::MAX),
269 })
270 .collect();
271 edge_types.sort_by(|a, b| b.edges.cmp(&a.edges).then(a.edge_type.cmp(&b.edge_type)));
272
273 let mut roles: Vec<(String, Vec<String>)> = db
274 .roles()
275 .into_iter()
276 .map(|r| {
277 (
278 sanitize(&r.name),
279 r.labels.iter().map(|l| sanitize(l)).collect(),
280 )
281 })
282 .collect();
283 roles.sort();
284
285 // Which property each rule actually reads, per label it reads it on. The
286 // `what_if` recipe is only worth copying when it names a field some rule
287 // has an opinion about: changing a node's display name loses and gains
288 // nothing, and a recipe that demonstrates nothing teaches nothing.
289 let mut rule_fields: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
290 for rule in db.rules() {
291 let mut fields = BTreeSet::new();
292 predicate_fields(&rule.predicate, &mut fields);
293 for label in [&rule.src_label, &rule.dst_label] {
294 rule_fields
295 .entry(label.clone())
296 .or_default()
297 .extend(fields.iter().cloned());
298 }
299 }
300
301 // Two different numbers, and the difference is the whole point. The
302 // history line reports how many commits the store has replayed, which is
303 // what a reader wants to know about its depth. The `edges_at` recipe needs
304 // an *index*, and `wal_total_commits` is the only thing that knows the top
305 // of that range — `commit_seq` counts replayed frames and is seeded from
306 // the snapshot's sequence numbers, so it is neither the count nor the
307 // index on a store that has ever been snapshotted.
308 //
309 // `wal_total_commits` re-reads the WAL, which is not free; it is bought
310 // once here rather than paid for by a session that copies a broken call.
311 // `edges_at` itself pays the same scan, so a brief that can afford to name
312 // the call can afford to have checked it.
313 // A WAL-truncating snapshot that has outlived its archives leaves a store
314 // with no reachable history at all: `floor == total`, an empty range, and
315 // every commit index out of it. There is no `as of` call to show, so the
316 // brief shows none — a recipe that cannot answer is worse than a missing
317 // one, since the session that copies it learns the tool is broken.
318 //
319 // And it is the first thing the budget drops: on a store nobody has
320 // snapshotted the scan is seconds on its own, which is the whole of the
321 // hook's five. Uncounted history renders as `unknown` and takes the `as
322 // of` recipe with it — a recipe whose commit index was guessed is the one
323 // failure a recipe must not have.
324 let (commits, latest_commit) = if spent(deadline) {
325 partial = true;
326 (None, None)
327 } else {
328 let floor = db.wal_horizon_floor();
329 let total = db.wal_total_commits().unwrap_or(floor);
330 (
331 Some(total.saturating_sub(floor)),
332 (total > floor).then(|| total - 1),
333 )
334 };
335 let recipes = recipes(
336 &nodes,
337 &by_type,
338 &labels,
339 &edge_types,
340 &roles,
341 &rule_fields,
342 latest_commit,
343 );
344
345 SchemaBrief {
346 // What was actually counted, so the number the brief prints is a
347 // lower bound the pass reached rather than a total it never read.
348 nodes: counted,
349 edges: usize::try_from(db.edge_count()).unwrap_or(usize::MAX),
350 labels,
351 edge_types,
352 commits,
353 roles,
354 recipes,
355 partial,
356 }
357}
358
359/// Every property name a predicate reads, its branches included.
360fn predicate_fields(p: &core_rules::Predicate, out: &mut BTreeSet<String>) {
361 use core_rules::Predicate as P;
362 match p {
363 P::KeyMatch { field }
364 | P::FieldEqual { field }
365 | P::Overlap { field, .. }
366 | P::NumericWithin { field, .. }
367 | P::GeoRadius { field, .. }
368 | P::VectorSimilar { field, .. } => {
369 out.insert(field.clone());
370 }
371 P::All(parts) | P::Any(parts) => {
372 for part in parts {
373 predicate_fields(part, out);
374 }
375 }
376 }
377}
378
379/// The labels on one end of an edge type, cut at [`MAX_END_LABELS`].
380fn ends(labels: &[String]) -> Vec<String> {
381 labels
382 .iter()
383 .take(MAX_END_LABELS)
384 .map(|l| sanitize(l))
385 .collect()
386}
387
388/// One worked call per question kind, in the order a session meets them.
389///
390/// Every placeholder the store can fill is filled: a pair a rule actually
391/// derived for `explain_association`, a key that has edges for `node_edges`,
392/// a commit `edges_at` accepts, a property that key really carries for
393/// `what_if`, a role out of `roles.json`, and the store's own labels and edge
394/// type in the counting template. What the store cannot supply — the *new*
395/// value in a `what_if` — stays an angle-bracketed placeholder rather than an
396/// invention.
397///
398/// # The keys come back through `key(n)`
399///
400/// A node's key is not a property, so `RETURN n.key` parses, runs, and answers
401/// a column of nulls. `key(n)` is the function that returns it. Same failure
402/// mode as the commit below: a line that reads like a call and answers
403/// nothing.
404///
405/// In the counting recipe the key is taken in the `WITH` rather than the
406/// `RETURN`, because after a grouping `WITH` the variable is a projected
407/// scalar and no longer a node: `key(b)` in the `RETURN` errors there.
408///
409/// # The commit is a commit, not a count
410///
411/// `history: N commits` counts; `edges_at`'s `at` is a zero-based WAL index
412/// whose range is `wal_horizon_floor..total_commits`. Substituting the count
413/// names one past the end, and the worked example answers `CommitOutOfRange`
414/// on every store there has ever been — the worst thing a recipe can do, since
415/// a session that copies it learns the tool is broken. So the recipe gets
416/// `latest_commit`, the newest index the store will accept.
417fn recipes(
418 nodes: &[crate::db::NodeInfo],
419 by_type: &BTreeMap<String, EdgeTypeCensus>,
420 labels: &[LabelBrief],
421 edge_types: &[EdgeTypeBrief],
422 roles: &[(String, Vec<String>)],
423 rule_fields: &BTreeMap<String, BTreeSet<String>>,
424 latest_commit: Option<u64>,
425) -> Vec<Recipe> {
426 // The pair to explain: prefer a type some rule derives, since that is the
427 // pair `explain_association` can name a predicate for. `edge_types` is
428 // already sorted most-numerous-first, so this is the busiest such type.
429 let sample_of = |t: &EdgeTypeBrief| by_type.get(&t.edge_type).and_then(|c| c.sample.clone());
430 let pair = edge_types
431 .iter()
432 .filter(|t| t.rule.is_some())
433 .find_map(sample_of)
434 .or_else(|| edge_types.iter().find_map(sample_of));
435 let (a, b) = match &pair {
436 Some((a, b)) => (sanitize(a), sanitize(b)),
437 None => ("<a>".to_string(), "<b>".to_string()),
438 };
439 // The key to ask about: the source of that pair, which is known to have
440 // edges. Failing any edge at all, the store's first key.
441 let key = match &pair {
442 Some((a, _)) => sanitize(a),
443 None => nodes
444 .first()
445 .map_or_else(|| "<key>".to_string(), |n| sanitize(&n.key)),
446 };
447 // A property that key really carries, and — where the store has a rule
448 // reading one — a property some rule reads, so the `what_if` shown is one
449 // that would actually lose and gain edges. A `what_if` on a display name
450 // is a demonstration of nothing.
451 //
452 // `id` and `key` are skipped either way: both name the node rather than
453 // describe it, and changing a node's name is `rename_node`, not a question
454 // about what its relationships would become. [`HIDDEN_PROPS`] goes with
455 // them: `what_if key embedding <value>` asks the session to type out a
456 // vector.
457 //
458 // The node itself is kept as `key_node` rather than looked up twice: the
459 // intersection below reads its label, the same node the field is read
460 // from.
461 let key_node = nodes.iter().find(|n| n.key == key);
462 let field = key_node
463 .and_then(|n| {
464 let watched = rule_fields.get(&n.label);
465 let mut usable = n.props.keys().filter(|f| {
466 !IDENTITY_PROPS.contains(&f.as_str()) && !HIDDEN_PROPS.contains(&f.as_str())
467 });
468 usable
469 .clone()
470 .find(|f| watched.is_some_and(|w| w.contains(*f)))
471 .or_else(|| usable.next())
472 })
473 .map_or_else(|| "<field>".to_string(), |f| sanitize(f));
474 // The same intersection `linked_by_all_recipe` runs for the store's
475 // busiest source label, run here for the label of the key already
476 // picked above — up to [`LINKED_BY_ALL_MAX_TYPES`] edge types shared
477 // between that label and the one target label they most often reach
478 // together. `edges_at`, `node_edges` and `what_if` all accept `all_of` /
479 // `edge_type`, so the same selection teaches the one-call intersection
480 // form on every recipe that names this key, not only on the one recipe
481 // that happens to demonstrate a `MATCH`.
482 let intersection = key_node.and_then(|n| types_from(&n.label, edge_types));
483 // The role and the label it probes have to be chosen *together*. Picked
484 // independently — the first role, the most populous label — the
485 // association store rendered `MATCH (n:Talent) … role: client`, and
486 // `client` reads only `Company` and `Job`: zero rows, and the session that
487 // copies the line learns the tool is broken. So walk the roles in the
488 // order they are printed and take the first that can see any label at all,
489 // probing the busiest label it can see (`labels` is already sorted most
490 // populous first). A store whose roles name no label the brief lists —
491 // roles scoped to individual keys, or no roles at all — falls back to the
492 // first role and the busiest label, which is as much as can be said.
493 let (role, probe_label) = roles
494 .iter()
495 .find_map(|(name, visible)| {
496 labels
497 .iter()
498 .find(|l| visible.contains(&l.label))
499 .map(|l| (name.clone(), l.label.clone()))
500 })
501 .unwrap_or_else(|| {
502 (
503 roles
504 .first()
505 .map_or_else(|| "<name>".to_string(), |(n, _)| n.clone()),
506 labels
507 .first()
508 .map_or_else(|| "<label>".to_string(), |l| l.label.clone()),
509 )
510 });
511 // The counting template. The busiest edge type, with the labels it was
512 // actually seen between.
513 let (l1, etype, l2) = edge_types.first().map_or_else(
514 || ("<L1>".to_string(), "<TYPE>".to_string(), "<L2>".to_string()),
515 |t| {
516 (
517 t.src.first().cloned().unwrap_or_else(|| "<L1>".to_string()),
518 t.edge_type.clone(),
519 t.dst.first().cloned().unwrap_or_else(|| "<L2>".to_string()),
520 )
521 },
522 );
523
524 let mut out = vec![
525 Recipe {
526 question: "why".to_string(),
527 call: format!(
528 "explain_association {a} {b} — returns each relationship's rule and \
529 the values the two share, so there is no need to fetch raw lists to \
530 compare by hand"
531 ),
532 },
533 Recipe {
534 question: "relationships".to_string(),
535 call: relationships_call(&key, &intersection),
536 },
537 ];
538 if let Some(at) = latest_commit {
539 out.push(Recipe {
540 question: "as of".to_string(),
541 call: as_of_call(&key, at, &intersection),
542 });
543 }
544 out.extend([
545 Recipe {
546 question: "what if".to_string(),
547 call: what_if_call(&key, &field, &intersection),
548 },
549 Recipe {
550 question: "who may see".to_string(),
551 call: format!(
552 "query 'MATCH (n:{probe_label}) RETURN key(n) LIMIT {ROLE_PROBE_ROWS}' role: {role}"
553 ),
554 },
555 Recipe {
556 question: "how many".to_string(),
557 call: format!(
558 "MATCH (a:{l1})-[:{etype}]->(b:{l2}) WITH key(b) AS b_key, count(a) AS n \
559 WHERE n >= {HOW_MANY_MIN} RETURN b_key, n"
560 ),
561 },
562 ]);
563 // The seventh recipe: "linked by all of" — the multi-hop intersection a
564 // benchmark showed agents fail, chaining one `MATCH` per relation with
565 // fresh variables and so counting each relation independently instead of
566 // requiring all of them at once. One `MATCH` with comma-separated
567 // patterns sharing `a` and `b` is the shape that actually intersects.
568 //
569 // Omitted on a store with only one edge type in it altogether: there is
570 // nothing to intersect, and a recipe of one pattern would demonstrate the
571 // wrong thing.
572 if edge_types.len() > 1 {
573 if let Some(recipe) = linked_by_all_recipe(labels, edge_types) {
574 out.push(recipe);
575 }
576 }
577 out
578}
579
580/// The "linked by all of" recipe: up to [`LINKED_BY_ALL_MAX_TYPES`] edge
581/// types run between the store's own busiest source label and the
582/// destination label it most commonly reaches, intersected in one `MATCH`
583/// rather than chained across several.
584///
585/// The pair is picked from the store's own schema, not asked for: the most
586/// populous label that is ever a source (`labels` is already sorted most
587/// populous first), then the destination label its edges most often land on,
588/// weighted by how many edges each type carries. With fewer than
589/// [`LINKED_BY_ALL_MAX_TYPES`] edge types actually running between that
590/// pair, the recipe still renders — one pattern is still a worked call, even
591/// though there is nothing yet to intersect it against.
592///
593/// `None` only when no label is ever a source, which does not happen once
594/// `edge_types` is non-empty — every edge type's `src` came from a real
595/// source label — but the search stays an `Option` rather than assume it.
596fn linked_by_all_recipe(labels: &[LabelBrief], edge_types: &[EdgeTypeBrief]) -> Option<Recipe> {
597 let src = &labels
598 .iter()
599 .find(|l| edge_types.iter().any(|t| t.src.contains(&l.label)))?
600 .label;
601 let (types, dst) = types_from(src, edge_types)?;
602
603 let mut pattern = format!("(a:{src})-[:{}]->(b:{dst})", types[0]);
604 for t in &types[1..] {
605 pattern.push_str(&format!(", (a)-[:{t}]->(b)"));
606 }
607
608 Some(Recipe {
609 question: "linked by all of".to_string(),
610 call: format!(
611 "MATCH {pattern} WITH b, count(DISTINCT a) AS n WHERE n >= 1 RETURN key(b), n \
612 ORDER BY n DESC LIMIT 20 — add `WHERE a.<field> = …` before WITH to filter \
613 the source side; one MATCH with comma-separated patterns intersects, \
614 separate MATCHes do not"
615 ),
616 })
617}
618
619/// Up to [`LINKED_BY_ALL_MAX_TYPES`] edge types running from `src`, and the
620/// one destination label they most often reach together — the selection
621/// [`linked_by_all_recipe`] runs for the store's own busiest source label,
622/// factored out so [`recipes`] can run the identical census-based pick for
623/// the source label of whatever key it has already chosen.
624///
625/// Weighted by how many edges each type carries, ties on the destination
626/// label's name; `edge_types` is already sorted most-numerous-first, ties on
627/// the name, so filtering it keeps that order — "most populous first" among
628/// the types that actually connect `src` to the label picked.
629///
630/// `None` when `src` is never a source at all — the only way for there to be
631/// no destination label to weigh.
632fn types_from(src: &str, edge_types: &[EdgeTypeBrief]) -> Option<(Vec<String>, String)> {
633 let mut by_dst: BTreeMap<&str, usize> = BTreeMap::new();
634 for t in edge_types.iter().filter(|t| t.src.iter().any(|s| s == src)) {
635 for dst in &t.dst {
636 *by_dst.entry(dst.as_str()).or_default() += t.edges;
637 }
638 }
639 let (dst, _) = by_dst
640 .into_iter()
641 .max_by_key(|(name, n)| (*n, std::cmp::Reverse(*name)))?;
642
643 let types: Vec<String> = edge_types
644 .iter()
645 .filter(|t| t.src.iter().any(|s| s == src) && t.dst.iter().any(|d| d.as_str() == dst))
646 .take(LINKED_BY_ALL_MAX_TYPES)
647 .map(|t| t.edge_type.clone())
648 .collect();
649 (!types.is_empty()).then(|| (types, dst.to_string()))
650}
651
652/// The `relationships` recipe: `node_edges` with the intersection the key's
653/// own source label supports, when there is one.
654///
655/// `all_of` even for a single type — the reply is the same partner-keys
656/// shape either way, and the note names the `edge_type` shortcut rather than
657/// the call switching form for it. Falls back to the plain call when the key
658/// has no [`types_from`] selection at all (a key that is never a source, or
659/// a store with no edge types).
660fn relationships_call(key: &str, intersection: &Option<(Vec<String>, String)>) -> String {
661 match intersection {
662 Some((types, dst)) => format!(
663 "node_edges {key} all_of: [{}] label: {dst} — or edge_type: {} for one \
664 type's partner keys",
665 types.join(", "),
666 types[0]
667 ),
668 None => format!("node_edges {key}"),
669 }
670}
671
672/// The `as of` recipe: `edges_at` with the same intersection, at commit `at`.
673///
674/// Unlike [`relationships_call`], a single type switches the call itself to
675/// `edge_type` rather than an `all_of` of one.
676///
677/// # The note is about where `at` comes from
678///
679/// The first association run lost two time-travel cells the same way: the
680/// agent had the right data and still answered from an arbitrary late commit,
681/// because the question named a *date* and `at` is a commit index. Nothing in
682/// a commit carries a date, so guessing one from the end of the WAL is the
683/// failure this note exists to stop — an agent asked a question in calendar time
684/// going off to *derive* a commit number, by probing `node_history`, or `stats`,
685/// or a date map sitting next to the store. As of v0.6.11 `at` takes the date
686/// itself, so the note points at that instead. It is worth more here than the
687/// `all_of` explanation the note used to carry, which `relationships_call`
688/// already gives on the line above.
689fn as_of_call(key: &str, at: u64, intersection: &Option<(Vec<String>, String)>) -> String {
690 let note = "— or pass a date in place of the number: `2026-06-19`, \
691 `2026-06-19T12:00:00Z`. It resolves to the last commit at or \
692 before that instant, so there is no commit to go and find";
693 match intersection {
694 Some((types, dst)) if types.len() >= 2 => format!(
695 "edges_at {key} {at} all_of: [{}] label: {dst} {note}",
696 types.join(", ")
697 ),
698 Some((types, _)) => format!("edges_at {key} {at} edge_type: {} {note}", types[0]),
699 None => format!("edges_at {key} {at}"),
700 }
701}
702
703/// The `what if` recipe: `what_if` with the busiest type from the same
704/// intersection, so the shown call also demonstrates narrowing to one type's
705/// partner keys.
706fn what_if_call(key: &str, field: &str, intersection: &Option<(Vec<String>, String)>) -> String {
707 match intersection {
708 Some((types, _)) => format!(
709 "what_if {key} {field} <value> edge_type: {} — the partners that would be \
710 lost or gained under that type",
711 types[0]
712 ),
713 None => format!("what_if {key} {field} <value>"),
714 }
715}
716
717// ── rendering ───────────────────────────────────────────────────────────────
718
719/// Longest session brief, in bytes.
720///
721/// A `SessionStart` hook's output is prepended to a session and cached for the
722/// whole of it, so it is paid for once but carried by every turn. Four
723/// thousand bytes is roughly a thousand tokens: enough for two rankings deep
724/// enough to be worth having, short enough that a session that never asks the
725/// graph anything has lost almost nothing.
726pub const MAX_BRIEF_BYTES: usize = 4_000;
727
728/// The one line a store with nothing in it at all gets as a session opens:
729/// what is missing, and the memory write tools that fill it — the same offer
730/// the skill makes for an empty store. There is nothing to be central *in*,
731/// and no point naming a way to reach an empty graph.
732///
733/// Not marked with [`UNTRUSTED_FRAMING`], unlike every brief with a graph
734/// behind it: not one byte of this line came out of a store, so there is
735/// nothing here to mark as data.
736pub const EMPTY_BRIEF: &str =
737 "mushroomdb brief — empty store; offer to fill it: `remember` for a fact, \
738 `upsert_entity` for one entity, `ingest_json` for a batch of rows\n";
739
740/// Headings a memory store's schema sits under.
741const BRIEF_LABELS_HEADING: &str = "labels:\n";
742const BRIEF_EDGE_TYPES_HEADING: &str = "edge types:\n";
743/// The heading over the worked calls. Named for what a reader wants out of
744/// it — one call, not a search — because the failure it exists to stop is a
745/// session probing the store for its schema before asking anything.
746const BRIEF_RECIPES_HEADING: &str = "ask in one call:\n";
747
748/// `1204` → `1,204`. Groups of three, ASCII digits only.
749fn thousands(n: usize) -> String {
750 let digits = n.to_string();
751 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
752 for (i, c) in digits.chars().enumerate() {
753 if i > 0 && (digits.len() - i).is_multiple_of(3) {
754 out.push(',');
755 }
756 out.push(c);
757 }
758 out
759}
760
761/// `n` of `word`, pluralised by adding an `s`. `1 file`, `2 files`.
762fn plural(n: usize, word: &str) -> String {
763 if n == 1 {
764 format!("{n} {word}")
765 } else {
766 format!("{} {word}s", thousands(n))
767 }
768}
769
770/// Keep whole lines while they fit in `max` bytes, dropping the rest.
771///
772/// A budget in bytes, unlike one in lines, can fall in the middle of a line —
773/// and half a line is worse than no line: a path cut short still reads as a
774/// path, and a caller acts on it. So the cut is always at a line ending, and
775/// a first line too long to fit yields nothing rather than a fragment.
776fn cap_bytes(text: &str, max: usize) -> String {
777 let mut out = String::with_capacity(text.len().min(max));
778 for line in text.lines() {
779 if out.len() + line.len() + 1 > max {
780 break;
781 }
782 out.push_str(line);
783 out.push('\n');
784 }
785 out
786}
787
788/// A memory store's brief: the schema, then one worked call per question
789/// kind, then `reach`.
790///
791/// `reach` is one line naming how to reach the graph from this session, which
792/// only the caller knows — a tool name on the MCP arm, a command on the CLI
793/// arm. It is fitted first and appended last, so everything above it gives way
794/// to it rather than the other way round. It is therefore the one part exempt
795/// from the budget. A store with no nodes and no edges gets [`EMPTY_BRIEF`]
796/// and no `reach` line at all.
797///
798/// The order is the argument. A session that has just been handed the
799/// association surface and an unfamiliar store asks two questions before its
800/// own — *what is in here* and *how do I ask* — and the first association run
801/// showed it answering both by probing Cypher, one guess at a time. So the
802/// labels and the edge types come first, complete enough to write a query
803/// against, and the worked calls come last, where a reader who skimmed the
804/// schema still lands on them.
805///
806/// **The calls come off last, and only when nothing else is left.** When the
807/// budget is short, entries drop from the listings above — edge types first,
808/// then labels, since a label with no edge type is still a thing to query and
809/// an edge type with no labels is not — and the cut is counted in the same
810/// `… and N more` every other digest uses. Dropping a recipe first would save
811/// a line and cost the session the round trip the whole section exists to
812/// remove.
813///
814/// # The cap is hard
815///
816/// Dropping lines alone is not a ceiling: a store whose names are themselves
817/// hundreds of bytes long spends the budget inside the lines that remain — a
818/// schema of 250-character edge types rendered 5,986 bytes against a 4,000
819/// byte cap, because the loop stopped when it ran out of *lines* rather than
820/// when it fit. So three measures run in order, each only when the one before
821/// it was not enough:
822///
823/// 1. the listings render whole, which is what every ordinary store gets;
824/// 2. every name is cut to [`BRIEF_NAME_CAP`] characters, and entries then
825/// drop from the listings against the shorter lines, counted;
826/// 3. the worked calls come off from the end, and the brief says so on a
827/// final `(brief truncated at 4,000 bytes)` line.
828///
829/// A brief whose header, history and roles alone overrun the budget — nothing
830/// left to drop — is cut on whole lines by [`cap_bytes`], so the returned
831/// string is never longer than the budget whatever the store holds.
832#[must_use]
833pub fn render(s: &SchemaBrief, reach: &str) -> String {
834 if s.nodes == 0 && s.edges == 0 {
835 return EMPTY_BRIEF.to_string();
836 }
837 let tail = format!("reach the graph: {}\n", sanitize(reach));
838 let budget = MAX_BRIEF_BYTES.saturating_sub(tail.len());
839
840 // A partial schema counts what it reached, so every count it produced is
841 // a lower bound. Marked once in the header rather than on each line — the
842 // budget the marker is charged against is the same one the counts came
843 // short of.
844 let at_least = |n: usize| {
845 if s.partial {
846 format!("≥ {}", thousands(n))
847 } else {
848 thousands(n)
849 }
850 };
851 let header = format!(
852 "{UNTRUSTED_FRAMING}mushroomdb brief — {}{}\n",
853 [
854 if s.partial {
855 format!("≥ {}", plural(s.nodes, "node"))
856 } else {
857 plural(s.nodes, "node")
858 },
859 plural(s.edges, "edge"),
860 plural(s.labels.len(), "label"),
861 ]
862 .join(SEP),
863 if s.partial { " (partial)" } else { "" }
864 );
865
866 let label_lines = |cap: usize| -> Vec<String> {
867 s.labels
868 .iter()
869 .map(|l| {
870 let mut line = format!(
871 " {} ({})",
872 cap_name(&sanitize(&l.label), cap),
873 at_least(l.nodes)
874 );
875 if !l.props.is_empty() {
876 let props: Vec<String> = l.props.iter().map(|p| cap_name(p, cap)).collect();
877 let _ = write!(line, " — {}", props.join(", "));
878 }
879 if l.hidden_props > 0 {
880 let _ = write!(line, ", … +{}", l.hidden_props);
881 }
882 line.push('\n');
883 line
884 })
885 .collect()
886 };
887 let edge_type_lines = |cap: usize| -> Vec<String> {
888 s.edge_types
889 .iter()
890 .map(|t| {
891 let mut line = format!(
892 " {} ({})",
893 cap_name(&sanitize(&t.edge_type), cap),
894 at_least(t.edges)
895 );
896 if let Some(rule) = &t.rule {
897 let _ = write!(line, " — rule {}", cap_name(&sanitize(rule), cap));
898 if t.hidden_rules > 0 {
899 let _ = write!(line, " +{}", t.hidden_rules);
900 }
901 }
902 let _ = writeln!(
903 line,
904 " — {} → {}",
905 end_labels(&t.src, cap),
906 end_labels(&t.dst, cap)
907 );
908 line
909 })
910 .collect()
911 };
912
913 // The part that gives way last: how deep the history runs, who may read
914 // it, and the calls.
915 // `unknown`, not `0`: a history the budget never counted is not a history
916 // that is not there, and the two lead a reader to opposite conclusions.
917 let mut prefix = match s.commits {
918 Some(n) => format!("history: {n} commits\n"),
919 None => "history: unknown\n".to_string(),
920 };
921 if !s.roles.is_empty() {
922 let roles: Vec<String> = s
923 .roles
924 .iter()
925 .map(|(name, labels)| {
926 if labels.is_empty() {
927 sanitize(name)
928 } else {
929 format!("{} ({})", sanitize(name), labels.join(", "))
930 }
931 })
932 .collect();
933 let _ = writeln!(prefix, "roles: {}", roles.join(SEP));
934 }
935 let recipes: Vec<String> = s
936 .recipes
937 .iter()
938 .map(|r| format!(" {}: {}\n", sanitize(&r.question), sanitize(&r.call)))
939 .collect();
940 let with_recipes = |kept: usize| -> String {
941 let mut fixed = prefix.clone();
942 if kept > 0 {
943 fixed.push_str(BRIEF_RECIPES_HEADING);
944 fixed.extend(recipes[..kept].iter().map(String::as_str));
945 }
946 fixed
947 };
948 let fixed = with_recipes(recipes.len());
949
950 // Measure one: the listings whole. A store whose brief already fits — every
951 // ordinary one — renders exactly the bytes it always did, since nothing
952 // below runs.
953 let whole = memory_body(
954 &header,
955 &label_lines(usize::MAX),
956 &edge_type_lines(usize::MAX),
957 &fixed,
958 0,
959 );
960 if whole.len() <= budget {
961 return whole + &tail;
962 }
963
964 // Measure two: every name cut to [`BRIEF_NAME_CAP`], and *then* entries
965 // dropped against the shorter lines. Cutting before dropping rather than
966 // after is the order that does anything: a brief over budget because one
967 // name is 250 characters keeps its whole schema once the name is cut,
968 // where dropping first would throw away entries to pay for the names
969 // inside the few that remain — and by the time dropping alone has run out
970 // of entries there are no names left to cut.
971 let mut labels = label_lines(BRIEF_NAME_CAP);
972 let mut edge_types = edge_type_lines(BRIEF_NAME_CAP);
973 let mut dropped = 0;
974 loop {
975 let body = memory_body(&header, &labels, &edge_types, &fixed, dropped);
976 if body.len() <= budget {
977 return body + &tail;
978 }
979 if edge_types.pop().is_none() && labels.pop().is_none() {
980 break;
981 }
982 dropped += 1;
983 }
984
985 // Measure three: the worked calls, from the end, and a line that says the
986 // brief was cut — without it a session reads a truncated set of recipes as
987 // the whole set.
988 let truncated = format!(
989 "(brief truncated at {} bytes)\n",
990 thousands(MAX_BRIEF_BYTES)
991 );
992 let mut kept = recipes.len();
993 loop {
994 let body = memory_body(&header, &[], &[], &with_recipes(kept), dropped) + &truncated;
995 if body.len() <= budget {
996 return body + &tail;
997 }
998 if kept == 0 {
999 // Nothing droppable is left: the header, the history and the roles
1000 // alone overrun the budget. Whole lines come off the end so the
1001 // ceiling holds whatever the store is named.
1002 return cap_bytes(&body, budget) + &tail;
1003 }
1004 kept -= 1;
1005 }
1006}
1007
1008/// Longest a name may print in a memory brief that did not fit its budget
1009/// with every droppable listing entry already gone.
1010///
1011/// Sixty characters is longer than any name written to be read and short
1012/// enough that a line spends its budget on the schema rather than on one
1013/// identifier. It applies only to the cut round: a store whose names are
1014/// ordinary never reaches it, and renders exactly what it rendered before.
1015const BRIEF_NAME_CAP: usize = 60;
1016
1017/// `name` cut to at most `cap` characters, the last of them `…` when anything
1018/// came off.
1019///
1020/// Counted in characters and cut on a character boundary, so a name of runes
1021/// is never halved mid-rune. `usize::MAX` is the uncut round and returns the
1022/// name whole.
1023fn cap_name(name: &str, cap: usize) -> String {
1024 if cap == 0 || name.chars().count() <= cap {
1025 return name.to_string();
1026 }
1027 let end = name
1028 .char_indices()
1029 .nth(cap - 1)
1030 .map_or(name.len(), |(i, _)| i);
1031 format!("{}…", &name[..end])
1032}
1033
1034/// A memory store's brief above its `reach` line, for one candidate schema.
1035fn memory_body(
1036 header: &str,
1037 labels: &[String],
1038 edge_types: &[String],
1039 fixed: &str,
1040 dropped: usize,
1041) -> String {
1042 let mut out = String::from(header);
1043 if !labels.is_empty() {
1044 out.push_str(BRIEF_LABELS_HEADING);
1045 out.extend(labels.iter().map(String::as_str));
1046 }
1047 if !edge_types.is_empty() {
1048 out.push_str(BRIEF_EDGE_TYPES_HEADING);
1049 out.extend(edge_types.iter().map(String::as_str));
1050 }
1051 if dropped > 0 {
1052 let _ = writeln!(out, " … and {dropped} more");
1053 }
1054 out.push_str(fixed);
1055 out
1056}
1057
1058/// The labels on one end of an edge type, as one phrase, each cut to `cap`
1059/// characters. An edge type seen between nodes of no known label — every
1060/// endpoint tombstoned — says `?` rather than leaving the arrow with nothing
1061/// on one side.
1062fn end_labels(labels: &[String], cap: usize) -> String {
1063 if labels.is_empty() {
1064 "?".to_string()
1065 } else {
1066 labels
1067 .iter()
1068 .map(|l| cap_name(&sanitize(l), cap))
1069 .collect::<Vec<_>>()
1070 .join("|")
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 #[test]
1079 fn cap_bytes_keeps_whole_lines_and_never_half_of_one() {
1080 let text = "aaaa\nbbbb\ncccc\n"; // three five-byte lines
1081 assert_eq!(cap_bytes(text, 15), text, "the whole text fits exactly");
1082 assert_eq!(
1083 cap_bytes(text, 14),
1084 "aaaa\nbbbb\n",
1085 "the last line is whole"
1086 );
1087 assert_eq!(cap_bytes(text, 10), "aaaa\nbbbb\n");
1088 assert_eq!(cap_bytes(text, 9), "aaaa\n");
1089 assert_eq!(
1090 cap_bytes(text, 4),
1091 "",
1092 "a first line too long yields nothing, never a fragment"
1093 );
1094 assert_eq!(cap_bytes(text, 0), "");
1095 // A line with no trailing newline still costs the one it is given.
1096 assert_eq!(cap_bytes("abc", 4), "abc\n");
1097 assert_eq!(cap_bytes("abc", 3), "");
1098 }
1099
1100 #[test]
1101 fn thousands_groups_from_the_right() {
1102 for (n, want) in [
1103 (0, "0"),
1104 (7, "7"),
1105 (999, "999"),
1106 (1_000, "1,000"),
1107 (1_204, "1,204"),
1108 (999_999, "999,999"),
1109 (1_830_412, "1,830,412"),
1110 ] {
1111 assert_eq!(thousands(n), want, "{n}");
1112 }
1113 }
1114
1115 #[test]
1116 fn plural_says_one_file_and_two_files() {
1117 assert_eq!(plural(1, "file"), "1 file");
1118 assert_eq!(plural(0, "file"), "0 files");
1119 assert_eq!(plural(1_204, "commit"), "1,204 commits");
1120 }
1121}