Skip to main content

core_api/repograph/
brief.rs

1//! `brief` — the repository 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. What it names is what the graph already says is
6//! central — the files the most rank flows to, and the symbols the most other
7//! symbols call — plus how big the graph is and which commit it was synced to.
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: unlike [`repo_map`](super::repo_map) there is no sync *age*, only
14//! the sha, and no window measured against a "now". Every collection is
15//! sorted with the ties broken on the key, so hash iteration order cannot
16//! reach the output either.
17//!
18//! # Where the two rankings come from
19//!
20//! | Section | From |
21//! |---|---|
22//! | key files | the same [`file_pagerank`] `map` ranks with — deeper, not different |
23//! | key symbols | incoming `CALLS`, ties on the key |
24//!
25//! Sharing `map`'s ranking is the point: two tools that disagreed about which
26//! files matter would each be wrong half the time.
27
28use crate::db::{EdgeTypeCensus, GraphDb};
29use crate::repograph::facts::str_prop;
30use crate::repograph::map::{file_pagerank, spent, SYNC_KEY};
31use crate::repograph::render::{basename, dir_components, sanitize, top_tokens};
32use core_storage::fs::Fs;
33use core_storage::Value;
34use serde::Serialize;
35use std::collections::{BTreeMap, BTreeSet};
36use std::time::{Duration, Instant};
37
38/// Property names one label's line may name before the rest are counted off.
39/// A line is the unit the byte budget drops, so one wide label must not be
40/// able to spend the whole brief.
41const MAX_LABEL_PROPS: usize = 12;
42/// Labels one edge type's line may name on either end. An edge type whose
43/// sources are of four labels is telling the reader it is polymorphic, not
44/// which four.
45const MAX_END_LABELS: usize = 3;
46/// Rows the `who may see` recipe asks for — enough to see whether a role can
47/// see anything at all, few enough to be free.
48const ROLE_PROBE_ROWS: usize = 20;
49/// The threshold the `how many` recipe filters on. Three is the smallest
50/// count that reads as a pattern rather than a coincidence.
51const HOW_MANY_MIN: usize = 3;
52/// Edge types the `linked by all of` recipe intersects in one `MATCH`. Three
53/// is enough to demonstrate the shape — a fourth pattern would not teach a
54/// reader anything a third has not already shown, and every one past the
55/// first costs a `, (a)-[:TYPE]->(b)` the byte budget pays for.
56const LINKED_BY_ALL_MAX_TYPES: usize = 3;
57/// Property names that name a node rather than describe it, and so are never
58/// what a `what_if` is about. `id` is the identity prop Cypher `CREATE`
59/// writes; `key` is what the store calls the same thing.
60const IDENTITY_PROPS: [&str; 2] = ["id", "key"];
61/// Property names the schema listing does not print. `embedding` is the vector
62/// payload `hybrid_search` and `find_similar` read: hundreds of floats, never
63/// a question target, and naming it in a schema a session is meant to write
64/// queries from invites a query that returns a wall of numbers. The tools that
65/// use it do not need to be told it is there.
66const HIDDEN_PROPS: [&str; 1] = ["embedding"];
67
68/// Characters of the synced sha the brief prints — the usual abbreviation,
69/// and the same width [`render_map`](super::render_map) uses.
70const SHORT_SHA: usize = 7;
71/// Subdirectory names a file's role may be built from.
72const ROLE_TOKENS: usize = 2;
73/// What the brief may spend. The `SessionStart` hook has five seconds, and a
74/// store too large to describe inside three of them yields what it had reached
75/// — a partial ranking is still a valid ordering, partial counts are still
76/// lower bounds, and a partial brief is worth more at the start of a session
77/// than none.
78///
79/// Both surfaces are budgeted, and the memory one needs it more: its work is
80/// [`GraphDb::wal_total_commits`], which re-reads the WAL — seconds on a store
81/// nobody has snapshotted — plus two passes whose length is the store's.
82const RANK_BUDGET: Duration = Duration::from_secs(3);
83/// The edge types that make a file a candidate for the key-files list: the
84/// *structural* two of the three [`file_pagerank`] ranks over.
85///
86/// `CO_CHANGED` is deliberately not here. It says two files were edited in the
87/// same commits, which is true of every asset added in one go — a directory of
88/// fonts co-changes with itself ten ways and reads to PageRank as a small
89/// tightly-knit cluster. The brief orients an assistant on *code structure*, so
90/// a file qualifies only when something imports it or calls into it; a file
91/// related to the codebase by co-change alone is still reachable through
92/// `context`, `impact` and `why`, which are the tools that ask about it.
93const DEPENDENCY_EDGES: [&str; 2] = ["IMPORTS", "CALLS"];
94
95/// How much of each ranking the brief lists, and how long it may take.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct BriefOptions {
98    /// Files listed, most central first.
99    pub max_files: usize,
100    /// Symbols listed, most called first.
101    pub max_symbols: usize,
102    /// Wall-clock the whole brief may spend, [`RANK_BUDGET`] by default.
103    /// [`Duration::ZERO`] is a budget already spent — every count comes back
104    /// as the lower bound reached, which is what the tests use. A budget too
105    /// large to add to the clock is no budget at all.
106    pub budget: Duration,
107}
108
109impl Default for BriefOptions {
110    fn default() -> Self {
111        Self {
112            max_files: 25,
113            max_symbols: 25,
114            budget: RANK_BUDGET,
115        }
116    }
117}
118
119/// One label of a memory store's schema: what it is called, how many nodes
120/// carry it, and every property name any of them has.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct LabelBrief {
123    pub label: String,
124    pub nodes: usize,
125    /// The union of the property names across the label's nodes, sorted, cut
126    /// at [`MAX_LABEL_PROPS`] with `hidden` counting the rest.
127    pub props: Vec<String>,
128    pub hidden_props: usize,
129}
130
131/// One edge type of a memory store's schema: what derives it, what it runs
132/// between, and how many there are.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134pub struct EdgeTypeBrief {
135    pub edge_type: String,
136    /// The first rule that declares it, sorted. `None` for an edge type
137    /// written by hand.
138    pub rule: Option<String>,
139    /// How many further rules also derive it — two rules deriving one type is
140    /// normal, and naming one while implying it is the only one would be a
141    /// half-truth.
142    pub hidden_rules: usize,
143    /// The labels seen on each end, sorted, cut at [`MAX_END_LABELS`].
144    pub src: Vec<String>,
145    pub dst: Vec<String>,
146    pub edges: usize,
147}
148
149/// One question kind and the single call that answers it, with the store's
150/// own keys, labels and edge types already substituted in.
151///
152/// The point is that the call is *worked*: an assistant that copies the line
153/// reaches an answer without first probing the store for its schema, which is
154/// the round trip this section exists to remove.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156pub struct Recipe {
157    /// The question kind, as a reader would name it — `why`, `as of`.
158    pub question: String,
159    /// The call that answers it.
160    pub call: String,
161}
162
163/// What a memory store *is*: its labels, its edge types, how deep its history
164/// runs, who may read it, and the call that answers each kind of question.
165///
166/// Present on a store no repository was ingested into — the same
167/// `GitSync`-marker test the MCP server's tool listing splits on — and absent
168/// on a code graph, which is described by its rankings instead.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct SchemaBrief {
171    /// Live nodes, all labels together.
172    pub nodes: usize,
173    /// Labels, most populous first, ties on the name.
174    pub labels: Vec<LabelBrief>,
175    /// Edge types, most numerous first, ties on the name.
176    pub edge_types: Vec<EdgeTypeBrief>,
177    /// How many commits of history are still reachable — `total` above the
178    /// horizon floor. A *count*, not an index: the newest commit `edges_at`,
179    /// `node_history` and `was_linked` accept is one below it, which is what
180    /// the `as of` recipe names. `Some(0)` on a store whose WAL was truncated
181    /// and whose archives are gone, and then there is no `as of` recipe at all.
182    ///
183    /// `None` when the budget ran out before it could be counted: the scan
184    /// that answers it re-reads the WAL, so it is the first thing a spent
185    /// budget drops. Unknown and zero are different answers, which is why this
186    /// is an `Option` and not a zero.
187    pub commits: Option<u64>,
188    /// `(role name, the labels it may see)`, sorted by name.
189    pub roles: Vec<(String, Vec<String>)>,
190    /// One worked call per question kind, in a fixed order.
191    pub recipes: Vec<Recipe>,
192    /// The budget ran out while counting: every count above is a *lower
193    /// bound*, the listings may be short of entries, and `commits` is
194    /// `None`. Rendered, so a reader never mistakes a partial count for a
195    /// complete one.
196    pub partial: bool,
197}
198
199/// The repository, as a session starts.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201pub struct BriefReport {
202    /// The repository's own name: the last segment of the path the `GitSync`
203    /// marker records. Empty on a store no repository was ingested into.
204    pub repo: String,
205    pub files: usize,
206    pub symbols: usize,
207    pub edges: usize,
208    /// The abbreviated sha the store was synced to. `None` without a marker.
209    /// The sha and not an age: an age would change between two prompts of the
210    /// same session and the host caches this output.
211    pub last_sync: Option<String>,
212    /// `(path, role)`, most central first, ties on the path. The role is
213    /// empty whenever the path has already said everything there is to say.
214    pub key_files: Vec<(String, String)>,
215    /// `(key, first line of the signature)`, most called first, ties on the
216    /// key.
217    pub key_symbols: Vec<(String, String)>,
218    /// The schema, on a memory store. `None` on a code graph, whose two
219    /// rankings above are its description.
220    pub schema: Option<SchemaBrief>,
221}
222
223/// Summarise the store for the start of a session.
224///
225/// Deterministic for the same store state, with no `now` to pin: see the
226/// module docs for why this one tool reads no clock at all.
227///
228/// # Two surfaces
229///
230/// A store `ingest-git` built carries the `GitSync` marker and is described
231/// by the two rankings above. Any other store is a *memory* store, and the
232/// same marker is what the MCP server's `tools/list` splits on — so a store
233/// whose session is offered `explain_association` and `query` is exactly the
234/// store this describes with a [`SchemaBrief`] instead. Ranking a memory
235/// store by `IMPORTS` and `CALLS` would rank nothing; naming its labels, its
236/// edge types and one worked call per question kind is what spares the
237/// session from probing for them.
238#[must_use]
239pub fn brief<F: Fs>(db: &GraphDb<F>, opts: &BriefOptions) -> BriefReport {
240    // One deadline for the whole brief, taken before the first read, so the
241    // two surfaces cannot each spend the budget in turn.
242    let deadline = Instant::now().checked_add(opts.budget);
243
244    let mut file_keys: Vec<String> = db
245        .nodes_with_label("File")
246        .iter()
247        .map(|n| n.key().to_string())
248        .collect();
249    file_keys.sort();
250
251    if !db.has_node(SYNC_KEY) {
252        return BriefReport {
253            repo: String::new(),
254            files: file_keys.len(),
255            symbols: db.nodes_with_label("Symbol").len(),
256            edges: usize::try_from(db.edge_count()).unwrap_or(usize::MAX),
257            last_sync: None,
258            key_files: Vec::new(),
259            key_symbols: Vec::new(),
260            schema: Some(memory_schema(db, deadline)),
261        };
262    }
263
264    // `file_pagerank` returns the ranking already sorted the way every digest
265    // prints one — score first, ties on the key — so the brief takes its head.
266    // Cut short by the budget it is a partial ranking, which is still an
267    // ordering; the brief has no "(truncated)" to report and does not pretend
268    // otherwise.
269    let (ranked, _truncated) = file_pagerank(db, &file_keys, deadline);
270
271    // The ranking alone fills the list with a repository's assets. A file
272    // nothing imports has no rank of its own, so PageRank leaves it on the
273    // uniform teleport mass and the tie breaks alphabetically; a directory of
274    // fonts added in one commit does better still, since co-change makes it a
275    // small tightly-knit cluster passing rank around inside itself. `map` only
276    // ever showed five entries, too few for either to surface — twenty-five is
277    // not. So the ranking says what *order* the files come in and
278    // [`DEPENDENCY_EDGES`] says which are eligible at all, and listing fewer
279    // files beats listing files whose structure the graph knows nothing about.
280    let connected = connected_files(db);
281    let key_files = ranked
282        .iter()
283        .filter(|(k, _)| connected.contains(k.as_str()))
284        .take(opts.max_files)
285        .map(|(k, _)| (sanitize(k), role_of(db, k, &file_keys)))
286        .collect();
287
288    // Who calls whom, counted once: a symbol many others call is one a reader
289    // will meet whichever thread they pull.
290    let mut callers: BTreeMap<String, usize> = BTreeMap::new();
291    for (_src, dst, _w) in db.weighted_edges("CALLS", None) {
292        *callers.entry(dst).or_default() += 1;
293    }
294    let symbols = db.nodes_with_label("Symbol");
295    let mut ranked_symbols: Vec<(String, usize, String)> = symbols
296        .iter()
297        .map(|n| {
298            let key = n.key().to_string();
299            let called = callers.get(&key).copied().unwrap_or(0);
300            (key, called, first_line(n.prop("signature")))
301        })
302        .collect();
303    ranked_symbols.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
304    let key_symbols = ranked_symbols
305        .into_iter()
306        .take(opts.max_symbols)
307        .map(|(k, _, sig)| (sanitize(&k), sig))
308        .collect();
309
310    BriefReport {
311        repo: str_prop(db, SYNC_KEY, "repo")
312            .map(|p| sanitize(basename(p.trim_end_matches('/'))))
313            .unwrap_or_default(),
314        files: file_keys.len(),
315        symbols: symbols.len(),
316        edges: usize::try_from(db.edge_count()).unwrap_or(usize::MAX),
317        last_sync: str_prop(db, SYNC_KEY, "sha")
318            .map(|s| sanitize(&s).chars().take(SHORT_SHA).collect()),
319        key_files,
320        key_symbols,
321        schema: None,
322    }
323}
324
325/// What a memory store is, in two passes and no more.
326///
327/// # Why neither pass is per edge
328///
329/// The counts here are per *label* and per *edge type*, and there are two ways
330/// to get them expensively. One is to ask the store about each edge in turn —
331/// `explain` per edge is a rule evaluation per edge. The other is to
332/// materialise every edge first: [`GraphDb::all_edges_for_export`] gives the
333/// rule names, the ends and the counts in one sweep, but it pays three
334/// `String`s and a provenance entry per edge to do it, which on the 1.3 M-edge
335/// association store is seven seconds and hundreds of megabytes spent to print
336/// nine lines.
337///
338/// So edges go through [`GraphDb::edge_type_census`], which walks the topology
339/// and sums neighbour slice lengths without building a record per edge, and
340/// nodes through [`GraphDb::all_nodes_for_export`], which is one record per
341/// node — on a memory store there are thousands of those, not millions.
342///
343/// Both come back sorted, and the census's sample edge is the first of its
344/// type in the store's own id order, so the worked calls name the same keys on
345/// every run.
346///
347/// # The budget
348///
349/// Neither pass is bounded by anything but the store, and the history count
350/// after them re-reads the WAL. `deadline` is checked inside both loops and
351/// before the WAL scan, so what a spent budget costs is *completeness*, not
352/// the brief: the counts reached become lower bounds, the history goes
353/// uncounted, and the `as of` recipe — which needs a commit index the scan
354/// would have supplied — is not shown at all rather than shown wrong.
355fn memory_schema<F: Fs>(db: &GraphDb<F>, deadline: Option<Instant>) -> SchemaBrief {
356    let nodes = db.all_nodes_for_export();
357    let mut partial = false;
358
359    // Pass one: label → how many nodes, and every property name any of them
360    // has. `props` is a `BTreeMap`, so the union arrives sorted.
361    let mut by_label: BTreeMap<String, (usize, BTreeSet<String>)> = BTreeMap::new();
362    let mut counted = 0usize;
363    for n in &nodes {
364        if spent(deadline) {
365            partial = true;
366            break;
367        }
368        counted += 1;
369        let entry = by_label.entry(n.label.clone()).or_default();
370        entry.0 += 1;
371        entry.1.extend(
372            n.props
373                .keys()
374                .filter(|p| !HIDDEN_PROPS.contains(&p.as_str()))
375                .cloned(),
376        );
377    }
378
379    // Pass two: the per-type census, keyed for the recipes to read back.
380    let mut by_type: BTreeMap<String, EdgeTypeCensus> = BTreeMap::new();
381    if spent(deadline) {
382        partial = true;
383    } else {
384        for c in db.edge_type_census() {
385            if spent(deadline) {
386                partial = true;
387                break;
388            }
389            by_type.insert(c.edge_type.clone(), c);
390        }
391    }
392
393    let mut labels: Vec<LabelBrief> = by_label
394        .iter()
395        .map(|(label, (count, props))| {
396            let all: Vec<String> = props.iter().map(|p| sanitize(p)).collect();
397            let hidden = all.len().saturating_sub(MAX_LABEL_PROPS);
398            LabelBrief {
399                label: sanitize(label),
400                nodes: *count,
401                props: all.into_iter().take(MAX_LABEL_PROPS).collect(),
402                hidden_props: hidden,
403            }
404        })
405        .collect();
406    labels.sort_by(|a, b| b.nodes.cmp(&a.nodes).then(a.label.cmp(&b.label)));
407
408    let mut edge_types: Vec<EdgeTypeBrief> = by_type
409        .values()
410        .map(|c| EdgeTypeBrief {
411            edge_type: sanitize(&c.edge_type),
412            rule: c.rules.first().map(|r| sanitize(r)),
413            hidden_rules: c.rules.len().saturating_sub(1),
414            src: ends(&c.src_labels),
415            dst: ends(&c.dst_labels),
416            edges: usize::try_from(c.edges).unwrap_or(usize::MAX),
417        })
418        .collect();
419    edge_types.sort_by(|a, b| b.edges.cmp(&a.edges).then(a.edge_type.cmp(&b.edge_type)));
420
421    let mut roles: Vec<(String, Vec<String>)> = db
422        .roles()
423        .into_iter()
424        .map(|r| {
425            (
426                sanitize(&r.name),
427                r.labels.iter().map(|l| sanitize(l)).collect(),
428            )
429        })
430        .collect();
431    roles.sort();
432
433    // Which property each rule actually reads, per label it reads it on. The
434    // `what_if` recipe is only worth copying when it names a field some rule
435    // has an opinion about: changing a node's display name loses and gains
436    // nothing, and a recipe that demonstrates nothing teaches nothing.
437    let mut rule_fields: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
438    for rule in db.rules() {
439        let mut fields = BTreeSet::new();
440        predicate_fields(&rule.predicate, &mut fields);
441        for label in [&rule.src_label, &rule.dst_label] {
442            rule_fields
443                .entry(label.clone())
444                .or_default()
445                .extend(fields.iter().cloned());
446        }
447    }
448
449    // Two different numbers, and the difference is the whole point. The
450    // history line reports how many commits the store has replayed, which is
451    // what a reader wants to know about its depth. The `edges_at` recipe needs
452    // an *index*, and `wal_total_commits` is the only thing that knows the top
453    // of that range — `commit_seq` counts replayed frames and is seeded from
454    // the snapshot's sequence numbers, so it is neither the count nor the
455    // index on a store that has ever been snapshotted.
456    //
457    // `wal_total_commits` re-reads the WAL, which is not free; it is bought
458    // once here rather than paid for by a session that copies a broken call.
459    // `edges_at` itself pays the same scan, so a brief that can afford to name
460    // the call can afford to have checked it.
461    // A WAL-truncating snapshot that has outlived its archives leaves a store
462    // with no reachable history at all: `floor == total`, an empty range, and
463    // every commit index out of it. There is no `as of` call to show, so the
464    // brief shows none — a recipe that cannot answer is worse than a missing
465    // one, since the session that copies it learns the tool is broken.
466    //
467    // And it is the first thing the budget drops: on a store nobody has
468    // snapshotted the scan is seconds on its own, which is the whole of the
469    // hook's five. Uncounted history renders as `unknown` and takes the `as
470    // of` recipe with it — a recipe whose commit index was guessed is the one
471    // failure a recipe must not have.
472    let (commits, latest_commit) = if spent(deadline) {
473        partial = true;
474        (None, None)
475    } else {
476        let floor = db.wal_horizon_floor();
477        let total = db.wal_total_commits().unwrap_or(floor);
478        (
479            Some(total.saturating_sub(floor)),
480            (total > floor).then(|| total - 1),
481        )
482    };
483    let recipes = recipes(
484        &nodes,
485        &by_type,
486        &labels,
487        &edge_types,
488        &roles,
489        &rule_fields,
490        latest_commit,
491    );
492
493    SchemaBrief {
494        // What was actually counted, so the number the brief prints is a
495        // lower bound the pass reached rather than a total it never read.
496        nodes: counted,
497        labels,
498        edge_types,
499        commits,
500        roles,
501        recipes,
502        partial,
503    }
504}
505
506/// Every property name a predicate reads, its branches included.
507fn predicate_fields(p: &core_rules::Predicate, out: &mut BTreeSet<String>) {
508    use core_rules::Predicate as P;
509    match p {
510        P::KeyMatch { field }
511        | P::FieldEqual { field }
512        | P::Overlap { field, .. }
513        | P::NumericWithin { field, .. }
514        | P::GeoRadius { field, .. }
515        | P::VectorSimilar { field, .. } => {
516            out.insert(field.clone());
517        }
518        P::All(parts) | P::Any(parts) => {
519            for part in parts {
520                predicate_fields(part, out);
521            }
522        }
523    }
524}
525
526/// The labels on one end of an edge type, cut at [`MAX_END_LABELS`].
527fn ends(labels: &[String]) -> Vec<String> {
528    labels
529        .iter()
530        .take(MAX_END_LABELS)
531        .map(|l| sanitize(l))
532        .collect()
533}
534
535/// One worked call per question kind, in the order a session meets them.
536///
537/// Every placeholder the store can fill is filled: a pair a rule actually
538/// derived for `explain_association`, a key that has edges for `node_edges`,
539/// a commit `edges_at` accepts, a property that key really carries for
540/// `what_if`, a role out of `roles.json`, and the store's own labels and edge
541/// type in the counting template. What the store cannot supply — the *new*
542/// value in a `what_if` — stays an angle-bracketed placeholder rather than an
543/// invention.
544///
545/// # The keys come back through `key(n)`
546///
547/// A node's key is not a property, so `RETURN n.key` parses, runs, and answers
548/// a column of nulls. `key(n)` is the function that returns it. Same failure
549/// mode as the commit below: a line that reads like a call and answers
550/// nothing.
551///
552/// In the counting recipe the key is taken in the `WITH` rather than the
553/// `RETURN`, because after a grouping `WITH` the variable is a projected
554/// scalar and no longer a node: `key(b)` in the `RETURN` errors there.
555///
556/// # The commit is a commit, not a count
557///
558/// `history: N commits` counts; `edges_at`'s `at` is a zero-based WAL index
559/// whose range is `wal_horizon_floor..total_commits`. Substituting the count
560/// names one past the end, and the worked example answers `CommitOutOfRange`
561/// on every store there has ever been — the worst thing a recipe can do, since
562/// a session that copies it learns the tool is broken. So the recipe gets
563/// `latest_commit`, the newest index the store will accept.
564fn recipes(
565    nodes: &[crate::db::NodeInfo],
566    by_type: &BTreeMap<String, EdgeTypeCensus>,
567    labels: &[LabelBrief],
568    edge_types: &[EdgeTypeBrief],
569    roles: &[(String, Vec<String>)],
570    rule_fields: &BTreeMap<String, BTreeSet<String>>,
571    latest_commit: Option<u64>,
572) -> Vec<Recipe> {
573    // The pair to explain: prefer a type some rule derives, since that is the
574    // pair `explain_association` can name a predicate for. `edge_types` is
575    // already sorted most-numerous-first, so this is the busiest such type.
576    let sample_of = |t: &EdgeTypeBrief| by_type.get(&t.edge_type).and_then(|c| c.sample.clone());
577    let pair = edge_types
578        .iter()
579        .filter(|t| t.rule.is_some())
580        .find_map(sample_of)
581        .or_else(|| edge_types.iter().find_map(sample_of));
582    let (a, b) = match &pair {
583        Some((a, b)) => (sanitize(a), sanitize(b)),
584        None => ("<a>".to_string(), "<b>".to_string()),
585    };
586    // The key to ask about: the source of that pair, which is known to have
587    // edges. Failing any edge at all, the store's first key.
588    let key = match &pair {
589        Some((a, _)) => sanitize(a),
590        None => nodes
591            .first()
592            .map_or_else(|| "<key>".to_string(), |n| sanitize(&n.key)),
593    };
594    // A property that key really carries, and — where the store has a rule
595    // reading one — a property some rule reads, so the `what_if` shown is one
596    // that would actually lose and gain edges. A `what_if` on a display name
597    // is a demonstration of nothing.
598    //
599    // `id` and `key` are skipped either way: both name the node rather than
600    // describe it, and changing a node's name is `rename_node`, not a question
601    // about what its relationships would become. [`HIDDEN_PROPS`] goes with
602    // them: `what_if key embedding <value>` asks the session to type out a
603    // vector.
604    //
605    // The node itself is kept as `key_node` rather than looked up twice: the
606    // intersection below reads its label, the same node the field is read
607    // from.
608    let key_node = nodes.iter().find(|n| n.key == key);
609    let field = key_node
610        .and_then(|n| {
611            let watched = rule_fields.get(&n.label);
612            let mut usable = n.props.keys().filter(|f| {
613                !IDENTITY_PROPS.contains(&f.as_str()) && !HIDDEN_PROPS.contains(&f.as_str())
614            });
615            usable
616                .clone()
617                .find(|f| watched.is_some_and(|w| w.contains(*f)))
618                .or_else(|| usable.next())
619        })
620        .map_or_else(|| "<field>".to_string(), |f| sanitize(f));
621    // The same intersection `linked_by_all_recipe` runs for the store's
622    // busiest source label, run here for the label of the key already
623    // picked above — up to [`LINKED_BY_ALL_MAX_TYPES`] edge types shared
624    // between that label and the one target label they most often reach
625    // together. `edges_at`, `node_edges` and `what_if` all accept `all_of` /
626    // `edge_type`, so the same selection teaches the one-call intersection
627    // form on every recipe that names this key, not only on the one recipe
628    // that happens to demonstrate a `MATCH`.
629    let intersection = key_node.and_then(|n| types_from(&n.label, edge_types));
630    // The role and the label it probes have to be chosen *together*. Picked
631    // independently — the first role, the most populous label — the
632    // association store rendered `MATCH (n:Talent) … role: client`, and
633    // `client` reads only `Company` and `Job`: zero rows, and the session that
634    // copies the line learns the tool is broken. So walk the roles in the
635    // order they are printed and take the first that can see any label at all,
636    // probing the busiest label it can see (`labels` is already sorted most
637    // populous first). A store whose roles name no label the brief lists —
638    // roles scoped to individual keys, or no roles at all — falls back to the
639    // first role and the busiest label, which is as much as can be said.
640    let (role, probe_label) = roles
641        .iter()
642        .find_map(|(name, visible)| {
643            labels
644                .iter()
645                .find(|l| visible.contains(&l.label))
646                .map(|l| (name.clone(), l.label.clone()))
647        })
648        .unwrap_or_else(|| {
649            (
650                roles
651                    .first()
652                    .map_or_else(|| "<name>".to_string(), |(n, _)| n.clone()),
653                labels
654                    .first()
655                    .map_or_else(|| "<label>".to_string(), |l| l.label.clone()),
656            )
657        });
658    // The counting template. The busiest edge type, with the labels it was
659    // actually seen between.
660    let (l1, etype, l2) = edge_types.first().map_or_else(
661        || ("<L1>".to_string(), "<TYPE>".to_string(), "<L2>".to_string()),
662        |t| {
663            (
664                t.src.first().cloned().unwrap_or_else(|| "<L1>".to_string()),
665                t.edge_type.clone(),
666                t.dst.first().cloned().unwrap_or_else(|| "<L2>".to_string()),
667            )
668        },
669    );
670
671    let mut out = vec![
672        Recipe {
673            question: "why".to_string(),
674            call: format!(
675                "explain_association {a} {b} — returns each relationship's rule and \
676                 the values the two share, so there is no need to fetch raw lists to \
677                 compare by hand"
678            ),
679        },
680        Recipe {
681            question: "relationships".to_string(),
682            call: relationships_call(&key, &intersection),
683        },
684    ];
685    if let Some(at) = latest_commit {
686        out.push(Recipe {
687            question: "as of".to_string(),
688            call: as_of_call(&key, at, &intersection),
689        });
690    }
691    out.extend([
692        Recipe {
693            question: "what if".to_string(),
694            call: what_if_call(&key, &field, &intersection),
695        },
696        Recipe {
697            question: "who may see".to_string(),
698            call: format!(
699                "query 'MATCH (n:{probe_label}) RETURN key(n) LIMIT {ROLE_PROBE_ROWS}' role: {role}"
700            ),
701        },
702        Recipe {
703            question: "how many".to_string(),
704            call: format!(
705                "MATCH (a:{l1})-[:{etype}]->(b:{l2}) WITH key(b) AS b_key, count(a) AS n \
706                 WHERE n >= {HOW_MANY_MIN} RETURN b_key, n"
707            ),
708        },
709    ]);
710    // The seventh recipe: "linked by all of" — the multi-hop intersection a
711    // benchmark showed agents fail, chaining one `MATCH` per relation with
712    // fresh variables and so counting each relation independently instead of
713    // requiring all of them at once. One `MATCH` with comma-separated
714    // patterns sharing `a` and `b` is the shape that actually intersects.
715    //
716    // Omitted on a store with only one edge type in it altogether: there is
717    // nothing to intersect, and a recipe of one pattern would demonstrate the
718    // wrong thing.
719    if edge_types.len() > 1 {
720        if let Some(recipe) = linked_by_all_recipe(labels, edge_types) {
721            out.push(recipe);
722        }
723    }
724    out
725}
726
727/// The "linked by all of" recipe: up to [`LINKED_BY_ALL_MAX_TYPES`] edge
728/// types run between the store's own busiest source label and the
729/// destination label it most commonly reaches, intersected in one `MATCH`
730/// rather than chained across several.
731///
732/// The pair is picked from the store's own schema, not asked for: the most
733/// populous label that is ever a source (`labels` is already sorted most
734/// populous first), then the destination label its edges most often land on,
735/// weighted by how many edges each type carries. With fewer than
736/// [`LINKED_BY_ALL_MAX_TYPES`] edge types actually running between that
737/// pair, the recipe still renders — one pattern is still a worked call, even
738/// though there is nothing yet to intersect it against.
739///
740/// `None` only when no label is ever a source, which does not happen once
741/// `edge_types` is non-empty — every edge type's `src` came from a real
742/// source label — but the search stays an `Option` rather than assume it.
743fn linked_by_all_recipe(labels: &[LabelBrief], edge_types: &[EdgeTypeBrief]) -> Option<Recipe> {
744    let src = &labels
745        .iter()
746        .find(|l| edge_types.iter().any(|t| t.src.contains(&l.label)))?
747        .label;
748    let (types, dst) = types_from(src, edge_types)?;
749
750    let mut pattern = format!("(a:{src})-[:{}]->(b:{dst})", types[0]);
751    for t in &types[1..] {
752        pattern.push_str(&format!(", (a)-[:{t}]->(b)"));
753    }
754
755    Some(Recipe {
756        question: "linked by all of".to_string(),
757        call: format!(
758            "MATCH {pattern} WITH b, count(DISTINCT a) AS n WHERE n >= 1 RETURN key(b), n \
759             ORDER BY n DESC LIMIT 20 — add `WHERE a.<field> = …` before WITH to filter \
760             the source side; one MATCH with comma-separated patterns intersects, \
761             separate MATCHes do not"
762        ),
763    })
764}
765
766/// Up to [`LINKED_BY_ALL_MAX_TYPES`] edge types running from `src`, and the
767/// one destination label they most often reach together — the selection
768/// [`linked_by_all_recipe`] runs for the store's own busiest source label,
769/// factored out so [`recipes`] can run the identical census-based pick for
770/// the source label of whatever key it has already chosen.
771///
772/// Weighted by how many edges each type carries, ties on the destination
773/// label's name; `edge_types` is already sorted most-numerous-first, ties on
774/// the name, so filtering it keeps that order — "most populous first" among
775/// the types that actually connect `src` to the label picked.
776///
777/// `None` when `src` is never a source at all — the only way for there to be
778/// no destination label to weigh.
779fn types_from(src: &str, edge_types: &[EdgeTypeBrief]) -> Option<(Vec<String>, String)> {
780    let mut by_dst: BTreeMap<&str, usize> = BTreeMap::new();
781    for t in edge_types.iter().filter(|t| t.src.iter().any(|s| s == src)) {
782        for dst in &t.dst {
783            *by_dst.entry(dst.as_str()).or_default() += t.edges;
784        }
785    }
786    let (dst, _) = by_dst
787        .into_iter()
788        .max_by_key(|(name, n)| (*n, std::cmp::Reverse(*name)))?;
789
790    let types: Vec<String> = edge_types
791        .iter()
792        .filter(|t| t.src.iter().any(|s| s == src) && t.dst.iter().any(|d| d.as_str() == dst))
793        .take(LINKED_BY_ALL_MAX_TYPES)
794        .map(|t| t.edge_type.clone())
795        .collect();
796    (!types.is_empty()).then(|| (types, dst.to_string()))
797}
798
799/// The `relationships` recipe: `node_edges` with the intersection the key's
800/// own source label supports, when there is one.
801///
802/// `all_of` even for a single type — the reply is the same partner-keys
803/// shape either way, and the note names the `edge_type` shortcut rather than
804/// the call switching form for it. Falls back to the plain call when the key
805/// has no [`types_from`] selection at all (a key that is never a source, or
806/// a store with no edge types).
807fn relationships_call(key: &str, intersection: &Option<(Vec<String>, String)>) -> String {
808    match intersection {
809        Some((types, dst)) => format!(
810            "node_edges {key} all_of: [{}] label: {dst} — or edge_type: {} for one \
811             type's partner keys",
812            types.join(", "),
813            types[0]
814        ),
815        None => format!("node_edges {key}"),
816    }
817}
818
819/// The `as of` recipe: `edges_at` with the same intersection, at commit `at`.
820///
821/// Unlike [`relationships_call`], a single type switches the call itself to
822/// `edge_type` rather than an `all_of` of one.
823///
824/// # The note is about where `at` comes from
825///
826/// The first association run lost two time-travel cells the same way: the
827/// agent had the right data and still answered from an arbitrary late commit,
828/// because the question named a *date* and `at` is a commit index. Nothing in
829/// a commit carries a date, so guessing one from the end of the WAL is the
830/// failure this note exists to stop — the two history tools are where a date
831/// turns into a commit number. That is worth more here than the `all_of`
832/// explanation the note used to carry, which `relationships_call` already
833/// gives on the line above.
834fn as_of_call(key: &str, at: u64, intersection: &Option<(Vec<String>, String)>) -> String {
835    let note = "— commits carry no dates: take `at` from node_history/edge_history \
836                commit numbers or the dataset's date→commit map";
837    match intersection {
838        Some((types, dst)) if types.len() >= 2 => format!(
839            "edges_at {key} {at} all_of: [{}] label: {dst} {note}",
840            types.join(", ")
841        ),
842        Some((types, _)) => format!("edges_at {key} {at} edge_type: {} {note}", types[0]),
843        None => format!("edges_at {key} {at}"),
844    }
845}
846
847/// The `what if` recipe: `what_if` with the busiest type from the same
848/// intersection, so the shown call also demonstrates narrowing to one type's
849/// partner keys.
850fn what_if_call(key: &str, field: &str, intersection: &Option<(Vec<String>, String)>) -> String {
851    match intersection {
852        Some((types, _)) => format!(
853            "what_if {key} {field} <value> edge_type: {} — the partners that would be \
854             lost or gained under that type",
855            types[0]
856        ),
857        None => format!("what_if {key} {field} <value>"),
858    }
859}
860
861/// Every file the graph records a [`DEPENDENCY_EDGES`] edge for, in either
862/// direction — the files something imports or calls into.
863///
864/// `IMPORTS` names files directly; `CALLS` runs between symbols, so both of its
865/// endpoints are read back to the file that defines them, the same projection
866/// [`file_pagerank`] does. A call inside one file counts: it is still the graph
867/// knowing that file's structure, which is what this set is asked about, even
868/// though the ranking drops it as a self-loop.
869///
870/// Keys that are not files come back too (an `IMPORTS` edge to a path the
871/// store has no node for, say); the caller only ever asks about file keys, so
872/// they cost a lookup and change nothing.
873fn connected_files<F: Fs>(db: &GraphDb<F>) -> BTreeSet<String> {
874    let mut sym_file: BTreeMap<String, String> = BTreeMap::new();
875    for node in db.nodes_with_label("Symbol") {
876        if let Some(Value::Str(file)) = node.prop("file_id") {
877            sym_file.insert(node.key().to_string(), file);
878        }
879    }
880    let mut out = BTreeSet::new();
881    for edge_type in DEPENDENCY_EDGES {
882        for (src, dst, _w) in db.weighted_edges(edge_type, None) {
883            for end in [src, dst] {
884                match sym_file.get(&end) {
885                    Some(file) => out.insert(file.clone()),
886                    None => out.insert(end),
887                };
888            }
889        }
890    }
891    out
892}
893
894/// The first line of a signature prop, or nothing for a node without one.
895///
896/// One line, because a multi-line signature would forge a section heading in
897/// a digest that is read as lines — [`sanitize`] would flatten the break, but
898/// the rest of the signature would still be spliced into someone else's line.
899fn first_line(v: Option<Value>) -> String {
900    match v {
901        Some(Value::Str(s)) => sanitize(s.lines().next().unwrap_or_default().trim()),
902        _ => String::new(),
903    }
904}
905
906/// What a file is, in a few words.
907///
908/// Its `role` prop when the graph carries one. Otherwise what its directory is
909/// made of: the subdirectory names most of its neighbours sit in, which is the
910/// part of the directory's cluster name the path printed beside it does not
911/// already show. A file in a leaf directory therefore has no role, and the
912/// brief spends no bytes repeating its own path back at the reader.
913fn role_of<F: Fs>(db: &GraphDb<F>, key: &str, file_keys: &[String]) -> String {
914    if let Some(role) = str_prop(db, key, "role") {
915        let role = sanitize(role.trim());
916        if !role.is_empty() {
917            return role;
918        }
919    }
920    let dir = dir_components(key).join("/");
921    if dir.is_empty() {
922        return String::new(); // a file at the root is under nothing
923    }
924    let prefix = format!("{dir}/");
925    let neighbours: Vec<String> = file_keys
926        .iter()
927        .filter(|k| k.starts_with(&prefix))
928        .cloned()
929        .collect();
930    sanitize(&top_tokens(&neighbours, &dir, ROLE_TOKENS, true).join(", "))
931}