use std::collections::{HashMap, HashSet};
use super::graph_model::{ContextGraph, ContextNode};
#[derive(Debug, Clone, Default)]
pub struct MergeReport {
pub nodes_added: u32,
pub nodes_updated: u32,
pub nodes_superseded: u32,
pub edges_added: u32,
pub edges_merged: u32,
pub conflicts: Vec<String>,
}
pub fn merge_graphs(base: &mut ContextGraph, incoming: &ContextGraph) -> MergeReport {
let mut report = MergeReport::default();
let mut existing_ids: HashSet<String> = base.nodes.iter().map(|n| n.id.clone()).collect();
let superseded = collect_superseded(incoming);
for node in &incoming.nodes {
if superseded.contains(&node.id) {
continue;
}
if existing_ids.contains(&node.id) {
merge_existing_node(base, node, &mut report);
} else {
base.nodes.push(node.clone());
existing_ids.insert(node.id.clone());
report.nodes_added += 1;
}
}
for id in &superseded {
if let Some(n) = base.nodes.iter_mut().find(|n| n.id == *id) {
n.activation = 0.0;
report.nodes_superseded += 1;
}
}
detect_conflicts(base, incoming, &mut report);
let mut edge_index: HashMap<(String, String, String), usize> = HashMap::new();
for (i, e) in base.edges.iter().enumerate() {
edge_index.insert((e.from.clone(), e.to.clone(), e.edge_type.clone()), i);
}
for edge in &incoming.edges {
if superseded.contains(&edge.from) || superseded.contains(&edge.to) {
continue;
}
if !existing_ids.contains(&edge.from) || !existing_ids.contains(&edge.to) {
continue;
}
let key = (edge.from.clone(), edge.to.clone(), edge.edge_type.clone());
if let Some(&idx) = edge_index.get(&key) {
let existing = &mut base.edges[idx];
existing.weight = f64::midpoint(existing.weight, edge.weight);
existing.coactivations += edge.coactivations;
report.edges_merged += 1;
} else {
let new_idx = base.edges.len();
base.edges.push(edge.clone());
edge_index.insert(key, new_idx);
report.edges_added += 1;
}
}
report
}
fn collect_superseded(graph: &ContextGraph) -> HashSet<String> {
let mut superseded = HashSet::new();
for node in &graph.nodes {
if let Some(ref s) = node.supersedes {
superseded.insert(s.clone());
}
}
superseded
}
fn merge_existing_node(base: &mut ContextGraph, incoming: &ContextNode, report: &mut MergeReport) {
let Some(existing) = base.nodes.iter_mut().find(|n| n.id == incoming.id) else {
return;
};
if incoming.activation > existing.activation {
existing.activation = incoming.activation;
}
if let Some(ref inc_cat) = incoming.category {
if existing.category.is_none() {
existing.category = Some(inc_cat.clone());
}
}
if let Some(inc_conf) = incoming.confidence {
match existing.confidence {
Some(ex_conf) if inc_conf > ex_conf => existing.confidence = Some(inc_conf),
None => existing.confidence = Some(inc_conf),
_ => {}
}
}
report.nodes_updated += 1;
}
fn detect_conflicts(base: &ContextGraph, incoming: &ContextGraph, report: &mut MergeReport) {
let contradiction_targets: HashSet<&str> = incoming
.edges
.iter()
.filter(|e| e.edge_type == "contradicts")
.map(|e| e.to.as_str())
.collect();
let base_ids: HashSet<&str> = base.nodes.iter().map(|n| n.id.as_str()).collect();
for target in contradiction_targets {
if base_ids.contains(target) {
report.conflicts.push(format!(
"incoming graph contradicts existing node '{target}'"
));
}
}
}
#[cfg(test)]
mod tests {
use super::super::graph_model::ContextEdge;
use super::*;
fn node(id: &str, node_type: &str, activation: f64) -> ContextNode {
ContextNode {
id: id.into(),
node_type: node_type.into(),
content: format!("content of {id}"),
activation,
category: None,
source: None,
created_at: None,
decay_half_life_days: None,
blob_ref: None,
file_path: None,
line_start: None,
line_end: None,
confidence: None,
supersedes: None,
}
}
fn edge(from: &str, to: &str, edge_type: &str, weight: f64) -> ContextEdge {
ContextEdge {
from: from.into(),
to: to.into(),
edge_type: edge_type.into(),
weight,
coactivations: 1,
metadata: None,
}
}
#[test]
fn merge_adds_new_nodes() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
let mut incoming = ContextGraph::new();
incoming.add_node(node("b", "gotcha", 0.9));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_added, 1);
assert_eq!(base.nodes.len(), 2);
}
#[test]
fn merge_updates_existing_activation() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 0.5));
let mut incoming = ContextGraph::new();
incoming.add_node(node("a", "fact", 0.8));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_updated, 1);
assert!((base.nodes[0].activation - 0.8).abs() < 0.001);
}
#[test]
fn merge_averages_shared_edge_weights() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
base.add_node(node("b", "fact", 1.0));
base.add_edge(edge("a", "b", "supports", 0.6));
let mut incoming = ContextGraph::new();
incoming.add_node(node("a", "fact", 1.0));
incoming.add_node(node("b", "fact", 1.0));
incoming.add_edge(edge("a", "b", "supports", 1.0));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.edges_merged, 1);
assert!((base.edges[0].weight - 0.8).abs() < 0.001);
assert_eq!(base.edges[0].coactivations, 2);
}
#[test]
fn merge_adds_new_edges() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
base.add_node(node("b", "fact", 1.0));
let mut incoming = ContextGraph::new();
incoming.add_node(node("a", "fact", 1.0));
incoming.add_node(node("b", "fact", 1.0));
incoming.add_edge(edge("a", "b", "supports", 0.9));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.edges_added, 1);
assert_eq!(base.edges.len(), 1);
}
#[test]
fn supersedes_deactivates_node() {
let mut base = ContextGraph::new();
base.add_node(node("old_fact", "fact", 1.0));
let mut incoming = ContextGraph::new();
let mut new_node = node("new_fact", "fact", 1.0);
new_node.supersedes = Some("old_fact".into());
incoming.add_node(new_node);
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_superseded, 1);
assert_eq!(report.nodes_added, 1);
assert!((base.node_by_id("old_fact").unwrap().activation).abs() < 0.001);
}
#[test]
fn detects_contradictions() {
let mut base = ContextGraph::new();
base.add_node(node("existing", "fact", 1.0));
let mut incoming = ContextGraph::new();
incoming.add_node(node("new", "fact", 1.0));
incoming.add_node(node("existing", "fact", 1.0));
incoming.add_edge(ContextEdge {
from: "new".into(),
to: "existing".into(),
edge_type: "contradicts".into(),
weight: 1.0,
coactivations: 0,
metadata: None,
});
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.conflicts.len(), 1);
assert!(report.conflicts[0].contains("contradicts"));
}
#[test]
fn edges_to_missing_nodes_skipped() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
let mut incoming = ContextGraph::new();
incoming.add_edge(edge("a", "missing", "supports", 1.0));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.edges_added, 0);
assert!(base.edges.is_empty());
}
#[test]
fn merge_is_idempotent() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 0.8));
base.add_node(node("b", "gotcha", 0.9));
base.add_edge(edge("a", "b", "has_gotcha", 0.7));
let incoming = base.clone();
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_added, 0);
assert_eq!(report.nodes_updated, 2);
assert_eq!(report.edges_merged, 1);
assert_eq!(report.edges_added, 0);
assert_eq!(base.nodes.len(), 2);
assert_eq!(base.edges.len(), 1);
}
#[test]
fn multi_merge_three_packages() {
let mut base = ContextGraph::new();
base.add_node(node("shared", "fact", 0.5));
let mut pkg_a = ContextGraph::new();
pkg_a.add_node(node("shared", "fact", 0.7));
pkg_a.add_node(node("from_a", "pattern", 0.9));
pkg_a.add_edge(edge("shared", "from_a", "supports", 0.8));
let mut pkg_b = ContextGraph::new();
pkg_b.add_node(node("shared", "fact", 0.6));
pkg_b.add_node(node("from_b", "gotcha", 1.0));
pkg_b.add_edge(edge("shared", "from_b", "has_gotcha", 0.9));
merge_graphs(&mut base, &pkg_a);
let report_b = merge_graphs(&mut base, &pkg_b);
assert_eq!(base.nodes.len(), 3);
assert_eq!(base.edges.len(), 2);
assert!(base.node_by_id("from_a").is_some());
assert!(base.node_by_id("from_b").is_some());
assert_eq!(report_b.nodes_added, 1);
}
#[test]
fn lower_activation_does_not_downgrade() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 0.9));
let mut incoming = ContextGraph::new();
incoming.add_node(node("a", "fact", 0.3));
merge_graphs(&mut base, &incoming);
assert!((base.nodes[0].activation - 0.9).abs() < 0.001);
}
#[test]
fn confidence_propagation() {
let mut base = ContextGraph::new();
let mut n = node("a", "fact", 1.0);
n.confidence = Some(0.5);
base.add_node(n);
let mut incoming = ContextGraph::new();
let mut n2 = node("a", "fact", 1.0);
n2.confidence = Some(0.9);
incoming.add_node(n2);
merge_graphs(&mut base, &incoming);
assert_eq!(base.nodes[0].confidence, Some(0.9));
}
#[test]
fn confidence_not_downgraded() {
let mut base = ContextGraph::new();
let mut n = node("a", "fact", 1.0);
n.confidence = Some(0.8);
base.add_node(n);
let mut incoming = ContextGraph::new();
let mut n2 = node("a", "fact", 1.0);
n2.confidence = Some(0.3);
incoming.add_node(n2);
merge_graphs(&mut base, &incoming);
assert_eq!(base.nodes[0].confidence, Some(0.8));
}
#[test]
fn category_filled_from_incoming() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
assert!(base.nodes[0].category.is_none());
let mut incoming = ContextGraph::new();
let mut n = node("a", "fact", 1.0);
n.category = Some("security".into());
incoming.add_node(n);
merge_graphs(&mut base, &incoming);
assert_eq!(base.nodes[0].category.as_deref(), Some("security"));
}
#[test]
fn superseded_edges_are_dropped() {
let mut base = ContextGraph::new();
base.add_node(node("old", "fact", 1.0));
base.add_node(node("other", "fact", 1.0));
base.add_edge(edge("old", "other", "supports", 0.5));
let mut incoming = ContextGraph::new();
let mut new_node = node("replacement", "fact", 1.0);
new_node.supersedes = Some("old".into());
incoming.add_node(new_node);
incoming.add_edge(edge("old", "other", "supports", 0.9));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_superseded, 1);
assert_eq!(base.edges.len(), 1);
}
#[test]
fn different_edge_types_not_merged() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
base.add_node(node("b", "fact", 1.0));
base.add_edge(edge("a", "b", "supports", 0.6));
let mut incoming = ContextGraph::new();
incoming.add_node(node("a", "fact", 1.0));
incoming.add_node(node("b", "fact", 1.0));
incoming.add_edge(edge("a", "b", "contradicts", 0.9));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.edges_added, 1);
assert_eq!(report.edges_merged, 0);
assert_eq!(base.edges.len(), 2);
}
#[test]
fn empty_graph_merge_is_noop() {
let mut base = ContextGraph::new();
base.add_node(node("a", "fact", 1.0));
let incoming = ContextGraph::new();
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_added, 0);
assert_eq!(report.nodes_updated, 0);
assert_eq!(report.edges_added, 0);
assert_eq!(base.nodes.len(), 1);
}
#[test]
fn merge_into_empty_base() {
let mut base = ContextGraph::new();
let mut incoming = ContextGraph::new();
incoming.add_node(node("x", "fact", 0.5));
incoming.add_node(node("y", "gotcha", 0.8));
incoming.add_edge(edge("x", "y", "has_gotcha", 0.7));
let report = merge_graphs(&mut base, &incoming);
assert_eq!(report.nodes_added, 2);
assert_eq!(report.edges_added, 1);
assert_eq!(base.nodes.len(), 2);
assert_eq!(base.edges.len(), 1);
}
}