Skip to main content

lean_ctx/core/context_package/
composition.rs

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}