Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions semantica/context/context_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):

self.nodes: Dict[str, ContextNode] = {}
self.edges: List[ContextEdge] = []
self._edge_index: Dict[str, ContextEdge] = {}

self._adjacency: Dict[str, List[ContextEdge]] = defaultdict(list)

Expand Down Expand Up @@ -1043,6 +1044,7 @@ def load_from_file(self, path: str) -> None:
# Clear existing
self.nodes.clear()
self.edges.clear()
self._edge_index.clear()
self._adjacency.clear()
self.node_type_index.clear()
self.edge_type_index.clear()
Expand Down Expand Up @@ -1436,6 +1438,7 @@ def clear(self) -> None:
with self._lock:
self.nodes.clear()
self.edges.clear()
self._edge_index.clear()
self._adjacency.clear()
self.node_type_index.clear()
self.edge_type_index.clear()
Expand Down Expand Up @@ -1508,6 +1511,11 @@ def _add_internal_edge(self, edge: ContextEdge) -> bool:
self.logger.warning("Skipping internal edge with invalid endpoints: %r", edge)
return False
with self._lock:
# Edge identity is content-derived, so an existing edge_id means this
# exact edge is already stored; re-adding it is a no-op (issue #922).
if edge.edge_id in self._edge_index:
return False

# Ensure nodes exist
if edge.source_id not in self.nodes:
self._add_internal_node(
Expand All @@ -1518,6 +1526,7 @@ def _add_internal_edge(self, edge: ContextEdge) -> bool:
ContextNode(edge.target_id, "entity", edge.target_id)
)

self._edge_index[edge.edge_id] = edge
self.edges.append(edge)
self.edge_type_index[edge.edge_type].append(edge)
self._adjacency[edge.source_id].append(edge)
Expand Down
55 changes: 55 additions & 0 deletions tests/context/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,61 @@ def test_context_graph_operations(self):
self.assertEqual(neighbors[0]["id"], "n2")
self.assertEqual(neighbors[0]["relationship"], "knows")

def test_add_edge_is_idempotent(self):
graph = ContextGraph()
graph.add_node("a", "t")
graph.add_node("b", "t")

self.assertTrue(graph.add_edge("a", "b", "rel"))
self.assertFalse(graph.add_edge("a", "b", "rel"))
self.assertFalse(graph.add_edge("a", "b", "rel"))

self.assertEqual(len(graph.edges), 1)
self.assertEqual(len(graph.edge_type_index["rel"]), 1)
self.assertEqual(len(graph._adjacency["a"]), 1)
self.assertEqual(graph.stats()["edge_count"], 1)
self.assertLessEqual(graph.density(), 1.0)

def test_parallel_edges_with_distinct_attributes_are_kept(self):
graph = ContextGraph()
graph.add_node("a", "t")
graph.add_node("b", "t")

graph.add_edge("a", "b", "rel", confidence=0.9)
graph.add_edge("a", "b", "rel", confidence=0.5)
graph.add_edge("a", "b", "other")

self.assertEqual(len(graph.edges), 3)
self.assertEqual(len({e.edge_id for e in graph.edges}), 3)

def test_reingest_does_not_duplicate_edges(self):
graph = ContextGraph()
entities = [
{"id": "alice", "type": "person"},
{"id": "acme", "type": "org"},
]
relationships = [
{"source_id": "alice", "target_id": "acme", "type": "works_at"}
]

for _ in range(3):
graph.build_from_entities_and_relationships(entities, relationships)

self.assertEqual(len(graph.edges), 1)

def test_clear_resets_edge_dedupe_index(self):
graph = ContextGraph()
graph.add_node("a", "t")
graph.add_node("b", "t")
graph.add_edge("a", "b", "rel")

graph.clear()

graph.add_node("a", "t")
graph.add_node("b", "t")
self.assertTrue(graph.add_edge("a", "b", "rel"))
self.assertEqual(len(graph.edges), 1)

def test_get_nodes_by_label_returns_metadata_copy(self):
graph = ContextGraph()
graph.add_node("n1", "person", "Alice", role="engineer")
Expand Down
Loading