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

nordisk/nornir

src/mimir_lens/name_collision.rs

//! **Lens — `Mimir.NameCollision`: graph-soundness self-audit of the code graph.**
//!
//! Idea #34 (`mimir-ideas-2026-07-26.md`, EASY / graph-soundness). Every
//! reachability answer Mímir gives walks call edges; the audit asks how many of
//! those edges point at a callee the graph cannot tell apart from another item.
//!
//! **What changed with Mímir v2.** The v1 graph matched a callee to a definition
//! by its leaf NAME (`new` in 17 repos, `main` in 16), so every same-named
//! function collapsed into one node and impurity / coverage / trust bled across
//! functions that shared nothing but a name. The v2 graph's edges are rustc's own
//! resolution onto node ids: that conflation is gone by construction. What is
//! left to measure is where the ID itself is not unique — a node id the fold had
//! to MERGE because several rows describe it differently ([`MergedKey`]): two
//! items under one id, or two compiled units that disagree about one item.
//!
//! So the headline `conflation_pct` is now `edges into a merged id / edges that
//! resolve to a described node`, and `leaf_conflation_pct` stays beside it as the
//! explicitly-labelled baseline: the share of the same edges a leaf-name matcher
//! WOULD conflate (the callee's name is defined more than once). The distance
//! between the two is what resolving bought.
//!
//! Pure fold. Anti-silent-empty: a room without definitions or without CALLS
//! edges reports a MISSING FEED via [`evidence_note`], never a false "no
//! collisions".

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use super::sources::{Feed, evidence_note};

/// The node kinds a CALLS edge lands on — the callable surface. Kept as data so
/// the audit scope is one obvious list.
pub const CALLABLE_KINDS: &[&str] = &["fn", "method"];

/// One ambiguous callee: a node id that names more than one definition.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Collision {
    /// The item's path, as the merged node reads.
    pub name: String,
    /// Distinct definitions the fold merged under the id.
    pub distinct_defs: usize,
    /// Distinct repos among the rows — `> 1` ⇒ two repos disagree about one id.
    pub distinct_repos: usize,
    /// CALLS edges landing on the id — they cannot say which definition they mean.
    pub conflated_edges: usize,
    /// Whether the definitions span more than one repo (the worst class).
    pub cross_repo: bool,
    /// The definition sites / owners, capped.
    pub sites: Vec<String>,
}

impl Collision {
    pub fn explain(&self) -> String {
        format!(
            "`{}` is ONE node id with {} different definitions across {} repo(s){}; {} call edge(s) to it are \
             AMBIGUOUS — the graph cannot say which definition they mean",
            self.name,
            self.distinct_defs,
            self.distinct_repos,
            if self.cross_repo { " (CROSS-REPO)" } else { "" },
            self.conflated_edges,
        )
    }
}

/// The whole reading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NameCollisionReport {
    /// Distinct callable leaf names defined.
    pub callable_names: usize,
    /// Names defined in ≥2 distinct places — what a leaf-name matcher would conflate.
    pub colliding_names: usize,
    /// Colliding names that span ≥2 repos.
    pub cross_repo_colliders: usize,
    /// CALLS edges examined.
    pub edges_seen: usize,
    /// Edges landing on a node some row describes — `pinned + conflated`.
    pub resolved_edges: usize,
    /// Edges landing on an id that names EXACTLY ONE definition — a true identity.
    pub pinned_edges: usize,
    /// Edges landing on a merged id — the genuinely unsound fraction of the graph.
    pub conflated_edges: usize,
    /// Edges landing on an id no row describes at all. Reported so a
    /// `resolved_edges` shortfall reads as coverage, not as soundness.
    pub unresolved_edges: usize,
    /// `conflated_edges / resolved_edges` as a percentage. THE headline.
    pub conflation_pct: f64,
    /// The share of `edges_seen` whose callee NAME is defined more than once — what
    /// a bare-leaf matcher would conflate. A labelled baseline, not what the graph does.
    pub leaf_conflation_pct: f64,
    pub worst: Vec<Collision>,
    pub feeds: Vec<Feed>,
    pub evidence_note: String,
    pub model_attribution: String,
}

pub const ATTRIBUTION: &str = "Composition of nornir primitives: the code room's node ids \
    (rustc-resolved monikers) × its record of the ids the fold MERGED (several rows, one \
    id, different payloads) × the CALLS edges landing on them. The reported conflation is \
    the graph's real residual ambiguity; the leaf-name figure beside it is the baseline a \
    name matcher would have. Measures how much of the graph's reachability is unsound — a \
    self-audit no external tool runs on its own graph.";

