Skip to content

ontocast.tool.agg.normalizer

Entity normalization for disambiguation.

This module handles the preparation of entities for embedding-based disambiguation. It creates normalized string representations r(e) that include: - Normalized form of the entity URI - Semantic neighbors (types, properties)

EntityContext dataclass

Semantic context extracted for one entity in a single graph scan.

Source code in ontocast/tool/agg/normalizer.py
@dataclass
class EntityContext:
    """Semantic context extracted for one entity in a single graph scan."""

    types: list[URIRef]
    properties: list[URIRef]
    labels: list[str]
    alt_labels: list[str]
    is_predicate: bool
    is_type_value: bool
    predicate_literals: dict[URIRef, frozenset[tuple[str, str]]]
    predicate_iri_objects: dict[URIRef, frozenset[URIRef]]
    has_data_literal: bool
    has_guard_literal: bool
    predicate_string_literals: dict[URIRef, frozenset[str]]

EntityNormalizer

Normalizes entities and creates string representations for embedding.

This class is responsible for transforming entity URIs into normalized string representations that can be embedded and compared.

Source code in ontocast/tool/agg/normalizer.py
class EntityNormalizer:
    """Normalizes entities and creates string representations for embedding.

    This class is responsible for transforming entity URIs into normalized
    string representations that can be embedded and compared.
    """

    def __init__(self, facts_iri: str = DEFAULT_IRI):
        """Initialize the entity normalizer.

        Args:
            facts_iri: Base IRI for fact entities. Entities under this namespace
                are facts; all other entities are considered ontology entities.
        """
        self.facts_iri = normalize_namespace_iri(facts_iri, context="facts")

    def normalize_string(self, text: str) -> str:
        """Normalize a string: lowercase, remove diacritics, clean special chars.

        CamelCase is split so that it yields the same logical tokens as snake_case
        (e.g. 'PLRedShift' -> 'pl red shift').

        Args:
            text: Input string to normalize

        Returns:
            Normalized string suitable for comparison

        Examples:
            'PLRedShift' -> 'pl red shift'
            'PL_red_shift_value' -> 'pl red shift value'
            'Café' -> 'cafe'
        """
        from ontocast.tool.agg.signatures import normalize_string_value

        return normalize_string_value(text)

    def normalize_uri(self, uri: URIRef) -> str:
        """Extract and normalize the local part of a URI.

        Args:
            uri: URI to normalize

        Returns:
            Normalized local name

        Examples:
            'http://example.org/PLRedShift' -> 'pl red shift'
            'http://example.org/PL_red_shift_value' -> 'pl red shift value'
        """
        return normalize_uri_local_name(uri)

    def is_ontology_entity(self, entity: URIRef) -> bool:
        """Check if an entity belongs to an ontology namespace.

        Facts live under ``facts_iri``; everything else is an ontology entity.

        Args:
            entity: Entity URI to check

        Returns:
            True if entity is **not** from the facts namespace
        """
        return not is_in_namespace(str(entity), self.facts_iri, context="facts")

    def extract_entity_context(self, entity: URIRef, graph: RDFGraph) -> EntityContext:
        """Extract semantic context for an entity from the graph.

        Args:
            entity: Entity to extract context for
            graph: RDF graph containing the entity

        Returns:
            :class:`EntityContext` with types, properties, labels, alt labels,
            role hints and merge-guard signatures.
            ``is_predicate`` is ``True`` when the entity appears in the
            predicate position of at least one triple.
            ``is_type_value`` is ``True`` when another entity is explicitly
            typed *as* this entity (i.e., ``(X, rdf:type, entity)`` exists).
            This allows role inference for range/domain classes that are
            referenced as types in sparse per-sentence graphs but carry no
            ``a owl:Class`` declaration locally.
        """
        from ontocast.tool.agg.signatures import canonical_literal

        types: list[URIRef] = []
        properties: set[URIRef] = set()
        labels: list[str] = []
        alt_labels: list[str] = []
        is_predicate = False
        is_type_value = False
        has_data_literal = False
        has_guard_literal = False
        predicate_literals: dict[URIRef, set[tuple[str, str]]] = {}
        predicate_string_literals: dict[URIRef, set[str]] = {}
        predicate_iri_objects: dict[URIRef, set[URIRef]] = {}
        schema_predicates = {RDF.type, RDFS.label, RDFS.comment}

        # Extract information from triples
        for s, p, o in graph:
            # When entity is subject
            if s == entity:
                if isinstance(p, URIRef):
                    properties.add(p)

                # Collect types
                if p == RDF.type and isinstance(o, URIRef):
                    types.append(o)
                elif isinstance(p, URIRef) and isinstance(o, URIRef):
                    predicate_iri_objects.setdefault(p, set()).add(o)

                # Collect labels
                if p == RDFS.label and isinstance(o, Literal):
                    labels.append(str(o))
                elif p not in schema_predicates and isinstance(o, Literal):
                    has_data_literal = True
                    if isinstance(p, URIRef):
                        canonical = canonical_literal(o)
                        if canonical is not None:
                            has_guard_literal = True
                            predicate_literals.setdefault(p, set()).add(canonical)
                        else:
                            normalized_value = self.normalize_string(str(o))
                            if normalized_value:
                                predicate_string_literals.setdefault(p, set()).add(
                                    normalized_value
                                )
                    if o.datatype is None:
                        value = str(o).strip()
                        if len(value) >= 3 and not value.isnumeric():
                            alt_labels.append(value)

            # When entity is object
            elif o == entity:
                if isinstance(p, URIRef):
                    properties.add(p)
                if p == RDF.type:
                    is_type_value = True

            # When entity is used as predicate
            if p == entity:
                is_predicate = True

        sorted_types = sorted(types, key=lambda entity: str(entity))
        sorted_properties = sorted(properties, key=lambda entity: str(entity))
        return EntityContext(
            types=sorted_types,
            properties=sorted_properties,
            labels=labels,
            alt_labels=alt_labels,
            is_predicate=is_predicate,
            is_type_value=is_type_value,
            predicate_literals={
                predicate: frozenset(values)
                for predicate, values in predicate_literals.items()
            },
            predicate_iri_objects={
                predicate: frozenset(objects)
                for predicate, objects in predicate_iri_objects.items()
            },
            has_data_literal=has_data_literal,
            has_guard_literal=has_guard_literal,
            predicate_string_literals={
                predicate: frozenset(values)
                for predicate, values in predicate_string_literals.items()
            },
        )

    def _render_term(self, term: Node) -> str:
        return render_term_for_text(term)

    def _build_neighborhood_representation(
        self, entity: URIRef, graph: RDFGraph
    ) -> str:
        by_role: dict[str, list[str]] = {
            "as_subject": [],
            "as_object": [],
            "as_predicate": [],
        }
        seen_by_role: dict[str, set[str]] = {
            "as_subject": set(),
            "as_object": set(),
            "as_predicate": set(),
        }

        triples_sorted = stable_sorted_triples(list(graph))
        for subj, pred, obj in triples_sorted:
            if subj == entity:
                sentence = (
                    f"{self._render_term(subj)} has relation {self._render_term(pred)} "
                    f"to {self._render_term(obj)}"
                )
                if sentence not in seen_by_role["as_subject"]:
                    seen_by_role["as_subject"].add(sentence)
                    by_role["as_subject"].append(sentence)
            if obj == entity:
                sentence = (
                    f"{self._render_term(subj)} relates via {self._render_term(pred)} "
                    f"to this entity {self._render_term(obj)}"
                )
                if sentence not in seen_by_role["as_object"]:
                    seen_by_role["as_object"].add(sentence)
                    by_role["as_object"].append(sentence)
            if pred == entity:
                sentence = (
                    f"predicate {self._render_term(pred)} links {self._render_term(subj)} "
                    f"and {self._render_term(obj)}"
                )
                if sentence not in seen_by_role["as_predicate"]:
                    seen_by_role["as_predicate"].add(sentence)
                    by_role["as_predicate"].append(sentence)

        selected: list[str] = []
        cap_per_role = 3
        for role in ("as_subject", "as_object", "as_predicate"):
            selected.extend(by_role[role][:cap_per_role])
        if not selected:
            return "no neighborhood facts available"
        return ". ".join(selected)

    @staticmethod
    def _normal_form_differs_from_text(normal_form: str, text: str) -> bool:
        if not normal_form or not text:
            return bool(normal_form or text)
        if normal_form == text:
            return False
        return normal_form not in text and text not in normal_form

    def _leading_text_tokens(
        self, *, normal_form: str, labels: list[str], alt_labels: list[str]
    ) -> tuple[str, bool]:
        """Return leading sentence text and whether URI normal_form is appended."""
        effective_labels = labels if labels else alt_labels[:2]
        if effective_labels:
            leading = self.normalize_string(effective_labels[0])
            append_normal_form = self._normal_form_differs_from_text(
                normal_form, leading
            )
            return leading, append_normal_form
        return normal_form, False

    def _build_core_representation(
        self,
        *,
        normal_form: str,
        types: list[URIRef],
        properties: list[URIRef],
        labels: list[str],
        alt_labels: list[str] | None = None,
    ) -> str:
        # Prefer human-readable labels over URI local names for embedding alignment.
        alt_labels = alt_labels or []
        leading, append_normal_form = self._leading_text_tokens(
            normal_form=normal_form,
            labels=labels,
            alt_labels=alt_labels,
        )
        sentences: list[str] = [leading]
        if append_normal_form:
            sentences.append(normal_form)
        if labels:
            normalized_labels = [self.normalize_string(label) for label in labels[:3]]
            if not any(token == leading for token in normalized_labels):
                sentences.append(f"It is labeled {', '.join(normalized_labels)}")
        if types:
            type_names = [self.normalize_uri(entity_type) for entity_type in types[:3]]
            # Keep the 'type' keyword to maintain compatibility with any
            # downstream keyword checks in unit tests.
            sentences.append(f"It has type {', '.join(type_names)}")
        if properties:
            filtered_props = [
                prop
                for prop in properties
                if prop not in {RDF.type, RDFS.label, RDFS.comment}
            ]
            prop_names = [self.normalize_uri(prop) for prop in filtered_props[:5]]
            sentences.append(f"It has properties {', '.join(prop_names)}")
        return ". ".join(sentences)

    def create_representation(
        self, entity: URIRef, graph: RDFGraph
    ) -> EntityRepresentation:
        """Create a normalized representation r(e) for an entity.

        This combines the normalized form with semantic neighbors to create
        a rich representation suitable for embedding.  The entity role
        (class / property / instance) is detected from the already-extracted
        context so no additional graph scan is needed downstream.

        Args:
            entity: Entity URI
            graph: RDF graph containing the entity

        Returns:
            EntityRepresentation containing r(e) and metadata
        """
        from ontocast.tool.agg.uri_builder import detect_role_from_context

        # Get normalized form
        normal_form = self.normalize_uri(entity)

        # Extract semantic context (single graph scan, incl. guard signatures)
        context = self.extract_entity_context(entity, graph)

        # Detect role from the already-extracted context (no extra graph scan)
        role = detect_role_from_context(
            context.types, context.is_predicate, context.is_type_value
        )

        core_representation = self._build_core_representation(
            normal_form=normal_form,
            types=context.types,
            properties=context.properties,
            labels=context.labels,
            alt_labels=context.alt_labels,
        )
        neighborhood_representation = self._build_neighborhood_representation(
            entity=entity,
            graph=graph,
        )

        # Check if ontology entity
        is_ontology = self.is_ontology_entity(entity)

        return EntityRepresentation(
            iri=entity,
            normal_form=normal_form,
            types=context.types,
            properties=context.properties,
            labels=context.labels,
            alt_labels=context.alt_labels,
            is_ontology_entity=is_ontology,
            role=role,
            core_representation=core_representation,
            neighborhood_representation=neighborhood_representation,
            predicate_literals=context.predicate_literals,
            predicate_iri_objects=context.predicate_iri_objects,
            has_data_literal=context.has_data_literal,
            has_guard_literal=context.has_guard_literal,
            predicate_string_literals=context.predicate_string_literals,
        )

    def create_representations_batch(
        self, entities: list[URIRef], graphs: dict[URIRef, RDFGraph]
    ) -> dict[URIRef, EntityRepresentation]:
        """Create representations for multiple entities.

        Args:
            entities: List of entity URIs
            graphs: Mapping from entity to its source graph

        Returns:
            Dictionary mapping entity URIs to their representations
        """
        representations = {}

        for entity in entities:
            graph = graphs.get(entity)
            if graph is not None:
                representations[entity] = self.create_representation(entity, graph)

        return representations

