Skip to main content

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}