fix(ontology): coalesce normalized property collisions (#1231)

fix(ontology): coalesce normalized property collisions

Different raw property spellings can normalize to the same ontology
name and IRI. works_for and worksFor, for example, both normalize to
worksFor, but property inference emitted a separate definition for
each spelling, so the generated ontology declared two distinct
properties under what would become the same IRI once minted. The same
collapse could also happen across kinds: a relationship type and an
entity attribute that normalize to the same name would previously
produce a data property and an object property sharing one name, with
no signal that anything was wrong.

infer_properties() now runs a coalescing pass after object and data
properties are both inferred. Properties are grouped by (kind, name).
Object properties that collide are merged in occurrence order:
domains and ranges are unioned rather than overwritten, so a property
seen across several source classes keeps every domain instead of
losing all but the first, and occurrence_count is summed across the
merged spellings so downstream confidence/frequency signals stay
correct. Data properties merge domains the same way and reconcile
differing ranges through the existing _get_more_general_type()
widening logic already used elsewhere in this file, rather than a new
implementation.

A name that resolves to both an object property and a data property
is not silently coalesced into either one, since the two kinds mean
different things in the emitted ontology. That case raises a
ValidationError up front, naming every colliding name and which kinds
collided, so the conflict surfaces before an ambiguous ontology is
written rather than after.

Verified beyond the two cases in the new test file: a data property
colliding across two different domain classes correctly unions the
domain instead of keeping only the first class, and three distinct
spellings of the same relationship type collapse into one property
with the occurrence count correctly summed across all three.

Follow-up to #1170 (relationship endpoint types) and #1171 (retained
data properties for normalized class names).
This commit is contained in:
Guofang.Tang
2026-08-28 16:23:46 +05:00
committed by GitHub
parent ecb33a5b7d
commit 56d9e9a857
2 changed files with 114 additions and 0 deletions
+63
View File
@@ -117,6 +117,8 @@ class PropertyGenerator:
data_properties = self._infer_data_properties(entities, classes, **options) data_properties = self._infer_data_properties(entities, classes, **options)
properties.extend(data_properties) properties.extend(data_properties)
properties = self._coalesce_normalized_properties(properties)
self.progress_tracker.stop_tracking( self.progress_tracker.stop_tracking(
tracking_id, tracking_id,
status="completed", status="completed",
@@ -193,6 +195,67 @@ class PropertyGenerator:
return properties 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( def _infer_data_properties(
self, entities: List[Dict[str, Any]], classes: List[Dict[str, Any]], **options self, entities: List[Dict[str, Any]], classes: List[Dict[str, Any]], **options
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
@@ -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
)