__init__(facts_iri=DEFAULT_IRI)

Initialize the entity normalizer.

Parameters:

Name Type Description Default
facts_iri str

Base IRI for fact entities. Entities under this namespace are facts; all other entities are considered ontology entities.

DEFAULT_IRI
Source code in ontocast/tool/agg/normalizer.py
def __init__(self, facts_iri: str = DEFAULT_IRI):
    """Initialize the entity normalizer.

    Args:
        facts_iri: Base IRI for fact entities. Entities under this namespace
            are facts; all other entities are considered ontology entities.
    """
    self.facts_iri = normalize_namespace_iri(facts_iri, context="facts")

create_representation(entity, graph)

Create a normalized representation r(e) for an entity.

This combines the normalized form with semantic neighbors to create a rich representation suitable for embedding. The entity role (class / property / instance) is detected from the already-extracted context so no additional graph scan is needed downstream.

Parameters:

Name Type Description Default
entity URIRef

Entity URI

required
graph RDFGraph

RDF graph containing the entity

required

Returns:

Type Description
EntityRepresentation

EntityRepresentation containing r(e) and metadata

Source code in ontocast/tool/agg/normalizer.py
def create_representation(
    self, entity: URIRef, graph: RDFGraph
) -> EntityRepresentation:
    """Create a normalized representation r(e) for an entity.

    This combines the normalized form with semantic neighbors to create
    a rich representation suitable for embedding.  The entity role
    (class / property / instance) is detected from the already-extracted
    context so no additional graph scan is needed downstream.

    Args:
        entity: Entity URI
        graph: RDF graph containing the entity

    Returns:
        EntityRepresentation containing r(e) and metadata
    """
    from ontocast.tool.agg.uri_builder import detect_role_from_context

    # Get normalized form
    normal_form = self.normalize_uri(entity)

    # Extract semantic context (single graph scan, incl. guard signatures)
    context = self.extract_entity_context(entity, graph)

    # Detect role from the already-extracted context (no extra graph scan)
    role = detect_role_from_context(
        context.types, context.is_predicate, context.is_type_value
    )

    core_representation = self._build_core_representation(
        normal_form=normal_form,
        types=context.types,
        properties=context.properties,
        labels=context.labels,
        alt_labels=context.alt_labels,
    )
    neighborhood_representation = self._build_neighborhood_representation(
        entity=entity,
        graph=graph,
    )

    # Check if ontology entity
    is_ontology = self.is_ontology_entity(entity)

    return EntityRepresentation(
        iri=entity,
        normal_form=normal_form,
        types=context.types,
        properties=context.properties,
        labels=context.labels,
        alt_labels=context.alt_labels,
        is_ontology_entity=is_ontology,
        role=role,
        core_representation=core_representation,
        neighborhood_representation=neighborhood_representation,
        predicate_literals=context.predicate_literals,
        predicate_iri_objects=context.predicate_iri_objects,
        has_data_literal=context.has_data_literal,
        has_guard_literal=context.has_guard_literal,
        predicate_string_literals=context.predicate_string_literals,
    )

