src/viz/navigator.rs
//! **Warehouse (Constellation) Navigator — the graph MODEL.**
//!
//! This is the render-agnostic DATA the [Warehouse Navigator + Code Vault]
//! render (see `workspace_nordisk/warehouse-navigator-design.md`). nornir is the
//! warehouse brain (L5a): it produces the MODEL — the hierarchical node/edge
//! graph plus per-node "travel" attributes — and a sibling **facett** agent builds
//! the 3-D view that CONSUMES this model via [`NavigatorModel::state_json`]. This
//! module holds **no** egui/facett types: it is pure `std` + `serde_json` so it is
//! fully offline-testable and the render layer stays a separate consumer.
//!
//! It EXTENDS the release dep-DAG seed ([`nornir_release_core::depdag::ReleaseDepDag`],
//! rendered by [`super::release_depdag`]) rather than forking a viz twin: the
//! crate publish-DAG is the L2 backbone, and this model layers the drill-down
//! hierarchy (constellation → repo → crate → module → code) and the travel
//! attributes (recency · bug anchors · release wave · downstream blast radius ·
//! bottleneck rank) on top of it.
//!
//! ## The five drill-down levels ([`Level`])
//! `L0 Constellation` → `L1 Repo` (the "encircling hull" around its crates) →
//! `L2 Crate` → `L3 Module` → `L4 Code`. Every node names its `parent`, so the
//! hierarchy IS the hull grouping; [`EdgeKind::Contains`] edges make it explicit.
//!
//! ## The travel-query surface ([`NavigatorModel::travel`])
//! Each [`Travel`] returns an ORDERED list of node ids — the spline the camera
//! tour follows (latest-changes / bugs-reported / release-wave / blast-radius /
//! bottleneck / deepest-chain).
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, Utc};
use crate::knowledge::symbols::SymbolRow;
use nornir_release_core::depdag::{ReleaseDepDag, state_str};
/// A drill-down level (the map3d LOD ladder L0→L4). Click = one level deeper.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
/// L0 — the whole constellation (single root super-node).
Constellation,
/// L1 — a repo, drawn as an encircling hull around its crates.
Repo,
/// L2 — a crate (the publish unit; the release-DAG backbone).
Crate,
/// L3 — a module/file inside a crate.
Module,
/// L4 — a source item (fn/struct/…) — the leaf that reaches actual code.
Code,
}
impl Level {
/// Stable token for `state_json` + robot assertions.
pub fn as_str(self) -> &'static str {
match self {
Level::Constellation => "constellation",
Level::Repo => "repo",
Level::Crate => "crate",
Level::Module => "module",
Level::Code => "code",
}
}
/// The LOD depth (0..=4).
pub fn depth(self) -> u8 {
match self {
Level::Constellation => 0,
Level::Repo => 1,
Level::Crate => 2,
Level::Module => 3,
Level::Code => 4,
}
}
}
/// The stable root id every repo hangs under.
pub const ROOT_ID: &str = "constellation";
/// Per-node change **recency** — the "latest changes" travel weight (glow ∝
/// recency). Rolled up from per-file git-heat ([`FileHeat`]) to the crate/repo.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Recency {
/// Commits touching this node's file(s) in the last 30 days.
pub commits_30d: i64,
/// … last 90 days.
pub commits_90d: i64,
/// … over all reachable history.
pub commits_total: i64,
/// The most recent commit timestamp across this node's file(s).
pub last_commit_ts: Option<DateTime<Utc>>,
}
impl Recency {
/// Fold another recency into this one (sum counts, keep the latest ts).
fn absorb(&mut self, o: &Recency) {
self.commits_30d += o.commits_30d;
self.commits_90d += o.commits_90d;
self.commits_total += o.commits_total;
if let Some(t) = o.last_commit_ts {
self.last_commit_ts = Some(self.last_commit_ts.map_or(t, |cur| cur.max(t)));
}
}
/// True when nothing changed in the last 30 days (dropped from the
/// latest-changes travel).
fn is_cold_30d(&self) -> bool {
self.commits_30d == 0
}
}
/// One per-file git-heat fact (the [`crate::knowledge::git_heat::GitHeatRow`]
/// projection the model reads). Kept as a plain struct so the builder is offline
/// (a real scan or a test fixture both feed the same shape).
#[derive(Debug, Clone)]
pub struct FileHeat {
pub repo: String,
pub file: String,
pub commits_30d: i64,
pub commits_90d: i64,
pub commits_total: i64,
pub last_commit_ts: Option<DateTime<Utc>>,
}
impl FileHeat {
fn recency(&self) -> Recency {
Recency {
commits_30d: self.commits_30d,
commits_90d: self.commits_90d,
commits_total: self.commits_total,
last_commit_ts: self.last_commit_ts,
}
}
}
/// One **bug anchor** — a reported problem parsed from a
/// `workspace_nordisk/<repo>_agents_problems.md` sister file, pointing at where in
/// the code the problem lives. Feeds the "bugs reported" travel (start at the
/// constellation, travel DOWN into the bug's crate/file).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BugAnchor {
/// The repo whose problems file this came from.
pub repo: String,
/// A source file the entry references (`src/deepscan.rs`), if any.
pub file: Option<String>,
/// A line number the entry pinned (`…:317`), if any.
pub line: Option<u32>,
/// The dated section header the anchor was found under (its label).
pub entry: String,
}
/// **Parse bug anchors** out of one repo's `*_agents_problems.md` markdown.
///
/// The problems files are freeform, but every mutating/BUG note pins a concrete
/// `src/….rs[:line]` path. We split the file into its `##` dated sections and, for
/// each section, emit one [`BugAnchor`] per distinct `src/….rs` file token found
/// (carrying its `:line` when present), labelled by the section header. A section
/// with no source-path token yields a single repo-level anchor (still a reported
/// problem, just not pinned to a file). Pure + deterministic — no network, no git.
#[must_use]
pub fn parse_bug_anchors(repo: &str, markdown: &str) -> Vec<BugAnchor> {
let mut out: Vec<BugAnchor> = Vec::new();
// Section header = the nearest preceding `## …` line (default: file title).
let mut header = format!("{repo} problems");
let mut seen_in_section: BTreeSet<(Option<String>, Option<u32>)> = BTreeSet::new();
let mut section_had_file = false;
let flush_bare = |out: &mut Vec<BugAnchor>, header: &str, had_file: bool| {
if !had_file {
out.push(BugAnchor {
repo: repo.to_string(),
file: None,
line: None,
entry: header.to_string(),
});
}
};
for line in markdown.lines() {
let trimmed = line.trim_start();
if let Some(h) = trimmed.strip_prefix("## ") {
// Close the previous section (repo-level anchor if it had no file).
flush_bare(&mut out, &header, section_had_file);
header = h.trim().to_string();
seen_in_section.clear();
section_had_file = false;
continue;
}
for (file, ln) in scan_src_paths(line) {
let key = (Some(file.clone()), ln);
if seen_in_section.insert(key) {
section_had_file = true;
out.push(BugAnchor {
repo: repo.to_string(),
file: Some(file),
line: ln,
entry: header.clone(),
});
}
}
}
flush_bare(&mut out, &header, section_had_file);
out
}
/// Scan one line for `src/….rs` (optionally `:line`) tokens. Returns each
/// (file, line?) pair. Regex-free: identifiers/paths cannot contain whitespace,
/// backticks or parens, so a token runs from `src/` to the next such delimiter.
fn scan_src_paths(line: &str) -> Vec<(String, Option<u32>)> {
let mut out = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
while i + 4 <= bytes.len() {
if &line[i..i + 4] == "src/" {
// Run to the next delimiter.
let mut j = i + 4;
while j < bytes.len() {
let c = bytes[j] as char;
if c.is_whitespace()
|| matches!(
c,
'`' | '('
| ')'
| '['
| ']'
| '"'
| '\''
| ','
| ';'
| '<'
| '>'
| '{'
| '}'
)
{
break;
}
j += 1;
}
let tok = &line[i..j];
if let Some((file, ln)) = split_path_line(tok) {
out.push((file, ln));
}
i = j;
} else {
i += 1;
}
}
out
}
/// Split a `src/foo.rs` or `src/foo.rs:317` token into (file, line?). Only a token
/// that actually contains `.rs` is a source anchor.
fn split_path_line(tok: &str) -> Option<(String, Option<u32>)> {
// Trim trailing sentence punctuation that survived the delimiter scan.
let tok = tok.trim_end_matches(['.', ':', ')', '`']);
if let Some((path, line)) = tok.rsplit_once(':') {
if path.ends_with(".rs") {
return Some((path.to_string(), line.parse().ok()));
}
}
if tok.ends_with(".rs") {
return Some((tok.to_string(), None));
}
None
}
/// **Load every repo's bug anchors** from a `workspace_nordisk`-style directory of
/// `<repo>_agents_problems.md` sister files. Each file's repo name is its basename
/// with the `_agents_problems.md` suffix stripped; its contents are parsed by
/// [`parse_bug_anchors`]. This is the concrete data source the "bugs reported"
/// travel consumes (the design's travel #2). Missing/unreadable files are skipped
/// (best-effort); the result is sorted by (repo, entry) for determinism.
///
/// Pure filesystem read — no git, no warehouse — so a temp-dir fixture exercises the
/// whole path offline.
pub fn load_bug_anchors_dir(dir: &std::path::Path) -> Vec<BugAnchor> {
const SUFFIX: &str = "_agents_problems.md";
let mut out: Vec<BugAnchor> = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else {
return out;
};
let mut entries: Vec<std::path::PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for path in entries {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(repo) = name.strip_suffix(SUFFIX) else {
continue;
};
if repo.is_empty() {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path) {
out.extend(parse_bug_anchors(repo, &text));
}
}
out.sort_by(|a, b| (&a.repo, &a.entry, &a.file).cmp(&(&b.repo, &b.entry, &b.file)));
out
}
/// The kind of a [`NavEdge`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeKind {
/// Hierarchy: parent → child (constellation→repo→crate→module→code). This is
/// the "encircling hull" grouping.
Contains,
/// A crate `from` depends-on crate `to` (the release-DAG backbone edge).
CrateDep,
/// A repo `from` depends-on repo `to` (crate deps rolled up to repos).
RepoDep,
}
impl EdgeKind {
pub fn as_str(self) -> &'static str {
match self {
EdgeKind::Contains => "contains",
EdgeKind::CrateDep => "crate_dep",
EdgeKind::RepoDep => "repo_dep",
}
}
}
/// One navigator node — a point in the constellation→…→code hierarchy carrying its
/// travel attributes.
#[derive(Debug, Clone)]
pub struct NavNode {
/// Stable id (`repo:<r>`, `crate:<c>`, `mod:<c>:<path>`, `code:<c>:<file>:<line>:<item>`).
pub id: String,
/// Display label (the bare name).
pub label: String,
/// Which drill-down level this node is.
pub level: Level,
/// The parent node id (the hull it sits inside). `None` only for the root.
pub parent: Option<String>,
/// The owning repo (colour-by-repo key).
pub repo: String,
/// The owning crate (empty at repo/constellation level).
pub crate_name: String,
/// A source file this node maps to (module/code levels).
pub file: Option<String>,
/// Source line (code level).
pub line: Option<u32>,
/// Change recency (the latest-changes travel weight).
pub recency: Recency,
/// Reported problems that resolve to this node (the bugs travel targets).
pub bug_anchors: Vec<BugAnchor>,
/// Release publish wave (crate level; deps-first, 0 = first). `None` off-DAG.
pub wave: Option<usize>,
/// Registry state token (crate level: will_publish/desync/…), if known.
pub registry_state: Option<String>,
/// Downstream **blast radius** — the count of crates that transitively DEPEND
/// on this one (0 for non-crate nodes). The impact travel + bottleneck weight.
pub blast: usize,
/// **Bottleneck rank** among crates (1 = the crate that unblocks the most
/// downstream work, by `blast` desc). `None` for non-crate nodes.
pub bottleneck_rank: Option<usize>,
}
/// One navigator edge.
#[derive(Debug, Clone)]
pub struct NavEdge {
pub from: String,
pub to: String,
pub kind: EdgeKind,
}
/// A selectable camera **travel** (each resolves to an ordered node-id list → a
/// glowing spline the tour follows). See the design's "travels" menu.
#[derive(Debug, Clone)]
pub enum Travel {
/// Crates changed in the last 30 days, newest/hottest first (glow ∝ recency).
LatestChanges,
/// Start at the constellation, travel DOWN into each reported bug's node.
BugsReported,
/// The release-DAG publish order (deps-first waves flattened).
ReleaseWave,
/// From a crate, visit every downstream dependent it would affect.
BlastRadius { crate_name: String },
/// Crates by bottleneck rank (the ones that unblock the most work first).
Bottleneck,
/// The single longest transitive crate-dependency chain.
DeepestChain,
}
impl Travel {
/// Stable token for `state_json`/robot assertions.
pub fn as_str(&self) -> &'static str {
match self {
Travel::LatestChanges => "latest_changes",
Travel::BugsReported => "bugs_reported",
Travel::ReleaseWave => "release_wave",
Travel::BlastRadius { .. } => "blast_radius",
Travel::Bottleneck => "bottleneck",
Travel::DeepestChain => "deepest_chain",
}
}
}
/// The assembled, render-agnostic **Warehouse Navigator model**: the hierarchical
/// node/edge graph + the travel-query surface. Built by [`NavigatorModel::build`]
/// from data (a [`ReleaseDepDag`], symbols, git-heat, parsed bug anchors), so the
/// whole model is offline + golden-testable; facett consumes [`Self::state_json`].
#[derive(Debug, Clone, Default)]
pub struct NavigatorModel {
pub nodes: Vec<NavNode>,
pub edges: Vec<NavEdge>,
/// id → index into `nodes`.
index: BTreeMap<String, usize>,
/// Crate publish order (deps-first) — the release-DAG's flattened waves.
release_order: Vec<String>,
/// Crate name → crate node id (fast crate lookup for travels).
crate_node: BTreeMap<String, String>,
}
/// A crate node id from a crate name.
fn crate_id(name: &str) -> String {
format!("crate:{name}")
}
/// A repo node id from a repo name.
fn repo_id(name: &str) -> String {
format!("repo:{name}")
}
impl NavigatorModel {
/// Assemble the model from already-loaded data.
///
/// * `dag` — the crate publish-DAG (nodes = crates with repo/wave/state, edges =
/// crate depends-on). The L1/L2 backbone + the release order.
/// * `symbols` — knowledge `symbol_facts` rows (may be empty). Each row seeds a
/// L3 module (by `module_path`) and a L4 code node (the item), grouped under
/// its crate. Empty ⇒ the model stops at the crate level.
/// * `heat` — per-file git-heat (may be empty). Attached to the file's node and
/// rolled up to the crate + repo for the latest-changes travel.
/// * `anchors` — parsed [`BugAnchor`]s (from [`parse_bug_anchors`]) resolved to
/// the deepest matching node (code line → file/module → crate → repo).
#[must_use]
pub fn build(
dag: &ReleaseDepDag,
symbols: &[SymbolRow],
heat: &[FileHeat],
anchors: &[BugAnchor],
) -> Self {
let mut m = NavigatorModel::default();
// ── L0 root ────────────────────────────────────────────────
m.push(NavNode {
id: ROOT_ID.to_string(),
label: "constellation".to_string(),
level: Level::Constellation,
parent: None,
repo: String::new(),
crate_name: String::new(),
file: None,
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
// ── L1 repos (distinct owners in the DAG) ──────────────────
let repos: BTreeSet<String> = dag.nodes.iter().map(|n| n.repo.clone()).collect();
for repo in &repos {
if repo.is_empty() {
continue;
}
m.push(NavNode {
id: repo_id(repo),
label: repo.clone(),
level: Level::Repo,
parent: Some(ROOT_ID.to_string()),
repo: repo.clone(),
crate_name: String::new(),
file: None,
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
m.edges.push(NavEdge {
from: ROOT_ID.to_string(),
to: repo_id(repo),
kind: EdgeKind::Contains,
});
}
// ── L2 crates (the publish DAG) ────────────────────────────
// Downstream blast (transitive dependents) over the crate edges.
let blast = downstream_blast(dag);
for cn in &dag.nodes {
let id = crate_id(&cn.name);
let parent = if cn.repo.is_empty() {
ROOT_ID.to_string()
} else {
repo_id(&cn.repo)
};
m.crate_node.insert(cn.name.clone(), id.clone());
m.push(NavNode {
id: id.clone(),
label: cn.name.clone(),
level: Level::Crate,
parent: Some(parent.clone()),
repo: cn.repo.clone(),
crate_name: cn.name.clone(),
file: None,
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: cn.wave,
registry_state: Some(state_str(cn.state).to_string()),
blast: blast.get(&cn.name).copied().unwrap_or(0),
bottleneck_rank: None,
});
m.edges.push(NavEdge {
from: parent,
to: id,
kind: EdgeKind::Contains,
});
}
m.release_order = dag.topo_order();
// Bottleneck rank: crates by blast desc, then name.
let mut ranked: Vec<(&String, usize)> = dag
.nodes
.iter()
.map(|n| (&n.name, blast.get(&n.name).copied().unwrap_or(0)))
.collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (rank, (name, _)) in ranked.iter().enumerate() {
if let Some(&i) = m.crate_node.get(*name).and_then(|id| m.index.get(id)) {
m.nodes[i].bottleneck_rank = Some(rank + 1);
}
}
// ── crate-dep edges (release-DAG backbone) + repo-dep rollup ─
let owner: BTreeMap<&str, &str> = dag
.nodes
.iter()
.map(|n| (n.name.as_str(), n.repo.as_str()))
.collect();
let mut repo_edges: BTreeSet<(String, String)> = BTreeSet::new();
for e in &dag.edges {
if m.crate_node.contains_key(&e.from) && m.crate_node.contains_key(&e.to) {
m.edges.push(NavEdge {
from: crate_id(&e.from),
to: crate_id(&e.to),
kind: EdgeKind::CrateDep,
});
}
if let (Some(&fr), Some(&tr)) = (owner.get(e.from.as_str()), owner.get(e.to.as_str())) {
if fr != tr && !fr.is_empty() && !tr.is_empty() {
repo_edges.insert((fr.to_string(), tr.to_string()));
}
}
}
for (fr, tr) in repo_edges {
m.edges.push(NavEdge {
from: repo_id(&fr),
to: repo_id(&tr),
kind: EdgeKind::RepoDep,
});
}
// ── L3 modules + L4 code (from symbols) ────────────────────
m.attach_symbols(symbols);
// ── recency (git-heat) rolled leaf → crate → repo ──────────
m.attach_recency(heat, symbols);
// ── bug anchors resolved to the deepest node ───────────────
m.attach_anchors(anchors);
m
}
fn push(&mut self, node: NavNode) {
let idx = self.nodes.len();
self.index.insert(node.id.clone(), idx);
self.nodes.push(node);
}
/// Build L3 module + L4 code nodes from `symbols`, grouped under their crate.
/// Only crates already present (in the DAG) get a subtree; a symbol for an
/// unknown crate is skipped (the DAG is the crate authority).
fn attach_symbols(&mut self, symbols: &[SymbolRow]) {
// Distinct modules first (crate, module_path) → the L3 node.
let mut modules: BTreeMap<(String, String), (String, Option<String>)> = BTreeMap::new();
for s in symbols {
if !self.crate_node.contains_key(&s.crate_name) {
continue;
}
let mp = if s.module_path.is_empty() {
s.crate_name.clone()
} else {
s.module_path.clone()
};
modules
.entry((s.crate_name.clone(), mp))
.or_insert_with(|| (s.file.clone(), Some(s.file.clone())));
}
for ((cn, mp), (file, _)) in &modules {
let id = module_id(cn, mp);
if self.index.contains_key(&id) {
continue;
}
let parent = crate_id(cn);
let repo = self.repo_of_crate(cn);
self.push(NavNode {
id: id.clone(),
label: last_seg(mp).to_string(),
level: Level::Module,
parent: Some(parent.clone()),
repo,
crate_name: cn.clone(),
file: Some(file.clone()),
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
self.edges.push(NavEdge {
from: parent,
to: id,
kind: EdgeKind::Contains,
});
}
for s in symbols {
if !self.crate_node.contains_key(&s.crate_name) {
continue;
}
let mp = if s.module_path.is_empty() {
s.crate_name.clone()
} else {
s.module_path.clone()
};
let mod_nid = module_id(&s.crate_name, &mp);
let id = code_id(&s.crate_name, &s.file, s.line, &s.item_name);
if self.index.contains_key(&id) {
continue;
}
let repo = self.repo_of_crate(&s.crate_name);
self.push(NavNode {
id: id.clone(),
label: s.item_name.clone(),
level: Level::Code,
parent: Some(mod_nid.clone()),
repo,
crate_name: s.crate_name.clone(),
file: Some(s.file.clone()),
line: Some(s.line),
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
self.edges.push(NavEdge {
from: mod_nid,
to: id,
kind: EdgeKind::Contains,
});
}
}
/// Attach git-heat: the per-file recency lands on the file's module/code nodes,
/// then rolls up (summed, latest-ts) to the crate and repo. Crate/repo rollups
/// dedupe by distinct file so many symbols in one file are not double-counted.
fn attach_recency(&mut self, heat: &[FileHeat], symbols: &[SymbolRow]) {
// (repo, file) → Recency.
let mut by_file: BTreeMap<(String, String), Recency> = BTreeMap::new();
for h in heat {
by_file
.entry((h.repo.clone(), h.file.clone()))
.or_default()
.absorb(&h.recency());
}
let lookup = |repo: &str, file: &str| -> Option<Recency> {
by_file
.get(&(repo.to_string(), file.to_string()))
.cloned()
.or_else(|| {
// Tolerate a path prefix mismatch: match on suffix.
by_file
.iter()
.find(|((r, f), _)| {
r == repo && (f.ends_with(file) || file.ends_with(f.as_str()))
})
.map(|(_, v)| v.clone())
})
};
// Leaf nodes (module/code) get their file's recency.
for i in 0..self.nodes.len() {
if matches!(self.nodes[i].level, Level::Module | Level::Code) {
let (repo, file) = (self.nodes[i].repo.clone(), self.nodes[i].file.clone());
if let Some(file) = file {
if let Some(r) = lookup(&repo, &file) {
self.nodes[i].recency = r;
}
}
}
}
// Crate rollup: sum over the crate's DISTINCT files (from symbols).
let mut crate_files: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();
for s in symbols {
if self.crate_node.contains_key(&s.crate_name) {
let repo = self.repo_of_crate(&s.crate_name);
crate_files
.entry(s.crate_name.clone())
.or_default()
.insert((repo, s.file.clone()));
}
}
let mut repo_roll: BTreeMap<String, Recency> = BTreeMap::new();
for (cn, files) in &crate_files {
let mut roll = Recency::default();
for (repo, file) in files {
if let Some(r) = lookup(repo, file) {
roll.absorb(&r);
}
}
if let Some(&i) = self.crate_node.get(cn).and_then(|id| self.index.get(id)) {
self.nodes[i].recency = roll.clone();
let repo = self.nodes[i].repo.clone();
if !repo.is_empty() {
repo_roll.entry(repo).or_default().absorb(&roll);
}
}
}
for (repo, roll) in repo_roll {
if let Some(&i) = self.index.get(&repo_id(&repo)) {
self.nodes[i].recency = roll;
}
}
}
/// Resolve each bug anchor to the DEEPEST matching node and record it there
/// (plus a repo-level copy so a repo shows it has open problems).
fn attach_anchors(&mut self, anchors: &[BugAnchor]) {
for a in anchors {
let target = self.resolve_anchor(a);
if let Some(&i) = self.index.get(&target) {
self.nodes[i].bug_anchors.push(a.clone());
}
// Also surface at the repo hull (unless the target already IS the repo).
let rid = repo_id(&a.repo);
if target != rid {
if let Some(&ri) = self.index.get(&rid) {
self.nodes[ri].bug_anchors.push(a.clone());
}
}
}
}
/// The node id a bug anchor points at: a code node on that file+line if one
/// exists, else the module for that file, else the crate owning that file, else
/// the repo hull.
fn resolve_anchor(&self, a: &BugAnchor) -> String {
if let Some(file) = &a.file {
// Best: a code node in this repo whose file matches (and line if given).
let mut best_code: Option<(&str, u32)> = None; // (id, line-distance-ish)
let mut file_module: Option<&str> = None;
let mut file_crate: Option<&str> = None;
for n in &self.nodes {
if n.repo != a.repo {
continue;
}
let matches_file = n
.file
.as_deref()
.map(|f| f.ends_with(file.as_str()) || file.ends_with(f))
.unwrap_or(false);
if !matches_file {
continue;
}
match n.level {
Level::Code => {
if let (Some(l), Some(al)) = (n.line, a.line) {
if l == al {
return n.id.clone(); // exact line hit
}
}
if best_code.is_none() {
best_code = Some((&n.id, n.line.unwrap_or(0)));
}
}
Level::Module => {
if file_module.is_none() {
file_module = Some(&n.id);
}
file_crate = Some(
self.crate_node
.get(&n.crate_name)
.map(String::as_str)
.unwrap_or(""),
);
}
_ => {}
}
}
if let Some(m) = file_module {
return m.to_string();
}
if let Some((c, _)) = best_code {
return c.to_string();
}
if let Some(c) = file_crate {
if !c.is_empty() {
return c.to_string();
}
}
}
// No file → repo hull (the anchor still marks the repo as having problems).
let rid = repo_id(&a.repo);
if self.index.contains_key(&rid) {
return rid;
}
ROOT_ID.to_string()
}
fn repo_of_crate(&self, cn: &str) -> String {
self.crate_node
.get(cn)
.and_then(|id| self.index.get(id))
.map(|&i| self.nodes[i].repo.clone())
.unwrap_or_default()
}
/// A node by id.
pub fn node(&self, id: &str) -> Option<&NavNode> {
self.index.get(id).map(|&i| &self.nodes[i])
}
/// Node count.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Edge count.
pub fn edge_count(&self) -> usize {
self.edges.len()
}
/// True when a directed edge of `kind` from `from` to `to` exists (test oracle).
pub fn has_edge(&self, from: &str, to: &str, kind: EdgeKind) -> bool {
self.edges
.iter()
.any(|e| e.from == from && e.to == to && e.kind == kind)
}
/// The release publish order (deps-first crate names).
pub fn release_order(&self) -> &[String] {
&self.release_order
}
// ── the TRAVEL-QUERY surface ───────────────────────────────────
/// Resolve a [`Travel`] to the ORDERED node-id list the camera spline follows.
#[must_use]
pub fn travel(&self, t: &Travel) -> Vec<String> {
match t {
Travel::LatestChanges => {
let mut crates: Vec<&NavNode> = self
.nodes
.iter()
.filter(|n| n.level == Level::Crate && !n.recency.is_cold_30d())
.collect();
crates.sort_by(|a, b| {
b.recency
.commits_30d
.cmp(&a.recency.commits_30d)
.then(b.recency.last_commit_ts.cmp(&a.recency.last_commit_ts))
.then(a.label.cmp(&b.label))
});
crates.into_iter().map(|n| n.id.clone()).collect()
}
Travel::BugsReported => {
// Start at the constellation, then descend to each bug node
// (deepest resolved node), most-pinned repos first, deterministic.
let mut out = vec![ROOT_ID.to_string()];
let mut targets: Vec<&NavNode> = self
.nodes
.iter()
.filter(|n| !n.bug_anchors.is_empty() && n.level != Level::Repo)
.collect();
// Order: by level depth (crate/module/code) then id for stability.
targets.sort_by(|a, b| a.level.depth().cmp(&b.level.depth()).then(a.id.cmp(&b.id)));
for n in targets {
// Prepend the repo hull the first time we descend into it.
let rid = repo_id(&n.repo);
if !out.contains(&rid) && self.index.contains_key(&rid) {
out.push(rid);
}
out.push(n.id.clone());
}
out
}
Travel::ReleaseWave => self.release_order.iter().map(|c| crate_id(c)).collect(),
Travel::Bottleneck => {
let mut crates: Vec<&NavNode> = self
.nodes
.iter()
.filter(|n| n.bottleneck_rank.is_some())
.collect();
crates.sort_by_key(|n| n.bottleneck_rank.unwrap_or(usize::MAX));
crates.into_iter().map(|n| n.id.clone()).collect()
}
Travel::BlastRadius { crate_name } => {
let mut out = Vec::new();
if let Some(id) = self.crate_node.get(crate_name) {
out.push(id.clone());
}
for dep in self.dependents_of(crate_name) {
if let Some(id) = self.crate_node.get(&dep) {
out.push(id.clone());
}
}
out
}
Travel::DeepestChain => self
.deepest_chain()
.into_iter()
.map(|c| crate_id(&c))
.collect(),
}
}
/// Every crate that transitively depends on `crate_name` (its downstream cone),
/// ordered by wave then name for a stable tour.
fn dependents_of(&self, crate_name: &str) -> Vec<String> {
// Reverse adjacency over CrateDep edges: dep → its dependents.
let mut rev: BTreeMap<String, Vec<String>> = BTreeMap::new();
for e in &self.edges {
if e.kind == EdgeKind::CrateDep {
let (from, to) = (strip_crate(&e.from), strip_crate(&e.to));
rev.entry(to).or_default().push(from);
}
}
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut stack = vec![crate_name.to_string()];
while let Some(c) = stack.pop() {
if let Some(deps) = rev.get(&c) {
for d in deps {
if seen.insert(d.clone()) {
stack.push(d.clone());
}
}
}
}
let mut out: Vec<String> = seen.into_iter().collect();
out.sort_by(|a, b| {
let wa = self
.node(&crate_id(a))
.and_then(|n| n.wave)
.unwrap_or(usize::MAX);
let wb = self
.node(&crate_id(b))
.and_then(|n| n.wave)
.unwrap_or(usize::MAX);
wa.cmp(&wb).then(a.cmp(b))
});
out
}
/// The longest transitive crate-dependency chain (deps-first). Longest path in
/// the crate DAG over CrateDep edges; empty if there are no crate edges.
fn deepest_chain(&self) -> Vec<String> {
// Adjacency from → to (from depends on to).
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut nodes: BTreeSet<String> = BTreeSet::new();
for e in &self.edges {
if e.kind == EdgeKind::CrateDep {
let (from, to) = (strip_crate(&e.from), strip_crate(&e.to));
adj.entry(from.clone()).or_default().push(to.clone());
nodes.insert(from);
nodes.insert(to);
}
}
// Memoized longest downward path from each node (DAG ⇒ no cycles reach here
// for publishable graphs; a cycle would just bound the memo by `visiting`).
let mut best: BTreeMap<String, Vec<String>> = BTreeMap::new();
fn longest(
n: &str,
adj: &BTreeMap<String, Vec<String>>,
best: &mut BTreeMap<String, Vec<String>>,
visiting: &mut BTreeSet<String>,
) -> Vec<String> {
if let Some(v) = best.get(n) {
return v.clone();
}
if !visiting.insert(n.to_string()) {
return vec![n.to_string()]; // cycle guard
}
let mut longest_tail: Vec<String> = Vec::new();
if let Some(succ) = adj.get(n) {
for s in succ {
let tail = longest(s, adj, best, visiting);
if tail.len() > longest_tail.len() {
longest_tail = tail;
}
}
}
visiting.remove(n);
let mut path = vec![n.to_string()];
path.extend(longest_tail);
best.insert(n.to_string(), path.clone());
path
}
let mut visiting = BTreeSet::new();
let mut winner: Vec<String> = Vec::new();
for n in &nodes {
let p = longest(n, &adj, &mut best, &mut visiting);
if p.len() > winner.len() {
winner = p;
}
}
winner
}
/// Robot-observable introspection blob — the surface **facett consumes**. Holds
/// the level tally, every node (with its travel attributes) and edge, the release
/// order, and a preview of each travel's ordered node-id list.
#[must_use]
pub fn state_json(&self) -> serde_json::Value {
let mut level_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
for n in &self.nodes {
*level_counts.entry(n.level.as_str()).or_default() += 1;
}
let mut edge_kinds: BTreeMap<&'static str, usize> = BTreeMap::new();
for e in &self.edges {
*edge_kinds.entry(e.kind.as_str()).or_default() += 1;
}
serde_json::json!({
"node_count": self.node_count(),
"edge_count": self.edge_count(),
"level_counts": level_counts,
"edge_kinds": edge_kinds,
"release_order": self.release_order,
"nodes": self.nodes.iter().map(|n| serde_json::json!({
"id": n.id,
"label": n.label,
"level": n.level.as_str(),
"parent": n.parent,
"repo": n.repo,
"crate": n.crate_name,
"file": n.file,
"line": n.line,
"wave": n.wave,
"registry_state": n.registry_state,
"blast": n.blast,
"bottleneck_rank": n.bottleneck_rank,
"recency": {
"commits_30d": n.recency.commits_30d,
"commits_90d": n.recency.commits_90d,
"commits_total": n.recency.commits_total,
"last_commit_ts": n.recency.last_commit_ts.map(|t| t.to_rfc3339()),
},
"bug_anchors": n.bug_anchors.iter().map(|b| serde_json::json!({
"file": b.file, "line": b.line, "entry": b.entry,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"edges": self.edges.iter().map(|e| serde_json::json!({
"from": e.from, "to": e.to, "kind": e.kind.as_str(),
})).collect::<Vec<_>>(),
"travels": {
"latest_changes": self.travel(&Travel::LatestChanges),
"bugs_reported": self.travel(&Travel::BugsReported),
"release_wave": self.travel(&Travel::ReleaseWave),
"bottleneck": self.travel(&Travel::Bottleneck),
"deepest_chain": self.travel(&Travel::DeepestChain),
},
})
}
}
/// Transitive downstream dependents count per crate over the DAG's crate edges.
/// `edge from→to` = `from` depends on `to`, so `to`'s dependents include `from`
/// and everything that reaches `from`. Reverse-reachability BFS per crate.
fn downstream_blast(dag: &ReleaseDepDag) -> BTreeMap<String, usize> {
let mut rev: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for e in &dag.edges {
rev.entry(e.to.as_str()).or_default().push(e.from.as_str());
}
let mut out = BTreeMap::new();
for n in &dag.nodes {
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![n.name.as_str()];
while let Some(c) = stack.pop() {
if let Some(deps) = rev.get(c) {
for d in deps {
if seen.insert(d) {
stack.push(d);
}
}
}
}
out.insert(n.name.clone(), seen.len());
}
out
}
/// Last `::`- or `/`-separated segment (the bare display name).
fn last_seg(s: &str) -> &str {
s.rsplit(['/', ':']).next().unwrap_or(s)
}
/// Module node id.
fn module_id(crate_name: &str, module_path: &str) -> String {
format!("mod:{crate_name}:{module_path}")
}
/// Code node id.
fn code_id(crate_name: &str, file: &str, line: u32, item: &str) -> String {
format!("code:{crate_name}:{file}:{line}:{item}")
}
/// Strip the `crate:` prefix off a crate node id back to the bare crate name.
fn strip_crate(id: &str) -> String {
id.strip_prefix("crate:").unwrap_or(id).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::doctor::RepoGraph;
/// A 3-repo chain: `app`(A) → `lib`(B) → `base`(C). Clean crate DAG.
fn chain_dag() -> ReleaseDepDag {
let mut a = RepoGraph {
repo: "A".into(),
..Default::default()
};
a.crate_deps
.entry("app".into())
.or_default()
.insert("lib".into());
let mut b = RepoGraph {
repo: "B".into(),
..Default::default()
};
b.crate_deps
.entry("lib".into())
.or_default()
.insert("base".into());
let mut c = RepoGraph {
repo: "C".into(),
..Default::default()
};
c.crate_deps.entry("base".into()).or_default();
ReleaseDepDag::assemble(&[a, b, c], &BTreeMap::new(), &BTreeMap::new())
}
fn sym(crate_name: &str, module_path: &str, item: &str, file: &str, line: u32) -> SymbolRow {
SymbolRow {
crate_name: crate_name.into(),
module_path: module_path.into(),
item_kind: "fn".into(),
item_name: item.into(),
visibility: "pub".into(),
file: file.into(),
line,
doc_lines: 0,
signature: None,
}
}
#[test]
fn builds_the_five_level_hierarchy_with_hull_edges() {
let dag = chain_dag();
let symbols = vec![sym("app", "app::run", "run", "src/run.rs", 10)];
let m = NavigatorModel::build(&dag, &symbols, &[], &[]);
// L0 root + 3 repos + 3 crates + 1 module + 1 code.
assert!(m.node(ROOT_ID).is_some());
assert_eq!(m.node(ROOT_ID).unwrap().level, Level::Constellation);
assert_eq!(m.node("repo:A").unwrap().level, Level::Repo);
assert_eq!(m.node("crate:app").unwrap().level, Level::Crate);
// hull grouping: crate app sits inside repo A inside the constellation.
assert_eq!(
m.node("crate:app").unwrap().parent.as_deref(),
Some("repo:A")
);
assert_eq!(m.node("repo:A").unwrap().parent.as_deref(), Some(ROOT_ID));
assert!(m.has_edge(ROOT_ID, "repo:A", EdgeKind::Contains));
assert!(m.has_edge("repo:A", "crate:app", EdgeKind::Contains));
// module + code reached.
let code = m.nodes.iter().find(|n| n.level == Level::Code).unwrap();
assert_eq!(code.label, "run");
assert_eq!(code.crate_name, "app");
assert_eq!(code.line, Some(10));
}
/// RED-WHEN-BROKEN #1: the KNOWN crate-dep edge `app → lib` must exist as a
/// CrateDep, and its repo rollup `A → B` as a RepoDep. Drop the crate-dep
/// wiring and this fails.
#[test]
fn known_dep_edges_exist_at_crate_and_repo_level() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
assert!(
m.has_edge("crate:app", "crate:lib", EdgeKind::CrateDep),
"app depends on lib"
);
assert!(
m.has_edge("crate:lib", "crate:base", EdgeKind::CrateDep),
"lib depends on base"
);
// repo rollup of the cross-repo crate deps.
assert!(
m.has_edge("repo:A", "repo:B", EdgeKind::RepoDep),
"repo A → B"
);
assert!(
m.has_edge("repo:B", "repo:C", EdgeKind::RepoDep),
"repo B → C"
);
// a NON-edge must be absent (guards over-generation).
assert!(!m.has_edge("crate:base", "crate:app", EdgeKind::CrateDep));
}
/// RED-WHEN-BROKEN #2: a seeded bug anchor resolves to ITS crate/file node.
/// The anchor pins `src/run.rs:10` in repo A → it must land on the `run` code
/// node (exact line) AND surface at the repo hull. Break the file→node
/// resolution and it lands on the wrong node.
#[test]
fn seeded_bug_anchor_resolves_to_its_code_node() {
let dag = chain_dag();
let symbols = vec![
sym("app", "app::run", "run", "src/run.rs", 10),
sym("app", "app::run", "helper", "src/run.rs", 42),
];
let anchor = BugAnchor {
repo: "A".into(),
file: Some("src/run.rs".into()),
line: Some(10),
entry: "2026-07-14 — boom".into(),
};
let m = NavigatorModel::build(&dag, &symbols, &[], &[anchor.clone()]);
// Exact line hit → the `run` code node carries the anchor.
let run = m
.nodes
.iter()
.find(|n| n.level == Level::Code && n.line == Some(10))
.unwrap();
assert_eq!(
run.bug_anchors.len(),
1,
"anchor resolved to the run() code node"
);
assert_eq!(run.bug_anchors[0].entry, "2026-07-14 — boom");
// The other code node on the same file (line 42) does NOT get it.
let helper = m
.nodes
.iter()
.find(|n| n.level == Level::Code && n.line == Some(42))
.unwrap();
assert!(helper.bug_anchors.is_empty());
// The repo hull also surfaces it (so a repo shows "has problems").
assert_eq!(m.node("repo:A").unwrap().bug_anchors.len(), 1);
}
#[test]
fn parse_bug_anchors_extracts_file_and_line_tokens() {
let md = "\
# nornir problems
## 2026-07-14 — populate arc
`WarehouseSink::flush` (`src/deepscan.rs:317`) calls the async twin at `src/warehouse/iceberg.rs:2333`.
## 2026-07-13 — no file here
Just prose, nothing pinned.
";
let anchors = parse_bug_anchors("nornir", md);
// Two file anchors in the first section, one bare anchor for the second.
let files: Vec<(Option<&str>, Option<u32>)> = anchors
.iter()
.map(|a| (a.file.as_deref(), a.line))
.collect();
assert!(
files.contains(&(Some("src/deepscan.rs"), Some(317))),
"{files:?}"
);
assert!(
files.contains(&(Some("src/warehouse/iceberg.rs"), Some(2333))),
"{files:?}"
);
// The prose-only section yields a repo-level (file=None) anchor.
assert!(
anchors
.iter()
.any(|a| a.file.is_none() && a.entry.starts_with("2026-07-13"))
);
// Every anchor carries its section header as the label.
assert!(anchors.iter().any(|a| a.entry.starts_with("2026-07-14")));
}
#[test]
fn load_bug_anchors_dir_reads_sister_files_and_derives_repo() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("nornir_agents_problems.md"),
"## 2026-07-14 — boom\nbug in `src/deepscan.rs:317`.\n",
)
.unwrap();
std::fs::write(
dir.path().join("facett_agents_problems.md"),
"## 2026-07-13 — paint\ncrash in `src/graph.rs:9`.\n",
)
.unwrap();
// A non-matching file is ignored.
std::fs::write(dir.path().join("README.md"), "not a problems file").unwrap();
let anchors = load_bug_anchors_dir(dir.path());
assert!(anchors.iter().any(|a| a.repo == "nornir"
&& a.file.as_deref() == Some("src/deepscan.rs")
&& a.line == Some(317)));
assert!(anchors.iter().any(|a| a.repo == "facett"
&& a.file.as_deref() == Some("src/graph.rs")
&& a.line == Some(9)));
assert!(
anchors
.iter()
.all(|a| a.repo == "nornir" || a.repo == "facett"),
"README skipped"
);
}
#[test]
fn release_wave_travel_is_deps_first() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
// base (leaf) publishes first, then lib, then app.
assert_eq!(
m.release_order(),
&["base".to_string(), "lib".to_string(), "app".to_string()]
);
let tour = m.travel(&Travel::ReleaseWave);
assert_eq!(tour, vec!["crate:base", "crate:lib", "crate:app"]);
}
#[test]
fn blast_and_bottleneck_rank_the_base_crate_highest() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
// base is depended on by lib and app → blast 2; app is a leaf dependent → 0.
assert_eq!(m.node("crate:base").unwrap().blast, 2);
assert_eq!(m.node("crate:lib").unwrap().blast, 1);
assert_eq!(m.node("crate:app").unwrap().blast, 0);
// base unblocks the most → bottleneck rank 1.
assert_eq!(m.node("crate:base").unwrap().bottleneck_rank, Some(1));
assert_eq!(
m.travel(&Travel::Bottleneck).first().map(String::as_str),
Some("crate:base")
);
// blast-radius travel from base visits base then its dependents.
let tour = m.travel(&Travel::BlastRadius {
crate_name: "base".into(),
});
assert_eq!(tour[0], "crate:base");
assert!(tour.contains(&"crate:lib".to_string()));
assert!(tour.contains(&"crate:app".to_string()));
}
#[test]
fn deepest_chain_is_the_full_transitive_path() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
// app → lib → base is the longest chain.
assert_eq!(
m.travel(&Travel::DeepestChain),
vec!["crate:app", "crate:lib", "crate:base"]
);
}
#[test]
fn latest_changes_travel_ranks_by_recency_and_drops_cold_crates() {
let dag = chain_dag();
// app hot (5 commits/30d on src/run.rs), lib warm (1), base cold (0).
let symbols = vec![
sym("app", "app::run", "run", "src/run.rs", 10),
sym("lib", "lib::l", "l", "src/l.rs", 1),
sym("base", "base::b", "b", "src/b.rs", 1),
];
let heat = vec![
FileHeat {
repo: "A".into(),
file: "src/run.rs".into(),
commits_30d: 5,
commits_90d: 5,
commits_total: 9,
last_commit_ts: None,
},
FileHeat {
repo: "B".into(),
file: "src/l.rs".into(),
commits_30d: 1,
commits_90d: 2,
commits_total: 3,
last_commit_ts: None,
},
FileHeat {
repo: "C".into(),
file: "src/b.rs".into(),
commits_30d: 0,
commits_90d: 0,
commits_total: 4,
last_commit_ts: None,
},
];
let m = NavigatorModel::build(&dag, &symbols, &heat, &[]);
// Crate recency rolled up from its files.
assert_eq!(m.node("crate:app").unwrap().recency.commits_30d, 5);
assert_eq!(m.node("crate:base").unwrap().recency.commits_30d, 0);
// Travel: hottest first, cold `base` excluded entirely.
let tour = m.travel(&Travel::LatestChanges);
assert_eq!(tour, vec!["crate:app", "crate:lib"]);
// Repo rollup too.
assert_eq!(m.node("repo:A").unwrap().recency.commits_30d, 5);
}
#[test]
fn state_json_exposes_levels_edges_and_travels_for_facett() {
let dag = chain_dag();
let symbols = vec![sym("app", "app::run", "run", "src/run.rs", 10)];
let m = NavigatorModel::build(&dag, &symbols, &[], &[]);
let j = m.state_json();
assert_eq!(j["level_counts"]["constellation"], 1);
assert_eq!(j["level_counts"]["crate"], 3);
assert_eq!(j["edge_kinds"]["crate_dep"], 2);
assert_eq!(j["release_order"][0], "base");
assert_eq!(j["travels"]["release_wave"][0], "crate:base");
// every node carries its travel attributes.
let base = j["nodes"]
.as_array()
.unwrap()
.iter()
.find(|n| n["id"] == "crate:base")
.unwrap();
assert_eq!(base["blast"], 2);
assert_eq!(base["bottleneck_rank"], 1);
assert_eq!(base["registry_state"], "unknown");
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"viz/navigator (warehouse constellation model)",
"navigator_model_built",
true,
&format!(
"nodes={} edges={} crates={} release_order={:?}",
m.node_count(),
m.edge_count(),
j["level_counts"]["crate"],
m.release_order(),
),
);
}
}