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