create_representations_batch(entities, graphs)

Create representations for multiple entities.

Parameters:

Name Type Description Default
entities list[URIRef]

List of entity URIs

required
graphs dict[URIRef, RDFGraph]

Mapping from entity to its source graph

required

Returns:

Type Description
dict[URIRef, EntityRepresentation]

Dictionary mapping entity URIs to their representations

Source code in ontocast/tool/agg/normalizer.py
def create_representations_batch(
    self, entities: list[URIRef], graphs: dict[URIRef, RDFGraph]
) -> dict[URIRef, EntityRepresentation]:
    """Create representations for multiple entities.

    Args:
        entities: List of entity URIs
        graphs: Mapping from entity to its source graph

    Returns:
        Dictionary mapping entity URIs to their representations
    """
    representations = {}

    for entity in entities:
        graph = graphs.get(entity)
        if graph is not None:
            representations[entity] = self.create_representation(entity, graph)

    return representations

extract_entity_context(entity, graph)

Extract semantic context for an entity from the graph.

Parameters:

Name Type Description Default
entity URIRef

Entity to extract context for

required
graph RDFGraph

RDF graph containing the entity

required

Returns:

Type Description
EntityContext
EntityContext

role hints and merge-guard signatures.

EntityContext

is_predicate is True when the entity appears in the

