diff --git a/semantica/ontology/property_generator.py b/semantica/ontology/property_generator.py index d43fd526..fdc36c96 100644 --- a/semantica/ontology/property_generator.py +++ b/semantica/ontology/property_generator.py @@ -117,6 +117,8 @@ class PropertyGenerator: data_properties = self._infer_data_properties(entities, classes, **options) properties.extend(data_properties) + properties = self._coalesce_normalized_properties(properties) + self.progress_tracker.stop_tracking( tracking_id, status="completed", @@ -193,6 +195,67 @@ class PropertyGenerator: return properties + def _coalesce_normalized_properties( + self, properties: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Merge same-kind properties that normalize to the same name.""" + property_kinds = defaultdict(set) + for prop in properties: + property_kinds[prop["name"]].add(prop.get("type")) + + collisions = { + name: sorted(kind for kind in kinds if kind is not None) + for name, kinds in property_kinds.items() + if len({kind for kind in kinds if kind is not None}) > 1 + } + if collisions: + raise ValidationError( + "Normalized property names cannot be shared by object and " + "data properties.", + validation_context={"property_kind_collisions": collisions}, + ) + + merged: Dict[tuple, Dict[str, Any]] = {} + result = [] + for prop in properties: + key = (prop.get("type"), prop["name"]) + existing = merged.get(key) + if existing is None: + merged[key] = prop + result.append(prop) + continue + + existing["domain"] = self._merge_property_values( + existing.get("domain", []), prop.get("domain", []) + ) + if prop.get("type") == "object": + existing["range"] = self._merge_property_values( + existing.get("range", []), prop.get("range", []) + ) + existing_metadata = existing.setdefault("metadata", {}) + existing_metadata["occurrence_count"] = ( + existing_metadata.get("occurrence_count", 0) + + prop.get("metadata", {}).get("occurrence_count", 0) + ) + elif existing.get("range") != prop.get("range"): + existing["range"] = self._get_more_general_type( + existing["range"], prop["range"] + ) + + return result + + @staticmethod + def _merge_property_values(current: Any, incoming: Any) -> List[Any]: + """Merge scalar-or-list property values while preserving input order.""" + values = list(current) if isinstance(current, list) else [current] + incoming_values = ( + incoming if isinstance(incoming, list) else [incoming] + ) + for value in incoming_values: + if value not in values: + values.append(value) + return [value for value in values if value is not None] + def _infer_data_properties( self, entities: List[Dict[str, Any]], classes: List[Dict[str, Any]], **options ) -> List[Dict[str, Any]]: diff --git a/tests/ontology/test_ontology_property_name_collisions.py b/tests/ontology/test_ontology_property_name_collisions.py new file mode 100644 index 00000000..64b62140 --- /dev/null +++ b/tests/ontology/test_ontology_property_name_collisions.py @@ -0,0 +1,51 @@ +import pytest + +from semantica.ontology.class_inferrer import ClassInferrer +from semantica.ontology.property_generator import PropertyGenerator +from semantica.utils.exceptions import ValidationError + + +def test_same_kind_normalized_object_properties_are_merged(): + entities = [ + {"id": "p1", "type": "Person", "name": "Alice"}, + {"id": "o1", "type": "Organization", "name": "Acme"}, + ] + classes = ClassInferrer(min_occurrences=1).infer_classes(entities) + relationships = [ + { + "source_type": "Person", + "target_type": "Organization", + "type": "works_for", + }, + { + "source_type": "Person", + "target_type": "Organization", + "type": "worksFor", + }, + ] + + properties = PropertyGenerator().infer_properties( + entities, relationships, classes, min_occurrences=1 + ) + + works_for = [prop for prop in properties if prop["name"] == "worksFor"] + assert len(works_for) == 1 + assert works_for[0]["domain"] == ["Person"] + assert works_for[0]["range"] == ["Organization"] + + +def test_normalized_name_cannot_be_both_object_and_data_property(): + entities = [ + {"id": "p1", "type": "Person", "value": "Alice"}, + {"id": "p2", "type": "Person", "value": "Bob"}, + ] + classes = ClassInferrer(min_occurrences=1).infer_classes(entities) + relationships = [ + {"source_type": "Person", "target_type": "Person", "type": "value"}, + {"source_type": "Person", "target_type": "Person", "type": "value"}, + ] + + with pytest.raises(ValidationError, match="object and data"): + PropertyGenerator().infer_properties( + entities, relationships, classes, min_occurrences=1 + )