// 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; /// 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 prefix = format!("{}#", target_key); let sections: Vec<(&str, &str)> = store.nodes.iter() .filter(|(k, _)| k.starts_with(&prefix)) .map(|(k, n)| (k.as_str(), n.content.as_str())) .collect(); if sections.is_empty() { return target_key.to_string(); } let mut best_section = ""; let mut best_sim = 0.0f32; for (section_key, section_content) in §ions { let sim = similarity::cosine_similarity(source_content, section_content); if sim > best_sim { best_sim = sim; best_section = section_key; } } // Threshold: only refine if there's a meaningful match if best_sim > 0.05 && !best_section.is_empty() { best_section.to_string() } 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 prefix = format!("{}#", hub_key); let sections: Vec<(&str, &str)> = store.nodes.iter() .filter(|(k, _)| k.starts_with(&prefix)) .map(|(k, n)| (k.as_str(), n.content.as_str())) .collect(); if sections.is_empty() { return None; } // Get all neighbors of the hub let neighbors = graph.neighbors(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 let mut best_section = ""; let mut best_sim = 0.0f32; for (section_key, section_content) in §ions { let sim = similarity::cosine_similarity(neighbor_content, section_content); if sim > best_sim { best_sim = sim; best_section = section_key; } } // Only propose move if there's a reasonable match if best_sim > 0.05 && !best_section.is_empty() { let snippet = crate::util::first_n_chars( neighbor_content.lines() .find(|l| !l.is_empty() && !l.starts_with("