EntityContext

predicate position of at least one triple.

EntityContext

is_type_value is True when another entity is explicitly

EntityContext

typed as this entity (i.e., (X, rdf:type, entity) exists).

EntityContext

This allows role inference for range/domain classes that are

EntityContext

referenced as types in sparse per-sentence graphs but carry no

EntityContext

a owl:Class declaration locally.

Source code in ontocast/tool/agg/normalizer.py
def extract_entity_context(self, entity: URIRef, graph: RDFGraph) -> EntityContext:
    """Extract semantic context for an entity from the graph.

    Args:
        entity: Entity to extract context for
        graph: RDF graph containing the entity

    Returns:
        :class:`EntityContext` with types, properties, labels, alt labels,
        role hints and merge-guard signatures.
        ``is_predicate`` is ``True`` when the entity appears in the
        predicate position of at least one triple.
        ``is_type_value`` is ``True`` when another entity is explicitly
        typed *as* this entity (i.e., ``(X, rdf:type, entity)`` exists).
        This allows role inference for range/domain classes that are
        referenced as types in sparse per-sentence graphs but carry no
        ``a owl:Class`` declaration locally.
    """
    from ontocast.tool.agg.signatures import canonical_literal

    types: list[URIRef] = []
    properties: set[URIRef] = set()
    labels: list[str] = []
    alt_labels: list[str] = []
    is_predicate = False
    is_type_value = False
    has_data_literal = False
    has_guard_literal = False
    predicate_literals: dict[URIRef, set[tuple[str, str]]] = {}
    predicate_string_literals: dict[URIRef, set[str]] = {}
    predicate_iri_objects: dict[URIRef, set[URIRef]] = {}
    schema_predicates = {RDF.type, RDFS.label, RDFS.comment}

    # Extract information from triples
    for s, p, o in graph:
        # When entity is subject
        if s == entity:
            if isinstance(p, URIRef):
                properties.add(p)

            # Collect types
            if p == RDF.type and isinstance(o, URIRef):
                types.append(o)
            elif isinstance(p, URIRef) and isinstance(o, URIRef):
                predicate_iri_objects.setdefault(p, set()).add(o)

            # Collect labels
            if p == RDFS.label and isinstance(o, Literal):
                labels.append(str(o))
            elif p not in schema_predicates and isinstance(o, Literal):
                has_data_literal = True
                if isinstance(p, URIRef):
                    canonical = canonical_literal(o)
                    if canonical is not None:
                        has_guard_literal = True
                        predicate_literals.setdefault(p, set()).add(canonical)
                    else:
                        normalized_value = self.normalize_string(str(o))
                        if normalized_value:
                            predicate_string_literals.setdefault(p, set()).add(
                                normalized_value
                            )
                if o.datatype is None:
                    value = str(o).strip()
                    if len(value) >= 3 and not value.isnumeric():
                        alt_labels.append(value)

        # When entity is object
        elif o == entity:
            if isinstance(p, URIRef):
                properties.add(p)
            if p == RDF.type:
                is_type_value = True

        # When entity is used as predicate
        if p == entity:
            is_predicate = True

    sorted_types = sorted(types, key=lambda entity: str(entity))
    sorted_properties = sorted(properties, key=lambda entity: str(entity))
    return EntityContext(
        types=sorted_types,
        properties=sorted_properties,
        labels=labels,
        alt_labels=alt_labels,
        is_predicate=is_predicate,
        is_type_value=is_type_value,
        predicate_literals={
            predicate: frozenset(values)
            for predicate, values in predicate_literals.items()
        },
        predicate_iri_objects={
            predicate: frozenset(objects)
            for predicate, objects in predicate_iri_objects.items()
        },
        has_data_literal=has_data_literal,
        has_guard_literal=has_guard_literal,
        predicate_string_literals={
            predicate: frozenset(values)
            for predicate, values in predicate_string_literals.items()
        },
    )

