1use std::collections::{HashMap, HashSet};
2
3use super::graph_model::{ContextGraph, ContextNode};
4
5#[derive(Debug, Clone, Default)]
6pub struct MergeReport {
7 pub nodes_added: u32,
8 pub nodes_updated: u32,
9 pub nodes_superseded: u32,
10 pub edges_added: u32,
11 pub edges_merged: u32,
12 pub conflicts: Vec<String>,
13}
14
15pub fn merge_graphs(base: &mut ContextGraph, incoming: &ContextGraph) -> MergeReport {
16 let mut report = MergeReport::default();
17 let mut existing_ids: HashSet<String> = base.nodes.iter().map(|n| n.id.clone()).collect();
18
19 let superseded = collect_superseded(incoming);
20
21 for node in &incoming.nodes {
22 if superseded.contains(&node.id) {
23 continue;
24 }
25
26 if existing_ids.contains(&node.id) {
27 merge_existing_node(base, node, &mut report);
28 } else {
29 base.nodes.push(node.clone());
30 existing_ids.insert(node.id.clone());
31 report.nodes_added += 1;
32 }
33 }
34
35 for id in &superseded {
36 if let Some(n) = base.nodes.iter_mut().find(|n| n.id == *id) {
37 n.activation = 0.0;
38 report.nodes_superseded += 1;
39 }
40 }
41
42 detect_conflicts(base, incoming, &mut report);
43
44 let mut edge_index: HashMap<(String, String, String), usize> = HashMap::new();
45 for (i, e) in base.edges.iter().enumerate() {
46 edge_index.insert((e.from.clone(), e.to.clone(), e.edge_type.clone()), i);
47 }
48
49 for edge in &incoming.edges {
50 if superseded.contains(&edge.from) || superseded.contains(&edge.to) {
51 continue;
52 }
53 if !existing_ids.contains(&edge.from) || !existing_ids.contains(&edge.to) {
54 continue;
55 }
56
57 let key = (edge.from.clone(), edge.to.clone(), edge.edge_type.clone());
58 if let Some(&idx) = edge_index.get(&key) {
59 let existing = &mut base.edges[idx];
60 existing.weight = f64::midpoint(existing.weight, edge.weight);
61 existing.coactivations += edge.coactivations;
62 report.edges_merged += 1;
63 } else {
64 let new_idx = base.edges.len();
65 base.edges.push(edge.clone());
66 edge_index.insert(key, new_idx);
67 report.edges_added += 1;
68 }
69 }
70
71 report
72}
73
74fn collect_superseded(graph: &ContextGraph) -> HashSet<String> {
75 let mut superseded = HashSet::new();
76 for node in &graph.nodes {
77 if let Some(ref s) = node.supersedes {
78 superseded.insert(s.clone());
79 }
80 }
81 superseded
82}
83
84fn merge_existing_node(base: &mut ContextGraph, incoming: &ContextNode, report: &mut MergeReport) {
85 let Some(existing) = base.nodes.iter_mut().find(|n| n.id == incoming.id) else {
86 return;
87 };
88
89 if incoming.activation > existing.activation {
90 existing.activation = incoming.activation;
91 }
92
93 if let Some(ref inc_cat) = incoming.category {
94 if existing.category.is_none() {
95 existing.category = Some(inc_cat.clone());
96 }
97 }
98
99 if let Some(inc_conf) = incoming.confidence {
100 match existing.confidence {
101 Some(ex_conf) if inc_conf > ex_conf => existing.confidence = Some(inc_conf),
102 None => existing.confidence = Some(inc_conf),
103 _ => {}
104 }
105 }
106
107 report.nodes_updated += 1;
108}
109
110fn detect_conflicts(base: &ContextGraph, incoming: &ContextGraph, report: &mut MergeReport) {
111 let contradiction_targets: HashSet<&str> = incoming
112 .edges
113 .iter()
114 .filter(|e| e.edge_type == "contradicts")
115 .map(|e| e.to.as_str())
116 .collect();
117
118 let base_ids: HashSet<&str> = base.nodes.iter().map(|n| n.id.as_str()).collect();
119
120 for target in contradiction_targets {
121 if base_ids.contains(target) {
122 report.conflicts.push(format!(
123 "incoming graph contradicts existing node '{target}'"
124 ));
125 }
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::super::graph_model::ContextEdge;
132 use super::*;
133
134 fn node(id: &str, node_type: &str, activation: f64) -> ContextNode {
135 ContextNode {
136 id: id.into(),
137 node_type: node_type.into(),
138 content: format!("content of {id}"),
139 activation,
140 category: None,
141 source: None,
142 created_at: None,
143 decay_half_life_days: None,
144 blob_ref: None,
145 file_path: None,
146 line_start: None,
147 line_end: None,
148 confidence: None,
149 supersedes: None,
150 }
151 }
152
153 fn edge(from: &str, to: &str, edge_type: &str, weight: f64) -> ContextEdge {
154 ContextEdge {
155 from: from.into(),
156 to: to.into(),
157 edge_type: edge_type.into(),
158 weight,
159 coactivations: 1,
160 metadata: None,
161 }
162 }
163
164 #[test]
165 fn merge_adds_new_nodes() {
166 let mut base = ContextGraph::new();
167 base.add_node(node("a", "fact", 1.0));
168
169 let mut incoming = ContextGraph::new();
170 incoming.add_node(node("b", "gotcha", 0.9));
171
172 let report = merge_graphs(&mut base, &incoming);
173 assert_eq!(report.nodes_added, 1);
174 assert_eq!(base.nodes.len(), 2);
175 }
176
177 #[test]
178 fn merge_updates_existing_activation() {
179 let mut base = ContextGraph::new();
180 base.add_node(node("a", "fact", 0.5));
181
182 let mut incoming = ContextGraph::new();
183 incoming.add_node(node("a", "fact", 0.8));
184
185 let report = merge_graphs(&mut base, &incoming);
186 assert_eq!(report.nodes_updated, 1);
187 assert!((base.nodes[0].activation - 0.8).abs() < 0.001);
188 }
189
190 #[test]
191 fn merge_averages_shared_edge_weights() {
192 let mut base = ContextGraph::new();
193 base.add_node(node("a", "fact", 1.0));
194 base.add_node(node("b", "fact", 1.0));
195 base.add_edge(edge("a", "b", "supports", 0.6));
196
197 let mut incoming = ContextGraph::new();
198 incoming.add_node(node("a", "fact", 1.0));
199 incoming.add_node(node("b", "fact", 1.0));
200 incoming.add_edge(edge("a", "b", "supports", 1.0));
201
202 let report = merge_graphs(&mut base, &incoming);
203 assert_eq!(report.edges_merged, 1);
204 assert!((base.edges[0].weight - 0.8).abs() < 0.001);
205 assert_eq!(base.edges[0].coactivations, 2);
206 }
207
208 #[test]
209 fn merge_adds_new_edges() {
210 let mut base = ContextGraph::new();
211 base.add_node(node("a", "fact", 1.0));
212 base.add_node(node("b", "fact", 1.0));
213
214 let mut incoming = ContextGraph::new();
215 incoming.add_node(node("a", "fact", 1.0));
216 incoming.add_node(node("b", "fact", 1.0));
217 incoming.add_edge(edge("a", "b", "supports", 0.9));
218
219 let report = merge_graphs(&mut base, &incoming);
220 assert_eq!(report.edges_added, 1);
221 assert_eq!(base.edges.len(), 1);
222 }
223
224 #[test]
225 fn supersedes_deactivates_node() {
226 let mut base = ContextGraph::new();
227 base.add_node(node("old_fact", "fact", 1.0));
228
229 let mut incoming = ContextGraph::new();
230 let mut new_node = node("new_fact", "fact", 1.0);
231 new_node.supersedes = Some("old_fact".into());
232 incoming.add_node(new_node);
233
234 let report = merge_graphs(&mut base, &incoming);
235 assert_eq!(report.nodes_superseded, 1);
236 assert_eq!(report.nodes_added, 1);
237 assert!((base.node_by_id("old_fact").unwrap().activation).abs() < 0.001);
238 }
239
240 #[test]
241 fn detects_contradictions() {
242 let mut base = ContextGraph::new();
243 base.add_node(node("existing", "fact", 1.0));
244
245 let mut incoming = ContextGraph::new();
246 incoming.add_node(node("new", "fact", 1.0));
247 incoming.add_node(node("existing", "fact", 1.0));
248 incoming.add_edge(ContextEdge {
249 from: "new".into(),
250 to: "existing".into(),
251 edge_type: "contradicts".into(),
252 weight: 1.0,
253 coactivations: 0,
254 metadata: None,
255 });
256
257 let report = merge_graphs(&mut base, &incoming);
258 assert_eq!(report.conflicts.len(), 1);
259 assert!(report.conflicts[0].contains("contradicts"));
260 }
261
262 #[test]
263 fn edges_to_missing_nodes_skipped() {
264 let mut base = ContextGraph::new();
265 base.add_node(node("a", "fact", 1.0));
266
267 let mut incoming = ContextGraph::new();
268 incoming.add_edge(edge("a", "missing", "supports", 1.0));
269
270 let report = merge_graphs(&mut base, &incoming);
271 assert_eq!(report.edges_added, 0);
272 assert!(base.edges.is_empty());
273 }
274
275 #[test]
276 fn merge_is_idempotent() {
277 let mut base = ContextGraph::new();
278 base.add_node(node("a", "fact", 0.8));
279 base.add_node(node("b", "gotcha", 0.9));
280 base.add_edge(edge("a", "b", "has_gotcha", 0.7));
281
282 let incoming = base.clone();
283 let report = merge_graphs(&mut base, &incoming);
284
285 assert_eq!(report.nodes_added, 0);
286 assert_eq!(report.nodes_updated, 2);
287 assert_eq!(report.edges_merged, 1);
288 assert_eq!(report.edges_added, 0);
289 assert_eq!(base.nodes.len(), 2);
290 assert_eq!(base.edges.len(), 1);
291 }
292
293 #[test]
294 fn multi_merge_three_packages() {
295 let mut base = ContextGraph::new();
296 base.add_node(node("shared", "fact", 0.5));
297
298 let mut pkg_a = ContextGraph::new();
299 pkg_a.add_node(node("shared", "fact", 0.7));
300 pkg_a.add_node(node("from_a", "pattern", 0.9));
301 pkg_a.add_edge(edge("shared", "from_a", "supports", 0.8));
302
303 let mut pkg_b = ContextGraph::new();
304 pkg_b.add_node(node("shared", "fact", 0.6));
305 pkg_b.add_node(node("from_b", "gotcha", 1.0));
306 pkg_b.add_edge(edge("shared", "from_b", "has_gotcha", 0.9));
307
308 merge_graphs(&mut base, &pkg_a);
309 let report_b = merge_graphs(&mut base, &pkg_b);
310
311 assert_eq!(base.nodes.len(), 3);
312 assert_eq!(base.edges.len(), 2);
313 assert!(base.node_by_id("from_a").is_some());
314 assert!(base.node_by_id("from_b").is_some());
315 assert_eq!(report_b.nodes_added, 1);
316 }
317
318 #[test]
319 fn lower_activation_does_not_downgrade() {
320 let mut base = ContextGraph::new();
321 base.add_node(node("a", "fact", 0.9));
322
323 let mut incoming = ContextGraph::new();
324 incoming.add_node(node("a", "fact", 0.3));
325
326 merge_graphs(&mut base, &incoming);
327 assert!((base.nodes[0].activation - 0.9).abs() < 0.001);
328 }
329
330 #[test]
331 fn confidence_propagation() {
332 let mut base = ContextGraph::new();
333 let mut n = node("a", "fact", 1.0);
334 n.confidence = Some(0.5);
335 base.add_node(n);
336
337 let mut incoming = ContextGraph::new();
338 let mut n2 = node("a", "fact", 1.0);
339 n2.confidence = Some(0.9);
340 incoming.add_node(n2);
341
342 merge_graphs(&mut base, &incoming);
343 assert_eq!(base.nodes[0].confidence, Some(0.9));
344 }
345
346 #[test]
347 fn confidence_not_downgraded() {
348 let mut base = ContextGraph::new();
349 let mut n = node("a", "fact", 1.0);
350 n.confidence = Some(0.8);
351 base.add_node(n);
352
353 let mut incoming = ContextGraph::new();
354 let mut n2 = node("a", "fact", 1.0);
355 n2.confidence = Some(0.3);
356 incoming.add_node(n2);
357
358 merge_graphs(&mut base, &incoming);
359 assert_eq!(base.nodes[0].confidence, Some(0.8));
360 }
361
362 #[test]
363 fn category_filled_from_incoming() {
364 let mut base = ContextGraph::new();
365 base.add_node(node("a", "fact", 1.0));
366 assert!(base.nodes[0].category.is_none());
367
368 let mut incoming = ContextGraph::new();
369 let mut n = node("a", "fact", 1.0);
370 n.category = Some("security".into());
371 incoming.add_node(n);
372
373 merge_graphs(&mut base, &incoming);
374 assert_eq!(base.nodes[0].category.as_deref(), Some("security"));
375 }
376
377 #[test]
378 fn superseded_edges_are_dropped() {
379 let mut base = ContextGraph::new();
380 base.add_node(node("old", "fact", 1.0));
381 base.add_node(node("other", "fact", 1.0));
382 base.add_edge(edge("old", "other", "supports", 0.5));
383
384 let mut incoming = ContextGraph::new();
385 let mut new_node = node("replacement", "fact", 1.0);
386 new_node.supersedes = Some("old".into());
387 incoming.add_node(new_node);
388 incoming.add_edge(edge("old", "other", "supports", 0.9));
389
390 let report = merge_graphs(&mut base, &incoming);
391 assert_eq!(report.nodes_superseded, 1);
392 assert_eq!(base.edges.len(), 1);
393 }
394
395 #[test]
396 fn different_edge_types_not_merged() {
397 let mut base = ContextGraph::new();
398 base.add_node(node("a", "fact", 1.0));
399 base.add_node(node("b", "fact", 1.0));
400 base.add_edge(edge("a", "b", "supports", 0.6));
401
402 let mut incoming = ContextGraph::new();
403 incoming.add_node(node("a", "fact", 1.0));
404 incoming.add_node(node("b", "fact", 1.0));
405 incoming.add_edge(edge("a", "b", "contradicts", 0.9));
406
407 let report = merge_graphs(&mut base, &incoming);
408 assert_eq!(report.edges_added, 1);
409 assert_eq!(report.edges_merged, 0);
410 assert_eq!(base.edges.len(), 2);
411 }
412
413 #[test]
414 fn empty_graph_merge_is_noop() {
415 let mut base = ContextGraph::new();
416 base.add_node(node("a", "fact", 1.0));
417
418 let incoming = ContextGraph::new();
419 let report = merge_graphs(&mut base, &incoming);
420
421 assert_eq!(report.nodes_added, 0);
422 assert_eq!(report.nodes_updated, 0);
423 assert_eq!(report.edges_added, 0);
424 assert_eq!(base.nodes.len(), 1);
425 }
426
427 #[test]
428 fn merge_into_empty_base() {
429 let mut base = ContextGraph::new();
430
431 let mut incoming = ContextGraph::new();
432 incoming.add_node(node("x", "fact", 0.5));
433 incoming.add_node(node("y", "gotcha", 0.8));
434 incoming.add_edge(edge("x", "y", "has_gotcha", 0.7));
435
436 let report = merge_graphs(&mut base, &incoming);
437 assert_eq!(report.nodes_added, 2);
438 assert_eq!(report.edges_added, 1);
439 assert_eq!(base.nodes.len(), 2);
440 assert_eq!(base.edges.len(), 1);
441 }
442}