import unittest from datetime import datetime from unittest.mock import MagicMock, patch from semantica.conflicts.conflict_analyzer import ConflictAnalyzer from semantica.conflicts.conflict_detector import ( Conflict, ConflictDetector, ConflictType, ) from semantica.conflicts.conflict_resolver import ConflictResolver from semantica.conflicts.investigation_guide import InvestigationGuideGenerator from semantica.conflicts.source_tracker import SourceReference, SourceTracker class TestConflictsModule(unittest.TestCase): def setUp(self): # Mock progress tracker self.mock_tracker_patcher = patch( "semantica.utils.progress_tracker.get_progress_tracker" ) self.mock_get_tracker = self.mock_tracker_patcher.start() self.mock_tracker = MagicMock() self.mock_get_tracker.return_value = self.mock_tracker self.setUp_data() def tearDown(self): self.mock_tracker_patcher.stop() def setUp_data(self): # Setup common data for tests self.entities = [ { "id": "e1", "type": "Person", "name": "John Doe", "properties": {"age": 30, "location": "New York"}, "source": "source1", "page": 1, "confidence": 0.9, "metadata": {"timestamp": "2023-01-01T10:00:00"}, }, { "id": "e1", "type": "Person", "name": "John Doe", "properties": {"age": 32, "location": "Boston"}, "source": "source2", "page": 5, "confidence": 0.8, "metadata": {"timestamp": "2023-06-01T10:00:00"}, }, ] self.source1 = SourceReference( document="doc1", page=1, confidence=0.9, timestamp=datetime(2023, 1, 1) ) self.source2 = SourceReference( document="doc2", page=2, confidence=0.8, timestamp=datetime(2023, 6, 1) ) def test_source_tracker(self): tracker = SourceTracker() # Test tracking property source tracker.track_property_source("e1", "age", 30, self.source1) tracker.track_property_source("e1", "age", 32, self.source2) # Test getting property sources prop_source = tracker.get_property_sources("e1", "age") self.assertIsNotNone(prop_source) self.assertEqual(len(prop_source.sources), 2) self.assertEqual(prop_source.value, 32) # Should store latest value # Test finding disagreements disagreements = tracker.find_source_disagreements("e1", "age") # Since we tracked different sources for the same property, there might be # disagreements based on how find_source_disagreements works. self.assertTrue(len(disagreements) > 0) # Test tracking entity source tracker.track_entity_source("e1", self.source1) sources = tracker.get_entity_sources("e1") self.assertTrue(len(sources) >= 1) def test_track_sources_batch_failure_not_counted(self): """Test that track_sources_batch does not increment stats when tracking fails (#783).""" tracker = SourceTracker() source_data = [ { "type": "property", "entity_id": "e1", "property_name": "age", "value": 30, "source": self.source1, }, { "type": "property", "entity_id": "e2", "property_name": "age", "value": 25, "source": self.source2, }, ] with patch.object(tracker, "track_property_source", return_value=False): stats = tracker.track_sources_batch(source_data) self.assertEqual(stats["properties_tracked"], 0) self.assertEqual(stats["total_tracked"], 0) def test_conflict_detector(self): detector = ConflictDetector() # We need to flatten the entities structure for detect_value_conflicts since it # expects properties at top-level (value = entity[property_name]). flat_entities = [ {"id": "e1", "age": 30, "source": "source1", "confidence": 0.9}, {"id": "e1", "age": 32, "source": "source2", "confidence": 0.8}, ] conflicts = detector.detect_value_conflicts(flat_entities, "age") self.assertEqual(len(conflicts), 1) conflict = conflicts[0] self.assertEqual(conflict.entity_id, "e1") self.assertEqual(conflict.property_name, "age") self.assertEqual(conflict.conflict_type, ConflictType.VALUE_CONFLICT) self.assertEqual(len(conflict.conflicting_values), 2) self.assertIn(30, conflict.conflicting_values) self.assertIn(32, conflict.conflicting_values) # Test type conflicts type_entities = [ {"id": "e2", "type": "Person", "source": "s1"}, {"id": "e2", "type": "Organization", "source": "s2"}, ] type_conflicts = detector.detect_type_conflicts(type_entities) self.assertEqual(len(type_conflicts), 1) self.assertEqual(type_conflicts[0].conflict_type, ConflictType.TYPE_CONFLICT) def test_conflict_resolver(self): resolver = ConflictResolver() conflict = Conflict( conflict_id="c1", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 30, 32], sources=[ { "document": "doc1", "confidence": 0.9, "metadata": {"timestamp": datetime(2023, 1, 1)}, }, { "document": "doc3", "confidence": 0.9, "metadata": {"timestamp": datetime(2023, 1, 2)}, }, { "document": "doc2", "confidence": 0.8, "metadata": {"timestamp": datetime(2023, 6, 1)}, }, ], ) # Test Voting result_voting = resolver.resolve_conflict(conflict, strategy="voting") self.assertTrue(result_voting.resolved) self.assertEqual(result_voting.resolved_value, 30) # 30 appears twice # Test Most Recent result_recent = resolver.resolve_conflict(conflict, strategy="most_recent") self.assertTrue(result_recent.resolved) self.assertEqual(result_recent.resolved_value, 32) # doc2 is most recent (June) # Test Highest Confidence # doc1 and doc3 have 0.9, doc2 has 0.8. Should pick 30 (first max confidence) result_conf = resolver.resolve_conflict(conflict, strategy="highest_confidence") self.assertTrue(result_conf.resolved) self.assertEqual(result_conf.resolved_value, 30) def test_conflict_analyzer(self): analyzer = ConflictAnalyzer() conflicts = [ Conflict( conflict_id="c1", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[{"document": "doc1"}, {"document": "doc2"}], severity="medium", ), Conflict( conflict_id="c2", conflict_type=ConflictType.TYPE_CONFLICT, entity_id="e2", property_name="type", conflicting_values=["Person", "Org"], sources=[{"document": "doc1"}, {"document": "doc3"}], severity="critical", ), ] analysis = analyzer.analyze_conflicts(conflicts) self.assertEqual(analysis["total_conflicts"], 2) self.assertEqual(analysis["by_severity"]["counts"]["critical"], 1) self.assertEqual(analysis["by_severity"]["counts"]["medium"], 1) self.assertIn("recommendations", analysis) def test_investigation_guide(self): generator = InvestigationGuideGenerator() conflict = Conflict( conflict_id="c1", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[{"document": "doc1"}, {"document": "doc2"}], severity="medium", ) guide = generator.generate_guide(conflict) self.assertEqual(guide.conflict_id, "c1") self.assertEqual(guide.severity, "medium") self.assertTrue(len(guide.investigation_steps) > 0) self.assertTrue(len(guide.recommended_actions) > 0) # Test checklist export checklist = generator.export_investigation_checklist(guide, format="text") self.assertIn("INVESTIGATION GUIDE: c1", checklist) def test_conflict_resolver_credibility_weighted(self): """Test credibility-weighted resolution strategy (#865).""" resolver = ConflictResolver() # Boost the credibility of "doc_trusted" so its value should win # even though it only has one vote, vs. two lower-credibility votes. # Use the validated setter rather than mutating the internal dict # directly, so the test stays coupled to the public API surface. resolver.source_tracker.set_source_credibility("doc_trusted", 1.0) resolver.source_tracker.set_source_credibility("doc_flaky", 0.1) conflict = Conflict( conflict_id="c_cred", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 30, 40], sources=[ {"document": "doc_flaky", "confidence": 0.9}, {"document": "doc_flaky", "confidence": 0.9}, {"document": "doc_trusted", "confidence": 0.9}, ], ) result = resolver.resolve_conflict(conflict, strategy="credibility_weighted") self.assertTrue(result.resolved) self.assertEqual(result.resolved_value, 40) self.assertEqual(result.resolution_strategy, "credibility_weighted") self.assertGreater(result.confidence, 0.0) def test_conflict_resolver_first_seen(self): """Test first-seen resolution strategy (#865).""" resolver = ConflictResolver() conflict = Conflict( conflict_id="c_first", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[ {"document": "doc1", "confidence": 0.9}, {"document": "doc2", "confidence": 0.9}, ], ) result = resolver.resolve_conflict(conflict, strategy="first_seen") self.assertTrue(result.resolved) self.assertEqual(result.resolved_value, 30) # first value in the list self.assertEqual(result.resolution_strategy, "first_seen") self.assertEqual(result.sources_used, ["doc1"]) def test_conflict_resolver_manual_review(self): """Test manual-review resolution strategy flags without resolving (#865).""" resolver = ConflictResolver() conflict = Conflict( conflict_id="c_manual", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[{"document": "doc1"}, {"document": "doc2"}], severity="high", ) result = resolver.resolve_conflict(conflict, strategy="manual_review") self.assertFalse(result.resolved) self.assertEqual(result.resolution_strategy, "manual_review") self.assertTrue(result.metadata.get("requires_manual_review")) self.assertEqual(result.metadata.get("severity"), "high") def test_conflict_resolver_expert_review(self): """Test expert-review resolution strategy flags without resolving (#865).""" resolver = ConflictResolver() conflict = Conflict( conflict_id="c_expert", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[{"document": "doc1"}, {"document": "doc2"}], severity="critical", ) result = resolver.resolve_conflict(conflict, strategy="expert_review") self.assertFalse(result.resolved) self.assertEqual(result.resolution_strategy, "expert_review") self.assertTrue(result.metadata.get("requires_expert_review")) self.assertEqual(result.metadata.get("severity"), "critical") def test_conflict_detector_relationship_conflicts(self): """Test relationship conflict detection (#865).""" detector = ConflictDetector() relationships = [ { "id": "r1", "source_id": "e1", "target_id": "e2", "type": "works_at", "source": "doc1", }, { "id": "r1", "source_id": "e1", "target_id": "e2", "type": "founded", "source": "doc2", }, ] conflicts = detector.detect_relationship_conflicts(relationships) self.assertEqual(len(conflicts), 1) conflict = conflicts[0] self.assertEqual(conflict.conflict_type, ConflictType.RELATIONSHIP_CONFLICT) self.assertEqual(conflict.relationship_id, "r1") self.assertEqual(conflict.property_name, "type") self.assertIn("works_at", conflict.conflicting_values) self.assertIn("founded", conflict.conflicting_values) def test_conflict_detector_relationship_conflicts_no_conflict(self): """Relationships with a single occurrence should not raise conflicts (#865).""" detector = ConflictDetector() relationships = [ {"id": "r1", "source_id": "e1", "target_id": "e2", "type": "works_at"}, ] conflicts = detector.detect_relationship_conflicts(relationships) self.assertEqual(len(conflicts), 0) def test_conflict_detector_temporal_conflicts(self): """Test temporal conflict detection (#865).""" detector = ConflictDetector() entities = [ {"id": "e1", "founded": "1998", "source": "doc1", "confidence": 0.9}, {"id": "e1", "founded": "2004", "source": "doc2", "confidence": 0.8}, ] conflicts = detector.detect_temporal_conflicts(entities) self.assertEqual(len(conflicts), 1) conflict = conflicts[0] self.assertEqual(conflict.conflict_type, ConflictType.TEMPORAL_CONFLICT) self.assertEqual(conflict.entity_id, "e1") self.assertEqual(conflict.property_name, "founded") self.assertIn("1998", conflict.conflicting_values) self.assertIn("2004", conflict.conflicting_values) def test_conflict_detector_temporal_conflicts_no_conflict(self): """Matching temporal values across sources should not raise conflicts (#865).""" detector = ConflictDetector() entities = [ {"id": "e1", "founded": "1998", "source": "doc1"}, {"id": "e1", "founded": "1998", "source": "doc2"}, ] conflicts = detector.detect_temporal_conflicts(entities) self.assertEqual(len(conflicts), 0) def test_conflict_detector_logical_conflicts(self): """Test logical conflict detection for incompatible entity types (#865).""" detector = ConflictDetector() entities = [ {"id": "e1", "type": "Person", "source": "doc1"}, {"id": "e1", "type": "Organization", "source": "doc2"}, ] conflicts = detector.detect_logical_conflicts(entities) self.assertEqual(len(conflicts), 1) conflict = conflicts[0] self.assertEqual(conflict.conflict_type, ConflictType.LOGICAL_CONFLICT) self.assertEqual(conflict.entity_id, "e1") self.assertEqual(conflict.severity, "critical") self.assertIn("Person", conflict.conflicting_values) self.assertIn("Organization", conflict.conflicting_values) def test_conflict_detector_logical_conflicts_compatible_types(self): """Compatible/unrelated types should not raise logical conflicts (#865).""" detector = ConflictDetector() entities = [ {"id": "e1", "type": "Person", "source": "doc1"}, {"id": "e1", "type": "Employee", "source": "doc2"}, ] conflicts = detector.detect_logical_conflicts(entities) self.assertEqual(len(conflicts), 0) def test_conflict_analyzer_by_source_breakdown(self): """Test the by_source breakdown of analyze_conflicts (#902).""" analyzer = ConflictAnalyzer() conflicts = [ Conflict( conflict_id="c1", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[{"document": "doc1"}, {"document": "doc2"}], severity="medium", ), Conflict( conflict_id="c2", conflict_type=ConflictType.TYPE_CONFLICT, entity_id="e2", property_name="type", conflicting_values=["Person", "Org"], sources=[{"document": "doc1"}, {"document": "doc3"}], severity="critical", ), ] analysis = analyzer.analyze_conflicts(conflicts) self.assertIn("by_source", analysis) by_source = analysis["by_source"] # doc1 appears in both conflicts, doc2 and doc3 in one each. self.assertEqual(by_source["counts"]["doc1"], 2) self.assertEqual(by_source["counts"]["doc2"], 1) self.assertEqual(by_source["counts"]["doc3"], 1) top_sources = { s["source"]: s["conflict_count"] for s in by_source["top_sources"] } self.assertEqual(top_sources["doc1"], 2) self.assertIn("doc1", by_source["details"]) doc1_entries = by_source["details"]["doc1"] self.assertEqual(len(doc1_entries), 2) self.assertEqual({e["conflict_id"] for e in doc1_entries}, {"c1", "c2"}) def test_conflict_analyzer_analyze_trends(self): """Test analyze_trends over deterministic, time-ordered data (#902).""" analyzer = ConflictAnalyzer() def make_conflict(conflict_id, timestamp): return Conflict( conflict_id=conflict_id, conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[{"document": "doc1", "metadata": {"timestamp": timestamp}}], ) # January: 1 conflict. February: 3 conflicts (>10% increase -> "increasing"). conflicts = [ make_conflict("c1", "2023-01-05T00:00:00"), make_conflict("c2", "2023-02-01T00:00:00"), make_conflict("c3", "2023-02-10T00:00:00"), make_conflict("c4", "2023-02-20T00:00:00"), ] trends = analyzer.analyze_trends(conflicts) self.assertEqual(len(trends), 2) self.assertEqual(trends[0]["period"], "2023-01") self.assertEqual(trends[0]["conflict_count"], 1) self.assertEqual(trends[1]["period"], "2023-02") self.assertEqual(trends[1]["conflict_count"], 3) self.assertEqual(trends[1]["trend"], "increasing") self.assertEqual(trends[1]["trend_direction"], "up") def test_conflict_analyzer_analyze_trends_insufficient_data(self): """Single-period data should report insufficient_data, not crash (#902).""" analyzer = ConflictAnalyzer() conflict = Conflict( conflict_id="c1", conflict_type=ConflictType.VALUE_CONFLICT, entity_id="e1", property_name="age", conflicting_values=[30, 32], sources=[ {"document": "doc1", "metadata": {"timestamp": "2023-01-05T00:00:00"}} ], ) trends = analyzer.analyze_trends([conflict]) self.assertEqual(len(trends), 1) self.assertEqual(trends[0]["trend"], "insufficient_data") self.assertEqual(trends[0]["conflict_count"], 1) if __name__ == "__main__": unittest.main()