is_ontology_entity(entity)

Check if an entity belongs to an ontology namespace.

Facts live under facts_iri; everything else is an ontology entity.

Parameters:

Name Type Description Default
entity URIRef

Entity URI to check

required

Returns:

Type Description
bool

True if entity is not from the facts namespace

Source code in ontocast/tool/agg/normalizer.py
def is_ontology_entity(self, entity: URIRef) -> bool:
    """Check if an entity belongs to an ontology namespace.

    Facts live under ``facts_iri``; everything else is an ontology entity.

    Args:
        entity: Entity URI to check

    Returns:
        True if entity is **not** from the facts namespace
    """
    return not is_in_namespace(str(entity), self.facts_iri, context="facts")

normalize_string(text)

Normalize a string: lowercase, remove diacritics, clean special chars.

CamelCase is split so that it yields the same logical tokens as snake_case (e.g. 'PLRedShift' -> 'pl red shift').

Parameters:

Name Type Description Default
text str

Input string to normalize

required

Returns:

Type Description
str

Normalized string suitable for comparison

Examples:

'PLRedShift' -> 'pl red shift' 'PL_red_shift_value' -> 'pl red shift value' 'Café' -> 'cafe'

Source code in ontocast/tool/agg/normalizer.py
def normalize_string(self, text: str) -> str:
    """Normalize a string: lowercase, remove diacritics, clean special chars.

    CamelCase is split so that it yields the same logical tokens as snake_case
    (e.g. 'PLRedShift' -> 'pl red shift').

    Args:
        text: Input string to normalize

    Returns:
        Normalized string suitable for comparison

    Examples:
        'PLRedShift' -> 'pl red shift'
        'PL_red_shift_value' -> 'pl red shift value'
        'Café' -> 'cafe'
    """
    from ontocast.tool.agg.signatures import normalize_string_value

    return normalize_string_value(text)

