Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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(),
            ),
        );
    }
}