// Graph topology mutations: hub differentiation, triangle closure, // orphan linking, and link refinement. These modify the store. use crate::store::{Store, new_relation}; use crate::graph::Graph; use crate::similarity; /// Collect (key, content) pairs for all section children of a file-level node. fn section_children<'a>(store: &'a Store, file_key: &str) -> Vec<(&'a str, &'a str)> { let prefix = format!("{}#", file_key); store.nodes.iter() .filter(|(k, _)| k.starts_with(&prefix)) .map(|(k, n)| (k.as_str(), n.content.as_str())) .collect() } /// Find the best matching candidate by cosine similarity against content. /// Returns (key, similarity) if any candidate exceeds threshold. fn best_match(candidates: &[(&str, &str)], content: &str, threshold: f32) -> Option<(String, f32)> { let (best_key, best_sim) = candidates.iter() .map(|(key, text)| (*key, similarity::cosine_similarity(content, text))) .max_by(|a, b| a.1.total_cmp(&b.1))?; if best_sim > threshold { Some((best_key.to_string(), best_sim)) } else { None } } /// Refine a link target: if the target is a file-level node with section /// children, find the best-matching section by cosine similarity against /// the source content. Returns the original key if no sections exist or /// no section matches above threshold. /// /// This prevents hub formation at link creation time — every new link /// targets the most specific available node. pub fn refine_target(store: &Store, source_content: &str, target_key: &str) -> String { // Only refine file-level nodes (no # in key) if target_key.contains('#') { return target_key.to_string(); } let sections = section_children(store, target_key); if sections.is_empty() { return target_key.to_string(); } best_match(§ions, source_content, 0.05) .map(|(key, _)| key) .unwrap_or_else(|| target_key.to_string()) } /// A proposed link move: from hub→neighbor to section→neighbor pub struct LinkMove { pub neighbor_key: String, pub from_hub: String, pub to_section: String, pub similarity: f32, pub neighbor_snippet: String, } /// Analyze a hub node and propose redistributing its links to child sections. /// /// Returns None if the node isn't a hub or has no sections to redistribute to. pub fn differentiate_hub(store: &Store, hub_key: &str) -> Option> { let graph = store.build_graph(); differentiate_hub_with_graph(store, hub_key, &graph) } /// Like differentiate_hub but uses a pre-built graph. pub fn differentiate_hub_with_graph(store: &Store, hub_key: &str, graph: &Graph) -> Option> { let degree = graph.degree(hub_key); // Only differentiate actual hubs if degree < 20 { return None; } // Only works on file-level nodes that have section children if hub_key.contains('#') { return None; } let sections = section_children(store, hub_key); if sections.is_empty() { return None; } // Get all neighbors of the hub let neighbors = graph.neighbors(hub_key); let prefix = format!("{}#", hub_key); let mut moves = Vec::new(); for (neighbor_key, _strength) in &neighbors { // Skip section children — they should stay linked to parent if neighbor_key.starts_with(&prefix) { continue; } let neighbor_content = match store.nodes.get(neighbor_key.as_str()) { Some(n) => &n.content, None => continue, }; // Find best-matching section by content similarity if let Some((best_section, best_sim)) = best_match(§ions, neighbor_content, 0.05) { let snippet = crate::util::first_n_chars( neighbor_content.lines() .find(|l| !l.is_empty() && !l.starts_with("