normalize_uri(uri)

Extract and normalize the local part of a URI.

Parameters:

Name Type Description Default
uri URIRef

URI to normalize

required

Returns:

Type Description
str

Normalized local name

Examples:

'http://example.org/PLRedShift' -> 'pl red shift' 'http://example.org/PL_red_shift_value' -> 'pl red shift value'

Source code in ontocast/tool/agg/normalizer.py
def normalize_uri(self, uri: URIRef) -> str:
    """Extract and normalize the local part of a URI.

    Args:
        uri: URI to normalize

    Returns:
        Normalized local name

    Examples:
        'http://example.org/PLRedShift' -> 'pl red shift'
        'http://example.org/PL_red_shift_value' -> 'pl red shift value'
    """
    return normalize_uri_local_name(uri)

EntityRepresentation dataclass

Normalized representation of an entity for embedding.

Attributes:

Name Type Description
entity

Original entity URI

normal_form str

Normalized string (lowercase, no diacritics, etc.)

types list[URIRef]

List of type URIs for this entity

properties list[URIRef]

List of property URIs used with this entity

labels list[str]

List of labels found for this entity

alt_labels list[str]

String literals from domain predicates (when no rdfs:label)

representation str

Combined string representation r(e) for embedding

is_ontology_entity bool

Whether this entity is from an ontology namespace

