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}