use uuid::Uuid;
use crate::domain::entities::{EmphasisNode, GraphEdge, GraphNode};
use crate::domain::value_objects::LinkageConfig;
#[derive(Debug, Clone, Default)]
pub struct GraphBuildResult {
pub nodes: Vec<GraphNode>,
pub co_occurrence_edges: Vec<GraphEdge>,
pub doc_node: Option<GraphNode>,
pub extraction_edges: Vec<GraphEdge>,
pub stats: BuildStats,
}
#[derive(Debug, Clone, Default)]
pub struct BuildStats {
pub emphasis_processed: usize,
pub nodes_created: usize,
pub nodes_skipped: usize,
pub co_occurrence_edges: usize,
}
pub struct GraphBuilder {
config: LinkageConfig,
}
impl GraphBuilder {
pub fn new(config: LinkageConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self {
config: LinkageConfig::default(),
}
}
pub fn build_from_emphasis(
&self,
rei_id: Uuid,
doc_id: Uuid,
doc_title: &str,
emphasis_nodes: &[EmphasisNode],
) -> GraphBuildResult {
let mut result = GraphBuildResult::default();
result.stats.emphasis_processed = emphasis_nodes.len();
let doc_node = GraphNode::document(rei_id, doc_id, doc_title.to_string());
result.doc_node = Some(doc_node.clone());
let mut created_nodes: Vec<GraphNode> = Vec::new();
for emphasis in emphasis_nodes {
let weight = self.config.weight_for_style(&emphasis.style);
if self.config.should_create_node(weight) {
let node = GraphNode::concept(rei_id, emphasis.text.clone(), weight, Some(doc_id));
let extraction_edge = GraphEdge::extracted_from(node.id, doc_id);
result.extraction_edges.push(extraction_edge);
created_nodes.push(node);
result.stats.nodes_created += 1;
} else {
result.stats.nodes_skipped += 1;
}
}
let co_occurrence_edges = self.detect_co_occurrences(&created_nodes, emphasis_nodes);
result.stats.co_occurrence_edges = co_occurrence_edges.len();
result.co_occurrence_edges = co_occurrence_edges;
result.nodes = created_nodes;
result
}
fn detect_co_occurrences(
&self,
nodes: &[GraphNode],
emphasis_nodes: &[EmphasisNode],
) -> Vec<GraphEdge> {
let mut edges = Vec::new();
let node_map: std::collections::HashMap<&str, &GraphNode> =
nodes.iter().map(|n| (n.text.as_str(), n)).collect();
let mut line_groups: std::collections::HashMap<usize, Vec<&EmphasisNode>> =
std::collections::HashMap::new();
for emphasis in emphasis_nodes {
let line = emphasis.position.line;
let group_key = line / 5;
line_groups.entry(group_key).or_default().push(emphasis);
}
for (_group, group_nodes) in line_groups.iter() {
if group_nodes.len() < 2 {
continue;
}
for i in 0..group_nodes.len() {
for j in (i + 1)..group_nodes.len() {
let node_a = node_map.get(group_nodes[i].text.as_str());
let node_b = node_map.get(group_nodes[j].text.as_str());
if let (Some(a), Some(b)) = (node_a, node_b) {
if a.id != b.id {
let strength = self
.calculate_co_occurrence_strength(group_nodes[i], group_nodes[j]);
let edge = GraphEdge::co_occurs_with(a.id, b.id, strength);
edges.push(edge);
}
}
}
}
}
self.limit_edges_per_node(&mut edges);
edges
}
fn calculate_co_occurrence_strength(&self, a: &EmphasisNode, b: &EmphasisNode) -> f32 {
let weight_a = self.config.weight_for_style(&a.style);
let weight_b = self.config.weight_for_style(&b.style);
let base = self.config.linkage_strategy.co_occurrence_weight;
let weight_factor = (weight_a + weight_b) / 2.0;
let line_distance = (a.position.line as i32 - b.position.line as i32).unsigned_abs() as f32;
let proximity_factor = 1.0 / (1.0 + line_distance * 0.1);
(base * weight_factor * proximity_factor).min(1.0)
}
fn limit_edges_per_node(&self, edges: &mut Vec<GraphEdge>) {
let max_edges = self.config.linkage_strategy.max_edges_per_node;
let mut edge_count: std::collections::HashMap<Uuid, usize> =
std::collections::HashMap::new();
edges.sort_by(|a, b| b.strength.partial_cmp(&a.strength).unwrap());
edges.retain(|edge| {
let from_count = *edge_count.get(&edge.from_id).unwrap_or(&0);
let to_count = *edge_count.get(&edge.to_id).unwrap_or(&0);
if from_count < max_edges && to_count < max_edges {
*edge_count.entry(edge.from_id).or_insert(0) += 1;
*edge_count.entry(edge.to_id).or_insert(0) += 1;
true
} else {
false
}
});
}
pub fn config(&self) -> &LinkageConfig {
&self.config
}
pub fn set_config(&mut self, config: LinkageConfig) {
self.config = config;
}
}
impl Default for GraphBuilder {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::TextPosition;
use crate::domain::value_objects::EmphasisStyle;
fn create_emphasis_node(
doc_id: Uuid,
text: &str,
style: EmphasisStyle,
line: usize,
) -> EmphasisNode {
EmphasisNode::new(
doc_id,
text.to_string(),
style,
TextPosition::new(0, line, 1),
format!("context around {}", text),
)
}
#[test]
fn test_build_from_emphasis() {
let builder = GraphBuilder::with_defaults();
let rei_id = Uuid::new_v4();
let doc_id = Uuid::new_v4();
let emphasis_nodes = vec![
create_emphasis_node(doc_id, "important concept", EmphasisStyle::Bold, 1),
create_emphasis_node(doc_id, "related idea", EmphasisStyle::Italic, 2),
create_emphasis_node(doc_id, "code example", EmphasisStyle::Code, 5),
];
let result = builder.build_from_emphasis(rei_id, doc_id, "Test Doc", &emphasis_nodes);
assert!(result.doc_node.is_some());
assert_eq!(result.stats.emphasis_processed, 3);
assert_eq!(result.stats.nodes_created, 3); assert!(!result.extraction_edges.is_empty());
}
#[test]
fn test_weight_filtering() {
let mut config = LinkageConfig::default();
config.linkage_strategy.min_node_weight = 0.75;
let builder = GraphBuilder::new(config);
let rei_id = Uuid::new_v4();
let doc_id = Uuid::new_v4();
let emphasis_nodes = vec![
create_emphasis_node(doc_id, "bold text", EmphasisStyle::Bold, 1), create_emphasis_node(doc_id, "italic text", EmphasisStyle::Italic, 2), ];
let result = builder.build_from_emphasis(rei_id, doc_id, "Test", &emphasis_nodes);
assert_eq!(result.stats.nodes_created, 1);
assert_eq!(result.stats.nodes_skipped, 1);
assert_eq!(result.nodes[0].text, "bold text");
}
#[test]
fn test_co_occurrence_detection() {
let builder = GraphBuilder::with_defaults();
let rei_id = Uuid::new_v4();
let doc_id = Uuid::new_v4();
let emphasis_nodes = vec![
create_emphasis_node(doc_id, "concept A", EmphasisStyle::Bold, 1),
create_emphasis_node(doc_id, "concept B", EmphasisStyle::Bold, 1),
create_emphasis_node(doc_id, "concept C", EmphasisStyle::Bold, 100), ];
let result = builder.build_from_emphasis(rei_id, doc_id, "Test", &emphasis_nodes);
assert!(!result.co_occurrence_edges.is_empty());
let ab_edge = result.co_occurrence_edges.iter().find(|e| {
(e.from_id == result.nodes[0].id && e.to_id == result.nodes[1].id)
|| (e.from_id == result.nodes[1].id && e.to_id == result.nodes[0].id)
});
assert!(ab_edge.is_some());
}
#[test]
fn test_edge_limit() {
let mut config = LinkageConfig::default();
config.linkage_strategy.max_edges_per_node = 2;
let builder = GraphBuilder::new(config);
let rei_id = Uuid::new_v4();
let doc_id = Uuid::new_v4();
let emphasis_nodes: Vec<EmphasisNode> = (0..10)
.map(|i| {
create_emphasis_node(doc_id, &format!("concept {}", i), EmphasisStyle::Bold, 1)
})
.collect();
let result = builder.build_from_emphasis(rei_id, doc_id, "Test", &emphasis_nodes);
let mut edge_count: std::collections::HashMap<Uuid, usize> =
std::collections::HashMap::new();
for edge in &result.co_occurrence_edges {
*edge_count.entry(edge.from_id).or_insert(0) += 1;
*edge_count.entry(edge.to_id).or_insert(0) += 1;
}
for count in edge_count.values() {
assert!(*count <= 2);
}
}
}