impl NameCollisionReport {
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "name_collision",
            "callable_names": self.callable_names,
            "colliding_names": self.colliding_names,
            "cross_repo_colliders": self.cross_repo_colliders,
            "edges_seen": self.edges_seen,
            "resolved_edges": self.resolved_edges,
            "pinned_edges": self.pinned_edges,
            "conflated_edges": self.conflated_edges,
            "unresolved_edges": self.unresolved_edges,
            "conflation_pct": self.conflation_pct,
            "leaf_conflation_pct": self.leaf_conflation_pct,
            "evidence_note": self.evidence_note,
            "model_attribution": self.model_attribution,
            "feeds": self.feeds,
            "worst": self.worst.iter().map(|c| serde_json::json!({
                "name": c.name,
                "distinct_defs": c.distinct_defs,
                "distinct_repos": c.distinct_repos,
                "conflated_edges": c.conflated_edges,
                "cross_repo": c.cross_repo,
                "sites": c.sites,
                "explain": c.explain(),
            })).collect::<Vec<_>>(),
        })
    }
}

/// A cap on the sample sites carried per collision (the full set can be hundreds).
const MAX_SITES: usize = 6;

/// One callable definition of the workspace: its repo and its own name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Def {
    pub repo: String,
    /// The item's own name (`new`, `parse`).
    pub name: String,
}

/// One callable node id the fold merged: several rows, one id, different payloads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergedKey {
    /// The item's path.
    pub path: String,
    /// Distinct payloads among the rows (≥ 2), or the rows a dump repeated.
    pub definitions: usize,
    /// The repos whose rows describe the id.
    pub repos: BTreeSet<String>,
    /// CALLS edges landing on it.
    pub inbound_calls: usize,
    /// Its owners (`crate@version:unit`) and positions.
    pub sites: Vec<String>,
}

/// The CALLS edges of the audit.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Calls {
    pub seen: usize,
    /// Landing on an id no row describes.
    pub unresolved: usize,
    /// Per callee NAME (the leaf), the edges landing on a callee so named.
    pub by_callee_name: BTreeMap<String, usize>,
}

