mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user