Skip to main content

core_api/repograph/
map.rs

1//! `map` — the whole repository in one screen.
2//!
3//! What a person new to a codebase asks first: how big is it, what are its
4//! parts, which files does everything else lean on, who knows them, and what
5//! has moved lately. Every answer is computed from the graph `ingest-git`
6//! wrote; nothing here reads the working tree, and the only clock it reads is
7//! the one behind "synced 3h ago" (see *Time* below).
8//!
9//! # How each answer is found
10//!
11//! | Section | From |
12//! |---|---|
13//! | clusters | Louvain over `CO_CHANGED` (weight `score`, ≥ 0.3) ∪ `IMPORTS` (1.0), members labelled `File` |
14//! | key files | PageRank over `IMPORTS` ∪ `CO_CHANGED` ∪ `CALLS`, the last projected onto files by `Symbol.file_id` |
15//! | owners | `TOP_AUTHOR` in-degree, printed as `Author.name` |
16//! | hot | files a commit inside the window touched, by `TOUCHED` |
17//! | stale concepts | a `Concept` whose `source_hashes` no longer match its `source_files` |
18//!
19//! # Time
20//!
21//! Two clocks, for two different questions.
22//!
23//! *Which files are hot* is a question about the store, so it is measured
24//! against the newest `Commit.ts` — the answer then depends on nothing but the
25//! data, and two runs against an unchanged store agree.
26//!
27//! *How stale is the graph* is a question about the present, so it is measured
28//! against the wall clock and the marker's `synced_at`, which `ingest-git`
29//! stamps whenever it takes new data. Reading `Commit.ts` here would be
30//! useless: on a store synced to its repository's head the newest commit *is*
31//! the sync point, so the age would always be `0s`.
32//!
33//! [`MapOptions::now_ts`] overrides both, which is how a test pins the output.
34//! Determinism therefore means byte-identical for the same store *and* the
35//! same `now_ts`; without one, only the sync age moves.
36//!
37//! # Budget
38//!
39//! [`MapOptions::budget_ms`] is checked once before each phase, and passed on
40//! to Louvain, which checks it per sweep. When it fires the phases that have
41//! not run are skipped and `truncated` is set, which the rendered digest
42//! reports as `(truncated)`.
43
44use crate::algo::LouvainConfig;
45use crate::db::GraphDb;
46use crate::repograph::facts::{rank, str_prop};
47use crate::repograph::render::{basename, cluster_name, common_dir_prefix, sanitize};
48use core_storage::fs::Fs;
49use core_storage::Value;
50use serde::Serialize;
51use std::collections::{BTreeMap, BTreeSet};
52use std::time::{Duration, Instant};
53
54/// Key of the singleton marker `ingest-git` writes the synced sha on.
55pub(super) const SYNC_KEY: &str = "__mushroomdb_git_sync__";
56/// Marker prop holding when the store last took new data, in Unix seconds.
57/// Absent on a store built before it existed.
58const SYNCED_AT: &str = "synced_at";
59/// A `CO_CHANGED` edge below this score is too weak to shape a cluster.
60const CO_CHANGED_MIN_WEIGHT: f64 = 0.3;
61/// Most entries any one-line section prints.
62const MAX_KEY_FILES: usize = 5;
63const MAX_OWNERS: usize = 5;
64const MAX_HOT: usize = 5;
65/// A cluster of one file names nothing; the smallest useful group is a pair.
66const MIN_CLUSTER: usize = 2;
67/// PageRank parameters, matching [`crate::algo::PageRankConfig`]'s defaults.
68const DAMPING: f64 = 0.85;
69const MAX_ITERS: u32 = 50;
70const TOL: f64 = 1e-6;
71const SECS_PER_DAY: i64 = 86_400;
72
73/// What [`repo_map`] is allowed to spend and how much it may print.
74#[derive(Debug, Clone, PartialEq)]
75pub struct MapOptions {
76    /// Clusters listed, largest first.
77    pub max_communities: usize,
78    /// Files named as examples inside each cluster.
79    pub max_samples: usize,
80    /// Width of the "hot" window, in days back from now.
81    pub hot_days: i64,
82    /// Wall-clock budget in milliseconds. `0` means no budget.
83    pub budget_ms: u64,
84    /// Treat this Unix timestamp as now, for both the hot window and the sync
85    /// age. Without it the window falls back to the newest `Commit.ts` and the
86    /// sync age to the wall clock. Set it to pin the whole output.
87    pub now_ts: Option<i64>,
88}
89
90impl Default for MapOptions {
91    fn default() -> Self {
92        Self {
93            max_communities: 8,
94            max_samples: 3,
95            hot_days: 90,
96            budget_ms: 3_000,
97            now_ts: None,
98        }
99    }
100}
101
102/// How current the graph is: the sha it was synced to, and how long ago that
103/// sync ran.
104#[derive(Debug, Clone, PartialEq, Serialize)]
105pub struct SyncInfo {
106    /// The full sha recorded on the `GitSync` marker.
107    pub sha: String,
108    /// The marker's `synced_at`: Unix seconds at which this store last took
109    /// new data from the repository. `None` on a store written before the
110    /// marker carried one.
111    pub synced_at: Option<i64>,
112    /// Seconds between `synced_at` and now. `None` whenever `synced_at` is,
113    /// and the digest then reports the sha without an age.
114    pub age_secs: Option<i64>,
115}
116
117/// One group of files that change and import together.
118#[derive(Debug, Clone, PartialEq, Serialize)]
119pub struct MapCommunity {
120    /// The directory its members share, followed by the subdirectories most
121    /// of them sit in — or `<mixed>` when they share no directory at all.
122    pub name: String,
123    /// Just the directory every member is under. Empty when there is none,
124    /// which is the machine-readable form of a `<mixed>` name.
125    pub dir: String,
126    /// Members in the cluster, of which `samples` names a few.
127    pub size: usize,
128    /// Share of the cluster's edge weight that stays inside it, `0.0..=1.0`.
129    pub cohesion: f64,
130    /// The most depended-on members, highest first. Full keys.
131    pub samples: Vec<String>,
132}
133
134/// The repository, summarised.
135#[derive(Debug, Clone, PartialEq, Serialize)]
136pub struct RepoMap {
137    pub files: usize,
138    pub symbols: usize,
139    pub commits: usize,
140    pub authors: usize,
141    /// Absent when the store carries no `GitSync` marker.
142    pub last_sync: Option<SyncInfo>,
143    pub communities: Vec<MapCommunity>,
144    /// `(file key, PageRank score)`, highest first.
145    pub key_files: Vec<(String, f64)>,
146    /// `(author name, files owned)`, most first.
147    pub owners: Vec<(String, usize)>,
148    /// `(file key, commits inside the window)`, most first.
149    pub hot_files: Vec<(String, usize)>,
150    /// Width of the hot window in days, so a reader knows what "hot" meant.
151    pub hot_days: i64,
152    /// Concepts whose sources changed since they were learned.
153    pub stale_concepts: usize,
154    /// Three questions this graph can answer well, phrased for asking.
155    pub questions: Vec<String>,
156    /// The budget fired: some sections are missing or partial.
157    pub truncated: bool,
158}
159
160/// Whether the deadline has passed. `None` is a run with no budget.
161pub(super) fn spent(deadline: Option<Instant>) -> bool {
162    deadline.is_some_and(|dl| Instant::now() >= dl)
163}
164
165/// Wall-clock seconds since the Unix epoch. The only clock this module reads,
166/// and only for the sync age — never for anything that decides content.
167fn now_unix() -> i64 {
168    std::time::SystemTime::now()
169        .duration_since(std::time::UNIX_EPOCH)
170        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
171}
172
173/// Milliseconds left, floored at 1 so a budgeted call never reads as
174/// unbudgeted. `None` in, `0` out: no budget either way.
175fn remaining_ms(deadline: Option<Instant>) -> u64 {
176    match deadline {
177        None => 0,
178        Some(dl) => u64::try_from(dl.saturating_duration_since(Instant::now()).as_millis())
179            .unwrap_or(u64::MAX)
180            .max(1),
181    }
182}
183
184/// Summarise the repository the store was built from.
185///
186/// Deterministic for the same store state *and* the same
187/// [`MapOptions::now_ts`]: every collection is sorted, ties break on the key,
188/// and every section but one is decided by the graph alone. The exception is
189/// [`SyncInfo::age_secs`], which without a `now_ts` is measured against the
190/// system clock and so moves between runs. See the module docs for what each
191/// section means and why that one reads a clock.
192#[must_use]
193pub fn repo_map<F: Fs>(db: &GraphDb<F>, opts: &MapOptions) -> RepoMap {
194    let deadline =
195        (opts.budget_ms > 0).then(|| Instant::now() + Duration::from_millis(opts.budget_ms));
196    let mut truncated = false;
197
198    let mut file_keys: Vec<String> = db
199        .nodes_with_label("File")
200        .iter()
201        .map(|n| n.key().to_string())
202        .collect();
203    file_keys.sort();
204    let files = file_keys.len();
205    let symbols = db.nodes_with_label("Symbol").len();
206    let authors = db.nodes_with_label("Author").len();
207
208    // Commit timestamps, read once: both the hot window and "now" need them.
209    let mut commit_ts: BTreeMap<String, i64> = BTreeMap::new();
210    for n in db.nodes_with_label("Commit") {
211        if let Some(Value::Int(ts)) = n.prop("ts") {
212            commit_ts.insert(n.key().to_string(), ts);
213        }
214    }
215    let commits = db.nodes_with_label("Commit").len();
216    // Two clocks, deliberately. The hot window is measured against the newest
217    // commit, so which files count as hot depends only on the store. The sync
218    // age is measured against the wall clock, because "how stale is my graph"
219    // is a question about the present — and `now_ts` overrides it, which is
220    // how the tests pin the answer.
221    let now = opts.now_ts.or_else(|| commit_ts.values().copied().max());
222    let sync_now = opts.now_ts.unwrap_or_else(now_unix);
223
224    let mut map = RepoMap {
225        files,
226        symbols,
227        commits,
228        authors,
229        last_sync: None,
230        communities: Vec::new(),
231        key_files: Vec::new(),
232        owners: Vec::new(),
233        hot_files: Vec::new(),
234        hot_days: opts.hot_days,
235        stale_concepts: 0,
236        questions: Vec::new(),
237        truncated: false,
238    };
239    if files == 0 {
240        return map; // nothing keyed on a file is worth computing
241    }
242
243    map.last_sync = str_prop(db, SYNC_KEY, "sha").map(|sha| {
244        let synced_at = match db.node_ref(SYNC_KEY).and_then(|n| n.prop(SYNCED_AT)) {
245            Some(Value::Int(at)) => Some(at),
246            _ => None, // a store built before the marker carried a stamp
247        };
248        SyncInfo {
249            sha: sanitize(&sha),
250            synced_at,
251            age_secs: synced_at.map(|at| sync_now - at),
252        }
253    });
254
255    // ── key files ───────────────────────────────────────────────────────────
256    // PageRank first: the cluster samples are ranked by it too.
257    let scores = if spent(deadline) {
258        truncated = true;
259        Vec::new()
260    } else {
261        let (scores, hit_budget) = file_pagerank(db, &file_keys, deadline);
262        truncated |= hit_budget;
263        scores
264    };
265    let by_score: BTreeMap<&str, f64> = scores.iter().map(|(k, s)| (k.as_str(), *s)).collect();
266    map.key_files = scores
267        .iter()
268        .take(MAX_KEY_FILES)
269        .map(|(k, s)| (sanitize(k), *s))
270        .collect();
271
272    // ── clusters ────────────────────────────────────────────────────────────
273    if !truncated && !spent(deadline) {
274        let report = db.communities(&LouvainConfig {
275            // One weight property covers both edge types: a `CO_CHANGED` edge
276            // is worth its `score`, and an `IMPORTS` edge, which carries no
277            // such property, falls back to 1.0 — above the threshold, so
278            // every import counts while a weak co-change does not.
279            edge_types: vec!["CO_CHANGED".to_string(), "IMPORTS".to_string()],
280            weight_prop: Some("score".to_string()),
281            min_weight: Some(CO_CHANGED_MIN_WEIGHT),
282            budget_ms: remaining_ms(deadline),
283            node_label: Some("File".to_string()),
284            ..LouvainConfig::default()
285        });
286        truncated |= report.truncated;
287        for c in report
288            .communities
289            .iter()
290            .filter(|c| c.members.len() >= MIN_CLUSTER)
291            .take(opts.max_communities)
292        {
293            let mut ranked: Vec<(String, f64)> = c
294                .members
295                .iter()
296                .map(|k| (k.clone(), by_score.get(k.as_str()).copied().unwrap_or(0.0)))
297                .collect();
298            rank(&mut ranked);
299            map.communities.push(MapCommunity {
300                name: sanitize(&cluster_name(&c.members)),
301                dir: sanitize(&common_dir_prefix(&c.members)),
302                size: c.members.len(),
303                cohesion: c.cohesion,
304                samples: ranked
305                    .into_iter()
306                    .take(opts.max_samples)
307                    .map(|(k, _)| sanitize(&k))
308                    .collect(),
309            });
310        }
311    } else {
312        truncated = true;
313    }
314
315    // ── owners ──────────────────────────────────────────────────────────────
316    if !spent(deadline) {
317        let mut owned: BTreeMap<String, usize> = BTreeMap::new();
318        for (_file, author, _w) in db.weighted_edges("TOP_AUTHOR", None) {
319            *owned.entry(author).or_default() += 1;
320        }
321        let mut named: Vec<(String, usize)> = owned
322            .into_iter()
323            .map(|(key, n)| {
324                // Authors are printed by name. The key — a mail address — is
325                // only ever a fallback for a store that has none.
326                let name = str_prop(db, &key, "name").unwrap_or(key);
327                (sanitize(&name), n)
328            })
329            .collect();
330        rank(&mut named);
331        named.truncate(MAX_OWNERS);
332        map.owners = named;
333    } else {
334        truncated = true;
335    }
336
337    // ── hot ─────────────────────────────────────────────────────────────────
338    if let (Some(now), false) = (now, spent(deadline)) {
339        let cutoff = now.saturating_sub(opts.hot_days.saturating_mul(SECS_PER_DAY));
340        // A closed window: a commit dated after "now" is outside it too, so
341        // asking the map what was hot at an earlier point answers about then.
342        let recent: BTreeSet<&str> = commit_ts
343            .iter()
344            .filter(|(_, ts)| (cutoff..=now).contains(ts))
345            .map(|(sha, _)| sha.as_str())
346            .collect();
347        let is_file: BTreeSet<&str> = file_keys.iter().map(String::as_str).collect();
348        let mut touched: BTreeMap<String, usize> = BTreeMap::new();
349        for (commit, file, _w) in db.weighted_edges("TOUCHED", None) {
350            if recent.contains(commit.as_str()) && is_file.contains(file.as_str()) {
351                *touched.entry(file).or_default() += 1;
352            }
353        }
354        let mut hot: Vec<(String, usize)> = touched
355            .into_iter()
356            .map(|(k, n)| (sanitize(&k), n))
357            .collect();
358        rank(&mut hot);
359        hot.truncate(MAX_HOT);
360        map.hot_files = hot;
361    } else if now.is_some() {
362        truncated = true;
363    }
364
365    // ── stale concepts ──────────────────────────────────────────────────────
366    if !spent(deadline) {
367        map.stale_concepts = super::concepts::stale_concepts(db).len();
368    } else {
369        truncated = true;
370    }
371
372    // Questions are phrased from the raw keys, not the sanitized ones printed
373    // above: they have to match a graph key to look a partner up.
374    map.questions = questions(db, &map, &scores);
375    map.truncated = truncated;
376    map
377}
378
379/// PageRank over the files, on the union of the three edge types that say one
380/// file depends on another.
381///
382/// `CALLS` runs between symbols, so it is projected onto the files that define
383/// them; a call inside one file is not a dependency and is dropped. Weights
384/// accumulate across the three sources, and rank flows along the edge — so a
385/// file many others import collects it, which is what "most depended-on"
386/// means. The iteration mirrors [`crate::algo::pagerank`]: same damping,
387/// tolerance, iteration cap, dangling-mass handling and per-iteration budget
388/// check.
389///
390/// Returns the ranking and whether the deadline cut the iteration short. Cut
391/// short, the scores are still a valid partial ranking — more iterations would
392/// only refine them — but the caller reports the map as truncated.
393pub(super) fn file_pagerank<F: Fs>(
394    db: &GraphDb<F>,
395    file_keys: &[String],
396    deadline: Option<Instant>,
397) -> (Vec<(String, f64)>, bool) {
398    let n = file_keys.len();
399    if n == 0 {
400        return (Vec::new(), false);
401    }
402    let idx: BTreeMap<&str, usize> = file_keys
403        .iter()
404        .enumerate()
405        .map(|(i, k)| (k.as_str(), i))
406        .collect();
407
408    // Where each symbol is defined, so a call can be read as a file edge.
409    let mut sym_file: BTreeMap<String, String> = BTreeMap::new();
410    for node in db.nodes_with_label("Symbol") {
411        if let Some(Value::Str(file)) = node.prop("file_id") {
412            sym_file.insert(node.key().to_string(), file);
413        }
414    }
415
416    let mut weight: BTreeMap<(usize, usize), f64> = BTreeMap::new();
417    let mut add = |src: Option<&usize>, dst: Option<&usize>, w: f64| {
418        if let (Some(&a), Some(&b)) = (src, dst) {
419            if a != b {
420                *weight.entry((a, b)).or_default() += w;
421            }
422        }
423    };
424    for (src, dst, _) in db.weighted_edges("IMPORTS", None) {
425        add(idx.get(src.as_str()), idx.get(dst.as_str()), 1.0);
426    }
427    for (src, dst, w) in db.weighted_edges("CO_CHANGED", Some("score")) {
428        add(
429            idx.get(src.as_str()),
430            idx.get(dst.as_str()),
431            w.unwrap_or(1.0),
432        );
433    }
434    for (src, dst, _) in db.weighted_edges("CALLS", None) {
435        let (Some(sf), Some(df)) = (sym_file.get(&src), sym_file.get(&dst)) else {
436            continue;
437        };
438        add(idx.get(sf.as_str()), idx.get(df.as_str()), 1.0);
439    }
440
441    let mut send_to: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
442    for ((a, b), w) in weight {
443        send_to[a].push((b, w));
444    }
445    let mut receive_from: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
446    let mut dangling: Vec<usize> = Vec::new();
447    for (i, send) in send_to.iter().enumerate() {
448        let out: f64 = send.iter().map(|(_, w)| w).sum();
449        if send.is_empty() || out <= 0.0 {
450            dangling.push(i);
451            continue;
452        }
453        for &(j, w) in send {
454            receive_from[j].push((i, w / out));
455        }
456    }
457
458    let (pr, hit_budget) = power_iteration(n, &receive_from, &dangling, deadline);
459    let mut scores: Vec<(String, f64)> = file_keys.iter().cloned().zip(pr).collect();
460    rank(&mut scores);
461    (scores, hit_budget)
462}
463
464/// The power iteration itself, split out so the budget check has a test that
465/// does not depend on how fast a machine is.
466///
467/// `receive_from[j]` holds `(i, share)` for every node that sends rank to `j`,
468/// already normalised by `i`'s outgoing weight; `dangling` lists the nodes with
469/// no outgoing weight, whose mass spreads uniformly. Returns the ranks and
470/// whether the deadline fired before convergence — checked before each
471/// iteration, so an already-expired deadline returns the uniform vector.
472fn power_iteration(
473    n: usize,
474    receive_from: &[Vec<(usize, f64)>],
475    dangling: &[usize],
476    deadline: Option<Instant>,
477) -> (Vec<f64>, bool) {
478    let nf = n as f64;
479    let teleport = (1.0 - DAMPING) / nf;
480    let mut pr: Vec<f64> = vec![1.0 / nf; n];
481    for _ in 0..MAX_ITERS {
482        if spent(deadline) {
483            return (pr, true);
484        }
485        let leaked = dangling.iter().map(|&i| pr[i]).sum::<f64>() * DAMPING / nf;
486        let mut next = vec![teleport + leaked; n];
487        for (j, slot) in next.iter_mut().enumerate() {
488            *slot += DAMPING * receive_from[j].iter().map(|&(i, w)| pr[i] * w).sum::<f64>();
489        }
490        let delta: f64 = pr.iter().zip(next.iter()).map(|(a, b)| (a - b).abs()).sum();
491        pr = next;
492        if delta < TOL {
493            break;
494        }
495    }
496    (pr, false)
497}
498
499/// Three questions worth asking of this graph, each naming something the map
500/// just showed is important.
501///
502/// A question is only offered when the graph can answer it: with no key file
503/// there is nothing to ask about, and with no cluster there is no directory to
504/// ask who owns.
505///
506/// `ranked` holds the file keys exactly as the graph stores them, which is what
507/// a lookup has to match; only the phrasing that comes out is sanitized.
508fn questions<F: Fs>(db: &GraphDb<F>, map: &RepoMap, ranked: &[(String, f64)]) -> Vec<String> {
509    let mut out = Vec::new();
510    if let Some((first, _)) = ranked.first() {
511        // The partner it changes with most often — the pairing a newcomer
512        // would not guess from the directory tree.
513        let mut partners: Vec<(String, f64)> = db
514            .weighted_edges("CO_CHANGED", Some("score"))
515            .into_iter()
516            .filter(|(src, _, _)| src == first)
517            .map(|(_, dst, w)| (dst, w.unwrap_or(1.0)))
518            .collect();
519        rank(&mut partners);
520        if let Some((partner, _)) = partners.first() {
521            let a = basename(first);
522            // Two files with the same name would make the question unreadable,
523            // so the partner keeps its path when its name collides.
524            let b = if basename(partner) == a {
525                partner.as_str()
526            } else {
527                basename(partner)
528            };
529            out.push(sanitize(&format!("why does {a} co-change with {b}?")));
530        }
531    }
532    // The largest cluster that has a directory to name: asking who owns a
533    // group of files that share no directory is not a question anyone can
534    // answer.
535    if let Some(cluster) = map.communities.iter().find(|c| !c.dir.is_empty()) {
536        out.push(sanitize(&format!("who owns {}?", cluster.dir)));
537    }
538    if let Some((second, _)) = ranked.get(1) {
539        out.push(sanitize(&format!("what imports {}?", basename(second))));
540    }
541    out
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    /// Three nodes in a line: 0 → 1 → 2, with 2 dangling.
549    fn line() -> (Vec<Vec<(usize, f64)>>, Vec<usize>) {
550        let receive_from = vec![Vec::new(), vec![(0, 1.0)], vec![(1, 1.0)]];
551        (receive_from, vec![2])
552    }
553
554    #[test]
555    fn an_expired_deadline_stops_the_iteration_before_it_starts() {
556        let (receive_from, dangling) = line();
557        let expired = Some(Instant::now() - Duration::from_secs(1));
558        let (pr, hit) = power_iteration(3, &receive_from, &dangling, expired);
559        assert!(hit, "the budget must be reported as spent");
560        assert_eq!(
561            pr,
562            vec![1.0 / 3.0; 3],
563            "nothing ran, so the ranks are still uniform — a valid partial answer"
564        );
565    }
566
567    #[test]
568    fn without_a_deadline_the_iteration_converges_and_ranks_the_sink_top() {
569        let (receive_from, dangling) = line();
570        let (pr, hit) = power_iteration(3, &receive_from, &dangling, None);
571        assert!(!hit, "no budget means nothing was cut short");
572        assert!(
573            pr[2] > pr[1] && pr[1] > pr[0],
574            "rank flows along the line and pools at the end: {pr:?}"
575        );
576        let total: f64 = pr.iter().sum();
577        assert!((total - 1.0).abs() < 1e-6, "ranks sum to one, got {total}");
578    }
579
580    #[test]
581    fn a_deadline_still_ahead_lets_the_iteration_finish() {
582        let (receive_from, dangling) = line();
583        let ample = Some(Instant::now() + Duration::from_secs(60));
584        let (pr, hit) = power_iteration(3, &receive_from, &dangling, ample);
585        assert!(!hit);
586        assert!(pr[2] > pr[0]);
587    }
588}