role EntityRole | None

Detected entity role (class / property / instance)

predicate_literals dict[URIRef, frozenset[tuple[str, str]]]

Per-predicate canonical guard-relevant literal values pred -> {(value, kind)} (numeric/temporal only)

predicate_iri_objects dict[URIRef, frozenset[URIRef]]

Per-predicate outgoing IRI objects (subject-position only)

has_data_literal bool

Whether the entity holds any literal beyond rdf:type/rdfs:label/rdfs:comment

has_guard_literal bool

Whether any such literal is guard-relevant (numeric/temporal payload) — those entities get a strict lexical merge bar; string-bearing entities (names, descriptions) do not

predicate_string_literals dict[URIRef, frozenset[str]]

Per-predicate normalized non-guard string values pred -> {normalized} used for identifier-style conflict detection

Source code in ontocast/tool/agg/normalizer.py
@dataclass
class EntityRepresentation:
    """Normalized representation of an entity for embedding.

    Attributes:
        entity: Original entity URI
        normal_form: Normalized string (lowercase, no diacritics, etc.)
        types: List of type URIs for this entity
        properties: List of property URIs used with this entity
        labels: List of labels found for this entity
        alt_labels: String literals from domain predicates (when no rdfs:label)
        representation: Combined string representation r(e) for embedding
        is_ontology_entity: Whether this entity is from an ontology namespace
        role: Detected entity role (class / property / instance)
        predicate_literals: Per-predicate canonical guard-relevant literal
            values ``pred -> {(value, kind)}`` (numeric/temporal only)
        predicate_iri_objects: Per-predicate outgoing IRI objects
            (subject-position only)
        has_data_literal: Whether the entity holds any literal beyond
            rdf:type/rdfs:label/rdfs:comment
        has_guard_literal: Whether any such literal is guard-relevant
            (numeric/temporal payload) — those entities get a strict lexical
            merge bar; string-bearing entities (names, descriptions) do not
        predicate_string_literals: Per-predicate normalized non-guard string
            values ``pred -> {normalized}`` used for identifier-style conflict
            detection
    """

    iri: URIRef
    normal_form: str
    types: list[URIRef]
    properties: list[URIRef]
    labels: list[str]
    is_ontology_entity: bool
    alt_labels: list[str] = field(default_factory=list)
    role: EntityRole | None = field(default=None)
    core_representation: str = ""
    neighborhood_representation: str = ""
    representation: str = ""
    predicate_literals: dict[URIRef, frozenset[tuple[str, str]]] = field(
        default_factory=dict
    )
    predicate_iri_objects: dict[URIRef, frozenset[URIRef]] = field(default_factory=dict)
    has_data_literal: bool = False
    has_guard_literal: bool = False
    predicate_string_literals: dict[URIRef, frozenset[str]] = field(
        default_factory=dict
    )

    def __post_init__(self) -> None:
        if not self.core_representation:
            self.core_representation = self.representation or self.normal_form
        if not self.representation:
            self.representation = combine_embedding_text(self)

    ontology_iri: str | None = None