/// **Audit the identity of the callable nodes.** Pure fold — no IO.
/// `repo_filter` restricts the DEFINITIONS considered (""=all).
pub fn analyze(defs: &[Def], merged: &[MergedKey], calls: &Calls, feeds: Vec<Feed>, repo_filter: &str, top_n: usize) -> NameCollisionReport {
    // ── the leaf baseline: names defined more than once ─────────────────────────
    let mut count_of: BTreeMap<&str, usize> = BTreeMap::new();
    let mut repos_of: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
    for d in defs {
        if d.name.is_empty() || (!repo_filter.is_empty() && d.repo != repo_filter) {
            continue;
        }
        *count_of.entry(&d.name).or_default() += 1;
        repos_of.entry(&d.name).or_default().insert(&d.repo);
    }
    let callable_names = count_of.len();
    let colliders: BTreeSet<&str> = count_of.iter().filter(|(_, n)| **n >= 2).map(|(k, _)| *k).collect();
    let colliding_names = colliders.len();
    let cross_repo_colliders = colliders.iter().filter(|n| repos_of.get(*n).is_some_and(|r| r.len() > 1)).count();
    let leaf_conflated: usize = calls.by_callee_name.iter().filter(|(n, _)| colliders.contains(n.as_str())).map(|(_, c)| *c).sum();

    // ── the edges the graph cannot pin: the ones landing on a merged id ─────────
    let merged: Vec<&MergedKey> = merged.iter().filter(|m| repo_filter.is_empty() || m.repos.contains(repo_filter)).collect();
    let edges_seen = calls.seen;
    let conflated_edges: usize = merged.iter().map(|m| m.inbound_calls).sum();
    let unresolved_edges = calls.unresolved;
    let resolved_edges = edges_seen.saturating_sub(unresolved_edges);
    let pinned_edges = resolved_edges.saturating_sub(conflated_edges);
    let conflation_pct = if resolved_edges > 0 { conflated_edges as f64 * 100.0 / resolved_edges as f64 } else { 0.0 };
    let leaf_conflation_pct = if edges_seen > 0 { leaf_conflated as f64 * 100.0 / edges_seen as f64 } else { 0.0 };

    // ── assemble + rank ───────────────────────────────────────────────────────────
    let mut worst: Vec<Collision> = merged
        .iter()
        .map(|m| Collision {
            name: m.path.clone(),
            distinct_defs: m.definitions,
            distinct_repos: m.repos.len(),
            conflated_edges: m.inbound_calls,
            cross_repo: m.repos.len() > 1,
            sites: m.sites.iter().take(MAX_SITES).cloned().collect(),
        })
        .collect();
    // Cross-repo first, then most conflated fan-in, then most definitions, then name.
    worst.sort_by(|a, b| {
        (b.cross_repo as u8)
            .cmp(&(a.cross_repo as u8))
            .then_with(|| b.conflated_edges.cmp(&a.conflated_edges))
            .then_with(|| b.distinct_defs.cmp(&a.distinct_defs))
            .then_with(|| a.name.cmp(&b.name))
    });
    worst.truncate(top_n);

    NameCollisionReport {
        callable_names,
        colliding_names,
        cross_repo_colliders,
        edges_seen,
        resolved_edges,
        pinned_edges,
        conflated_edges,
        unresolved_edges,
        conflation_pct,
        leaf_conflation_pct,
        worst,
        evidence_note: evidence_note(&feeds, "NameCollision (graph soundness)"),
        model_attribution: ATTRIBUTION.to_string(),
        feeds,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn def(repo: &str, name: &str) -> Def {
        Def { repo: repo.into(), name: name.into() }
    }

    fn merged(path: &str, repos: &[&str], inbound: usize) -> MergedKey {
        MergedKey { path: path.into(), definitions: 2, repos: repos.iter().map(|r| r.to_string()).collect(), inbound_calls: inbound, sites: vec!["a@0.1.0:lib".into(), "a@0.1.0:lib+test".into()] }
    }

    fn calls(seen: usize, unresolved: usize, by_name: &[(&str, usize)]) -> Calls {
        Calls { seen, unresolved, by_callee_name: by_name.iter().map(|(n, c)| (n.to_string(), *c)).collect() }
    }

    fn feeds() -> Vec<Feed> {
        vec![Feed { table: "code_edges:CALLS".into(), rows: 10, root: "code room".into(), missing: false }]
    }

    /// **Red-when-reverted core.** Same-named functions are NOT a collision any
    /// more — the edges to them are pinned by id. Only a merged id is a finding,
    /// and the headline counts the edges landing on it. Make the fold rank leaf
    /// colliders again and `new` shows up in `worst`; count leaf colliders into
    /// `conflated_edges` and the headline jumps from 10 % to 70 %.
    #[test]
    fn same_named_fns_are_pinned_and_only_a_merged_id_is_conflated() {
        let defs = [def("a", "new"), def("a", "new"), def("b", "new"), def("a", "parse"), def("a", "helper")];
        let m = [merged("a::cart::helper", &["a"], 1)];
        let r = analyze(&defs, &m, &calls(10, 0, &[("new", 6), ("parse", 3), ("helper", 1)]), feeds(), "", 10);
        assert_eq!((r.callable_names, r.colliding_names, r.cross_repo_colliders), (3, 1, 1));
        assert_eq!((r.edges_seen, r.resolved_edges, r.pinned_edges, r.conflated_edges), (10, 10, 9, 1));
        assert_eq!(r.conflation_pct, 10.0);
        assert_eq!(r.leaf_conflation_pct, 60.0, "what a leaf matcher would have conflated");
        assert_eq!(r.worst.len(), 1);
        assert_eq!(r.worst[0].name, "a::cart::helper");
        assert!(r.worst[0].explain().contains("ONE node id"), "{}", r.worst[0].explain());
    }

    #[test]
    fn a_merged_id_two_repos_describe_ranks_above_a_local_one() {
        let m = [merged("a::local", &["a"], 50), merged("a::shared", &["a", "b"], 2)];
        let r = analyze(&[], &m, &calls(100, 0, &[]), feeds(), "", 10);
        assert_eq!(r.worst[0].name, "a::shared");
        assert!(r.worst[0].cross_repo && !r.worst[1].cross_repo);
        assert_eq!(r.conflated_edges, 52);
    }

    #[test]
    fn edges_onto_an_undescribed_id_are_coverage_not_soundness() {
        let r = analyze(&[], &[merged("a::x", &["a"], 2)], &calls(10, 6, &[]), feeds(), "", 10);
        assert_eq!((r.resolved_edges, r.pinned_edges, r.unresolved_edges), (4, 2, 6));
        assert_eq!(r.conflation_pct, 50.0);
    }

    #[test]
    fn the_repo_filter_restricts_definitions_and_merged_ids() {
        let defs = [def("a", "new"), def("a", "new"), def("b", "run"), def("b", "run")];
        let m = [merged("a::x", &["a"], 1), merged("b::y", &["b"], 1)];
        let r = analyze(&defs, &m, &calls(4, 0, &[]), feeds(), "b", 10);
        assert_eq!((r.callable_names, r.colliding_names), (1, 1));
        assert_eq!(r.worst.len(), 1);
        assert_eq!(r.worst[0].name, "b::y");
    }

    /// Anti-silent-empty: no CALLS edges reads as a MISSING FEED, not "sound".
    #[test]
    fn a_room_without_calls_reads_as_missing_not_sound() {
        let feeds = vec![Feed { table: "code_edges:CALLS".into(), rows: 0, root: "code room".into(), missing: true }];
        let r = analyze(&[], &[], &Calls::default(), feeds, "", 10);
        assert_eq!(r.conflation_pct, 0.0);
        assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
        assert!(r.evidence_note.contains("code_edges:CALLS"), "{}", r.evidence_note);
    }
}