Skip to content

ontocast.tool

Tool package for OntoCast.

The Qdrant and LanceDB vector managers are re-exported lazily. Naming them in a plain from .vector_store import ... would defeat that subpackage's own lazy export, because a from-import resolves every name in its list immediately.

AtomicToolBox

Small tool surface used by atomic render/critic paths.

Configuration arrives as config sections, never as unpacked scalars. An earlier signature accepted both a :class:WebSearchConfig and seventeen flat web_search_* parameters mirroring its fields, chosen between by an if/else; production passed the section and only tests took the flat branch, so the tested configuration path was not the one that shipped. Each default also existed three times -- here, in settings.py, and inline at the read sites. Now settings.py is the single source.

Source code in ontocast/tool/atomic.py
class AtomicToolBox:
    """Small tool surface used by atomic render/critic paths.

    Configuration arrives as config *sections*, never as unpacked scalars. An
    earlier signature accepted both a :class:`WebSearchConfig` and seventeen
    flat ``web_search_*`` parameters mirroring its fields, chosen between by an
    ``if/else``; production passed the section and only tests took the flat
    branch, so the tested configuration path was not the one that shipped. Each
    default also existed three times -- here, in ``settings.py``, and inline at
    the read sites. Now ``settings.py`` is the single source.
    """

    def __init__(
        self,
        llm_provider: AtomicLLMProvider,
        search_provider: AtomicSearchProvider | None = None,
        web_search_config: WebSearchConfig | None = None,
        facts_validation_config: FactsValidationConfig | None = None,
        citation_vocabulary: dict[str, str] | None = None,
    ):
        """Build the atomic tool surface.

        Args:
            llm_provider: Supplies budget-aware LLM tools.
            search_provider: Optional web-search backend. Without one, search
                returns no hits regardless of configuration.
            web_search_config: Web-grounding settings. Defaults to
                :class:`WebSearchConfig`, which is disabled unless configured.
            facts_validation_config: Facts-gate settings consumed by the render
                and repair paths. Defaults to :class:`FactsValidationConfig`.
            citation_vocabulary: Bibliographic terms for citation-metadata
                units. Configuration rather than retrieval: a reference list is
                not domain content, so its vocabulary never reaches the catalog.
        """
        web_search = web_search_config or WebSearchConfig()
        facts_validation = facts_validation_config or FactsValidationConfig()

        self.llm_provider = llm_provider
        self.search_provider = search_provider
        self.web_search_config = web_search

        self.object_property_literal_check = (
            facts_validation.object_property_literal_check
        )
        # Finding-driven repair renders: each one is a provider call.
        self.facts_llm_repair_visits = facts_validation.llm_repair_visits
        # Code predicates for the LLM-free code -> catalog IRI repair.
        self.code_predicates: tuple[str, ...] = tuple(facts_validation.code_predicates)
        self.property_alias_min_ratio = facts_validation.property_alias_min_ratio
        self.citation_vocabulary: dict[str, str] = dict(citation_vocabulary or {})
        # Fallback vocabulary the facts prompt names for bounded quantities when
        # retrieval supplied no suitable class. An explicitly empty mapping
        # forbids the fallback.
        self.quantity_fallback_vocabulary: dict[str, str] | None = dict(
            facts_validation.quantity_fallback_vocabulary
        )
        # Non-meta vocabularies a deployment shares across catalogs and does not
        # want reported as unknown terms.
        self.additional_standard_namespaces: tuple[str, ...] = tuple(
            facts_validation.additional_standard_namespaces
        )

        self.web_search_enabled = web_search.enabled
        self.web_search_top_k = web_search.top_k
        self.web_search_max_snippet_chars = web_search.max_snippet_chars
        self.web_search_max_total_chars = web_search.max_total_chars
        self.web_search_for_ontology_render = web_search.ontology_render_enabled
        self.web_search_for_ontology_critic = web_search.ontology_critic_enabled
        self.web_search_for_facts_render = web_search.facts_render_enabled
        self.web_search_for_facts_critic = web_search.facts_critic_enabled
        self.web_search_planner_enabled = web_search.planner_enabled
        self.web_search_planner_max_queries = web_search.planner_max_queries
        self.web_search_planner_min_query_chars = web_search.planner_min_query_chars
        self.web_search_planner_min_confidence = web_search.planner_min_confidence
        self.web_search_reuse_evidence_across_attempt = (
            web_search.reuse_evidence_across_attempt
        )
        self.web_search_allowed_domains = _domain_set(web_search.allowed_domains)
        self.web_search_blocked_domains = _domain_set(web_search.blocked_domains)
        self.web_search_min_snippet_chars = web_search.min_snippet_chars

    async def get_llm_tool(self, budget_tracker) -> LLMTool:
        """Return a budget-aware LLM tool instance."""
        return await self.llm_provider.get_llm_tool(budget_tracker)

    async def search(
        self, query: str, max_results: int | None = None
    ) -> list[SearchHit]:
        """Run optional web search and return normalized hits."""
        if not self.web_search_enabled or self.search_provider is None:
            return []

        result_limit = max_results if max_results is not None else self.web_search_top_k
        return await self.search_provider.search(query=query, max_results=result_limit)

    def web_grounding_enabled_for_node(self, node: WorkflowNode) -> bool:
        """Return whether web grounding is enabled for a workflow node."""
        if not self.web_search_enabled:
            return False
        mapping = {
            WorkflowNode.TEXT_TO_ONTOLOGY: self.web_search_for_ontology_render,
            WorkflowNode.CRITICISE_ONTOLOGY: self.web_search_for_ontology_critic,
            WorkflowNode.TEXT_TO_FACTS: self.web_search_for_facts_render,
            WorkflowNode.CRITICISE_FACTS: self.web_search_for_facts_critic,
        }
        return mapping.get(node, False)

__init__(llm_provider, search_provider=None, web_search_config=None, facts_validation_config=None, citation_vocabulary=None)

Build the atomic tool surface.

Parameters:

Name Type Description Default
llm_provider AtomicLLMProvider

Supplies budget-aware LLM tools.

required
search_provider AtomicSearchProvider | None

Optional web-search backend. Without one, search returns no hits regardless of configuration.

None
web_search_config WebSearchConfig | None

Web-grounding settings. Defaults to :class:WebSearchConfig, which is disabled unless configured.

None
facts_validation_config FactsValidationConfig | None

Facts-gate settings consumed by the render and repair paths. Defaults to :class:FactsValidationConfig.

None
citation_vocabulary dict[str, str] | None

Bibliographic terms for citation-metadata units. Configuration rather than retrieval: a reference list is not domain content, so its vocabulary never reaches the catalog.

None
Source code in ontocast/tool/atomic.py
def __init__(
    self,
    llm_provider: AtomicLLMProvider,
    search_provider: AtomicSearchProvider | None = None,
    web_search_config: WebSearchConfig | None = None,
    facts_validation_config: FactsValidationConfig | None = None,
    citation_vocabulary: dict[str, str] | None = None,
):
    """Build the atomic tool surface.

    Args:
        llm_provider: Supplies budget-aware LLM tools.
        search_provider: Optional web-search backend. Without one, search
            returns no hits regardless of configuration.
        web_search_config: Web-grounding settings. Defaults to
            :class:`WebSearchConfig`, which is disabled unless configured.
        facts_validation_config: Facts-gate settings consumed by the render
            and repair paths. Defaults to :class:`FactsValidationConfig`.
        citation_vocabulary: Bibliographic terms for citation-metadata
            units. Configuration rather than retrieval: a reference list is
            not domain content, so its vocabulary never reaches the catalog.
    """
    web_search = web_search_config or WebSearchConfig()
    facts_validation = facts_validation_config or FactsValidationConfig()

    self.llm_provider = llm_provider
    self.search_provider = search_provider
    self.web_search_config = web_search

    self.object_property_literal_check = (
        facts_validation.object_property_literal_check
    )
    # Finding-driven repair renders: each one is a provider call.
    self.facts_llm_repair_visits = facts_validation.llm_repair_visits
    # Code predicates for the LLM-free code -> catalog IRI repair.
    self.code_predicates: tuple[str, ...] = tuple(facts_validation.code_predicates)
    self.property_alias_min_ratio = facts_validation.property_alias_min_ratio
    self.citation_vocabulary: dict[str, str] = dict(citation_vocabulary or {})
    # Fallback vocabulary the facts prompt names for bounded quantities when
    # retrieval supplied no suitable class. An explicitly empty mapping
    # forbids the fallback.
    self.quantity_fallback_vocabulary: dict[str, str] | None = dict(
        facts_validation.quantity_fallback_vocabulary
    )
    # Non-meta vocabularies a deployment shares across catalogs and does not
    # want reported as unknown terms.
    self.additional_standard_namespaces: tuple[str, ...] = tuple(
        facts_validation.additional_standard_namespaces
    )

    self.web_search_enabled = web_search.enabled
    self.web_search_top_k = web_search.top_k
    self.web_search_max_snippet_chars = web_search.max_snippet_chars
    self.web_search_max_total_chars = web_search.max_total_chars
    self.web_search_for_ontology_render = web_search.ontology_render_enabled
    self.web_search_for_ontology_critic = web_search.ontology_critic_enabled
    self.web_search_for_facts_render = web_search.facts_render_enabled
    self.web_search_for_facts_critic = web_search.facts_critic_enabled
    self.web_search_planner_enabled = web_search.planner_enabled
    self.web_search_planner_max_queries = web_search.planner_max_queries
    self.web_search_planner_min_query_chars = web_search.planner_min_query_chars
    self.web_search_planner_min_confidence = web_search.planner_min_confidence
    self.web_search_reuse_evidence_across_attempt = (
        web_search.reuse_evidence_across_attempt
    )
    self.web_search_allowed_domains = _domain_set(web_search.allowed_domains)
    self.web_search_blocked_domains = _domain_set(web_search.blocked_domains)
    self.web_search_min_snippet_chars = web_search.min_snippet_chars

get_llm_tool(budget_tracker) async

Return a budget-aware LLM tool instance.

Source code in ontocast/tool/atomic.py
async def get_llm_tool(self, budget_tracker) -> LLMTool:
    """Return a budget-aware LLM tool instance."""
    return await self.llm_provider.get_llm_tool(budget_tracker)

search(query, max_results=None) async

Run optional web search and return normalized hits.

Source code in ontocast/tool/atomic.py
async def search(
    self, query: str, max_results: int | None = None
) -> list[SearchHit]:
    """Run optional web search and return normalized hits."""
    if not self.web_search_enabled or self.search_provider is None:
        return []

    result_limit = max_results if max_results is not None else self.web_search_top_k
    return await self.search_provider.search(query=query, max_results=result_limit)

web_grounding_enabled_for_node(node)

Return whether web grounding is enabled for a workflow node.

Source code in ontocast/tool/atomic.py
def web_grounding_enabled_for_node(self, node: WorkflowNode) -> bool:
    """Return whether web grounding is enabled for a workflow node."""
    if not self.web_search_enabled:
        return False
    mapping = {
        WorkflowNode.TEXT_TO_ONTOLOGY: self.web_search_for_ontology_render,
        WorkflowNode.CRITICISE_ONTOLOGY: self.web_search_for_ontology_critic,
        WorkflowNode.TEXT_TO_FACTS: self.web_search_for_facts_render,
        WorkflowNode.CRITICISE_FACTS: self.web_search_for_facts_critic,
    }
    return mapping.get(node, False)

ChunkerTool

Bases: Tool

Tool for semantic chunking of documents.

Falls back to naive chunking if sentence-transformers is not available. Includes caching to avoid re-chunking the same text with the same parameters.

Source code in ontocast/tool/chunk/chunker.py
class ChunkerTool(Tool):
    """Tool for semantic chunking of documents.

    Falls back to naive chunking if sentence-transformers is not available.
    Includes caching to avoid re-chunking the same text with the same parameters.
    """

    config: ChunkConfig = Field(
        default_factory=ChunkConfig, description="Chunking configuration parameters"
    )
    chunking_mode: Literal["semantic", "naive"] = Field(
        default="semantic",
        description="Chunking mode: semantic (requires sentence-transformers) or naive (fallback)",
    )
    cache: Any = Field(default=None, exclude=True)

    def __init__(
        self,
        chunk_config: ChunkConfig | None = None,
        cache: Cacher | None = None,
        **kwargs,
    ):
        """Initialize the ChunkerTool.

        Args:
            chunk_config: Chunking configuration. If None, uses default ChunkConfig.
            cache: Optional shared Cacher instance. If None, creates a new one.
            **kwargs: Additional keyword arguments passed to the parent class.
        """
        super().__init__(**kwargs)
        # The model itself is process-shared and its construction is locked by
        # get_shared_encoder; all this holds is the per-tool adapter around it.
        self._embeddings: SharedSentenceTransformerEmbeddings | None = None
        self._embeddings_unavailable = False

        # Initialize cache - use shared cacher or create new one
        if cache is not None:
            self.cache = ToolCacher(cache, CHUNKER_CACHE_SUBDIR)
        else:
            # Standalone use (CLI helpers, direct library use): fall back to a
            # private Cacher on the configured/default directory.
            shared_cache = Cacher()
            self.cache = ToolCacher(shared_cache, CHUNKER_CACHE_SUBDIR)

        # Override config if provided
        if chunk_config is not None:
            self.config = chunk_config

        # Probe heavy deps only when semantic mode is requested
        if self.chunking_mode == "semantic" and not _semantic_chunking_available():
            self.chunking_mode = "naive"
            logger.warning(
                "Semantic chunking not available (needs the 'semantic-chunking' "
                "extra: sentence-transformers, hdbscan, umap-learn). "
                "Falling back to naive chunking."
            )

    def embeddings(self) -> SharedSentenceTransformerEmbeddings | None:
        """Embeddings over the process-shared encoder, or ``None`` if unavailable.

        The encoder is shared with retrieval and entity clustering when their
        model names match, so this loads no weights of its own in that case, and
        its inference is serialised against theirs.
        """
        if self._embeddings is not None or self._embeddings_unavailable:
            return self._embeddings
        if not _embedding_model_available():
            self._embeddings_unavailable = True
            return None
        try:
            self._embeddings = SharedSentenceTransformerEmbeddings(
                get_shared_encoder(
                    self.config.embedding_model,
                    feature=(
                        "Semantic chunking and schema detection. Install the "
                        "'semantic-chunking' extra"
                    ),
                ),
                normalize=False,
            )
        except Exception as exc:
            # Record the failure rather than retrying the load on every call:
            # a missing or broken checkpoint does not become available later in
            # the same process.
            logger.error("Failed to initialize chunker embedding model: %s", exc)
            self._embeddings_unavailable = True
            return None
        return self._embeddings

    def embed_texts(self, texts: list[str]) -> list[list[float]] | None:
        """Embed short texts with the chunker's model, or ``None`` if unavailable.

        Exposed so document-type detection can reuse the model already loaded
        for semantic chunking instead of constructing a second one. Returns
        ``None`` -- rather than raising -- when the semantic extras are absent,
        so callers degrade to their deterministic tiers exactly as chunking
        itself degrades to ``naive``.

        Args:
            texts: Short strings to embed (headings or sampled paragraphs).

        Returns:
            One embedding per input, or ``None`` when no model is available.
        """
        if not texts:
            return []
        embeddings = self.embeddings()
        if embeddings is None:
            return None
        try:
            return embeddings.embed_documents(texts)
        except Exception as exc:  # pragma: no cover - environment dependent
            logger.warning("Embedding failed, skipping semantic tier: %s", exc)
            return None

    def naive_split(self, doc: str) -> list[str]:
        """Split text by paragraph/sentence boundaries up to ``max_size``.

        Unlike :meth:`_naive_chunk`, does not enforce ``min_size`` filtering.
        """
        paragraphs = re.split(r"\n\s*\n", doc.strip())

        chunks: list[str] = []
        current_chunk = ""

        for paragraph in paragraphs:
            paragraph = paragraph.strip()
            if not paragraph:
                continue

            if (
                current_chunk
                and len(current_chunk) + len(paragraph) + 2 > self.config.max_size
            ):
                if current_chunk:
                    chunks.append(current_chunk.strip())
                current_chunk = paragraph
            else:
                if current_chunk:
                    current_chunk += "\n\n" + paragraph
                else:
                    current_chunk = paragraph

            if len(current_chunk) > self.config.max_size:
                if len(current_chunk) - len(paragraph) - 2 > 0:
                    prev_chunk = current_chunk[
                        : len(current_chunk) - len(paragraph) - 2
                    ].strip()
                    if prev_chunk:
                        chunks.append(prev_chunk)

                sentences = re.split(r"(?<=[.!?])\s+", paragraph)
                temp_chunk = ""

                for sentence in sentences:
                    if len(temp_chunk) + len(sentence) + 1 > self.config.max_size:
                        if temp_chunk:
                            chunks.append(temp_chunk.strip())
                        temp_chunk = sentence
                    else:
                        if temp_chunk:
                            temp_chunk += " " + sentence
                        else:
                            temp_chunk = sentence

                current_chunk = temp_chunk

        if current_chunk:
            chunks.append(current_chunk.strip())

        return chunks

    def size_text(self, doc: str) -> list[str]:
        """Split ``doc`` to respect ``min_size`` / ``max_size`` using naive boundaries."""
        return size_bounded_text(doc, self.config, self.naive_split)

    def _naive_chunk(self, doc: str) -> list[str]:
        """Naive chunking fallback when semantic chunking is not available.

        Args:
            doc: The document text to chunk.

        Returns:
            List of text chunks.
        """
        chunks = self.size_text(doc)

        logger.info(f"Naive chunking produced {len(chunks)} chunks")
        return chunks

    def __call__(self, doc: str) -> list[str]:
        """Chunk a document into semantic segments.

        Args:
            doc: The document text to chunk.

        Returns:
            List of text chunks.
        """
        # Prepare configuration for caching. The "model" key name is kept even
        # though its source moved to ChunkConfig -- the dict is hashed, so
        # renaming it would invalidate every cached chunking for no reason.
        config_dict = {
            "model": self.config.embedding_model,
            "chunking_mode": self.chunking_mode,
            "max_size": self.config.max_size,
            "min_size": self.config.min_size,
        }

        # Check cache first
        cached_result = self.cache.get(doc, config=config_dict)
        if cached_result is not None:
            logger.debug("Cache hit for document chunking")
            return cached_result

        # Perform chunking
        embeddings = None if self.chunking_mode == "naive" else self.embeddings()
        if embeddings is None or not _semantic_chunking_available():
            if self.chunking_mode != "naive":
                logger.warning(
                    "Semantic chunking requested but not available. "
                    "Falling back to naive chunking."
                )
            result = self._naive_chunk(doc)
        else:
            from ontocast.tool.chunk.util import SemanticChunker

            text_splitter = SemanticChunker(
                embeddings=embeddings,
                chunk_config=self.config,
                sentence_split_regex=SENTENCE_SPLIT_REGEX,
            )

            try:
                # SemanticChunker now handles max_size internally
                result_docs = text_splitter.create_documents([doc])
                result = [chunk.page_content for chunk in result_docs]
            except ValueError as exc:
                # Degenerate inputs (too few distinct sentences for the
                # HDBSCAN neighborhood) must not fail chunking outright.
                logger.warning(
                    "Semantic chunking failed (%s); falling back to "
                    "naive chunking for this text.",
                    exc,
                )
                result = self._naive_chunk(doc)

            # Log chunk lengths for debugging
            lens = [len(chunk) for chunk in result]
            logger.info(
                f"Semantic chunking produced {len(result)} chunks with lengths: {lens}"
            )

        # Cache the result
        self.cache.set(doc, result, config=config_dict)
        logger.debug("Cached document chunking result")

        return result

__call__(doc)

Chunk a document into semantic segments.

Parameters:

Name Type Description Default
doc str

The document text to chunk.

required

Returns:

Type Description
list[str]

List of text chunks.

Source code in ontocast/tool/chunk/chunker.py
def __call__(self, doc: str) -> list[str]:
    """Chunk a document into semantic segments.

    Args:
        doc: The document text to chunk.

    Returns:
        List of text chunks.
    """
    # Prepare configuration for caching. The "model" key name is kept even
    # though its source moved to ChunkConfig -- the dict is hashed, so
    # renaming it would invalidate every cached chunking for no reason.
    config_dict = {
        "model": self.config.embedding_model,
        "chunking_mode": self.chunking_mode,
        "max_size": self.config.max_size,
        "min_size": self.config.min_size,
    }

    # Check cache first
    cached_result = self.cache.get(doc, config=config_dict)
    if cached_result is not None:
        logger.debug("Cache hit for document chunking")
        return cached_result

    # Perform chunking
    embeddings = None if self.chunking_mode == "naive" else self.embeddings()
    if embeddings is None or not _semantic_chunking_available():
        if self.chunking_mode != "naive":
            logger.warning(
                "Semantic chunking requested but not available. "
                "Falling back to naive chunking."
            )
        result = self._naive_chunk(doc)
    else:
        from ontocast.tool.chunk.util import SemanticChunker

        text_splitter = SemanticChunker(
            embeddings=embeddings,
            chunk_config=self.config,
            sentence_split_regex=SENTENCE_SPLIT_REGEX,
        )

        try:
            # SemanticChunker now handles max_size internally
            result_docs = text_splitter.create_documents([doc])
            result = [chunk.page_content for chunk in result_docs]
        except ValueError as exc:
            # Degenerate inputs (too few distinct sentences for the
            # HDBSCAN neighborhood) must not fail chunking outright.
            logger.warning(
                "Semantic chunking failed (%s); falling back to "
                "naive chunking for this text.",
                exc,
            )
            result = self._naive_chunk(doc)

        # Log chunk lengths for debugging
        lens = [len(chunk) for chunk in result]
        logger.info(
            f"Semantic chunking produced {len(result)} chunks with lengths: {lens}"
        )

    # Cache the result
    self.cache.set(doc, result, config=config_dict)
    logger.debug("Cached document chunking result")

    return result

__init__(chunk_config=None, cache=None, **kwargs)

Initialize the ChunkerTool.

Parameters:

Name Type Description Default
chunk_config ChunkConfig | None

Chunking configuration. If None, uses default ChunkConfig.

None
cache Cacher | None

Optional shared Cacher instance. If None, creates a new one.

None
**kwargs

Additional keyword arguments passed to the parent class.

{}
Source code in ontocast/tool/chunk/chunker.py
def __init__(
    self,
    chunk_config: ChunkConfig | None = None,
    cache: Cacher | None = None,
    **kwargs,
):
    """Initialize the ChunkerTool.

    Args:
        chunk_config: Chunking configuration. If None, uses default ChunkConfig.
        cache: Optional shared Cacher instance. If None, creates a new one.
        **kwargs: Additional keyword arguments passed to the parent class.
    """
    super().__init__(**kwargs)
    # The model itself is process-shared and its construction is locked by
    # get_shared_encoder; all this holds is the per-tool adapter around it.
    self._embeddings: SharedSentenceTransformerEmbeddings | None = None
    self._embeddings_unavailable = False

    # Initialize cache - use shared cacher or create new one
    if cache is not None:
        self.cache = ToolCacher(cache, CHUNKER_CACHE_SUBDIR)
    else:
        # Standalone use (CLI helpers, direct library use): fall back to a
        # private Cacher on the configured/default directory.
        shared_cache = Cacher()
        self.cache = ToolCacher(shared_cache, CHUNKER_CACHE_SUBDIR)

    # Override config if provided
    if chunk_config is not None:
        self.config = chunk_config

    # Probe heavy deps only when semantic mode is requested
    if self.chunking_mode == "semantic" and not _semantic_chunking_available():
        self.chunking_mode = "naive"
        logger.warning(
            "Semantic chunking not available (needs the 'semantic-chunking' "
            "extra: sentence-transformers, hdbscan, umap-learn). "
            "Falling back to naive chunking."
        )

embed_texts(texts)

Embed short texts with the chunker's model, or None if unavailable.

Exposed so document-type detection can reuse the model already loaded for semantic chunking instead of constructing a second one. Returns None -- rather than raising -- when the semantic extras are absent, so callers degrade to their deterministic tiers exactly as chunking itself degrades to naive.

Parameters:

Name Type Description Default
texts list[str]

Short strings to embed (headings or sampled paragraphs).

required

Returns:

Type Description
list[list[float]] | None

One embedding per input, or None when no model is available.

Source code in ontocast/tool/chunk/chunker.py
def embed_texts(self, texts: list[str]) -> list[list[float]] | None:
    """Embed short texts with the chunker's model, or ``None`` if unavailable.

    Exposed so document-type detection can reuse the model already loaded
    for semantic chunking instead of constructing a second one. Returns
    ``None`` -- rather than raising -- when the semantic extras are absent,
    so callers degrade to their deterministic tiers exactly as chunking
    itself degrades to ``naive``.

    Args:
        texts: Short strings to embed (headings or sampled paragraphs).

    Returns:
        One embedding per input, or ``None`` when no model is available.
    """
    if not texts:
        return []
    embeddings = self.embeddings()
    if embeddings is None:
        return None
    try:
        return embeddings.embed_documents(texts)
    except Exception as exc:  # pragma: no cover - environment dependent
        logger.warning("Embedding failed, skipping semantic tier: %s", exc)
        return None

embeddings()

Embeddings over the process-shared encoder, or None if unavailable.

The encoder is shared with retrieval and entity clustering when their model names match, so this loads no weights of its own in that case, and its inference is serialised against theirs.

Source code in ontocast/tool/chunk/chunker.py
def embeddings(self) -> SharedSentenceTransformerEmbeddings | None:
    """Embeddings over the process-shared encoder, or ``None`` if unavailable.

    The encoder is shared with retrieval and entity clustering when their
    model names match, so this loads no weights of its own in that case, and
    its inference is serialised against theirs.
    """
    if self._embeddings is not None or self._embeddings_unavailable:
        return self._embeddings
    if not _embedding_model_available():
        self._embeddings_unavailable = True
        return None
    try:
        self._embeddings = SharedSentenceTransformerEmbeddings(
            get_shared_encoder(
                self.config.embedding_model,
                feature=(
                    "Semantic chunking and schema detection. Install the "
                    "'semantic-chunking' extra"
                ),
            ),
            normalize=False,
        )
    except Exception as exc:
        # Record the failure rather than retrying the load on every call:
        # a missing or broken checkpoint does not become available later in
        # the same process.
        logger.error("Failed to initialize chunker embedding model: %s", exc)
        self._embeddings_unavailable = True
        return None
    return self._embeddings

naive_split(doc)

Split text by paragraph/sentence boundaries up to max_size.

Unlike :meth:_naive_chunk, does not enforce min_size filtering.

Source code in ontocast/tool/chunk/chunker.py
def naive_split(self, doc: str) -> list[str]:
    """Split text by paragraph/sentence boundaries up to ``max_size``.

    Unlike :meth:`_naive_chunk`, does not enforce ``min_size`` filtering.
    """
    paragraphs = re.split(r"\n\s*\n", doc.strip())

    chunks: list[str] = []
    current_chunk = ""

    for paragraph in paragraphs:
        paragraph = paragraph.strip()
        if not paragraph:
            continue

        if (
            current_chunk
            and len(current_chunk) + len(paragraph) + 2 > self.config.max_size
        ):
            if current_chunk:
                chunks.append(current_chunk.strip())
            current_chunk = paragraph
        else:
            if current_chunk:
                current_chunk += "\n\n" + paragraph
            else:
                current_chunk = paragraph

        if len(current_chunk) > self.config.max_size:
            if len(current_chunk) - len(paragraph) - 2 > 0:
                prev_chunk = current_chunk[
                    : len(current_chunk) - len(paragraph) - 2
                ].strip()
                if prev_chunk:
                    chunks.append(prev_chunk)

            sentences = re.split(r"(?<=[.!?])\s+", paragraph)
            temp_chunk = ""

            for sentence in sentences:
                if len(temp_chunk) + len(sentence) + 1 > self.config.max_size:
                    if temp_chunk:
                        chunks.append(temp_chunk.strip())
                    temp_chunk = sentence
                else:
                    if temp_chunk:
                        temp_chunk += " " + sentence
                    else:
                        temp_chunk = sentence

            current_chunk = temp_chunk

    if current_chunk:
        chunks.append(current_chunk.strip())

    return chunks

size_text(doc)

Split doc to respect min_size / max_size using naive boundaries.

Source code in ontocast/tool/chunk/chunker.py
def size_text(self, doc: str) -> list[str]:
    """Split ``doc`` to respect ``min_size`` / ``max_size`` using naive boundaries."""
    return size_bounded_text(doc, self.config, self.naive_split)

ConverterTool

Bases: Tool

Tool for converting documents to native DoclingDocument format.

This class provides functionality for converting various document formats into DoclingDocument objects that can be processed by the OntoCast system. It includes caching to avoid re-converting the same documents.

Attributes:

Name Type Description
supported_extensions set[str]

Set of supported file extensions.

cache Any

Cacher instance for caching conversion results.

Source code in ontocast/tool/converter.py
class ConverterTool(Tool):
    """Tool for converting documents to native DoclingDocument format.

    This class provides functionality for converting various document formats
    into DoclingDocument objects that can be processed by the OntoCast system.
    It includes caching to avoid re-converting the same documents.

    Attributes:
        supported_extensions: Set of supported file extensions.
        cache: Cacher instance for caching conversion results.
    """

    supported_extensions: set[str] = Field(
        default={".pdf", ".pptx"},
        description="Set of supported file extensions",
    )
    cache: Any = Field(default=None, exclude=True)
    converter_config: ConverterConfig = Field(default_factory=ConverterConfig)

    def __init__(
        self,
        cache: Cacher | None = None,
        converter_config: ConverterConfig | None = None,
        **kwargs,
    ):
        """Initialize the converter tool.

        Args:
            cache: Optional shared Cacher instance. If None, creates a new one.
            **kwargs: Additional keyword arguments passed to the parent class.
        """
        super().__init__(**kwargs)
        self.converter_config = converter_config or ConverterConfig()
        self._converter = None
        self._converter_lock = threading.Lock()  # Lock for thread-safe converter access

        # Initialize cache - use shared cacher or create new one
        if cache is not None:
            self.cache = ToolCacher(cache, CONVERTER_CACHE_SUBDIR)
        else:
            # Standalone use (CLI helpers, direct library use): fall back to a
            # private Cacher on the configured/default directory.
            shared_cache = Cacher()
            self.cache = ToolCacher(shared_cache, CONVERTER_CACHE_SUBDIR)

    def ensure_converter(self) -> Any:
        """Return the Docling converter, building it once on first use.

        Exposed so a server can warm the models at startup instead of making the
        first request pay for loading the layout, OCR and table-structure models.

        Returns:
            Any: The shared docling ``DocumentConverter``. Untyped because
            docling is an optional dependency resolved lazily.
        """
        converter = self._converter
        if converter is not None:
            return converter
        with self._converter_lock:
            if self._converter is None:
                logger.info("Building Docling DocumentConverter (first conversion)")
                try:
                    self._converter = build_document_converter(self.converter_config)
                except ImportError as e:
                    logger.error("Could not import DocumentConverter: %s", e)
                    raise
            return self._converter

    def __call__(self, file_input: bytes | str | pathlib.Path) -> DoclingDocument:
        """Convert a document to a DoclingDocument.

        Args:
            file_input: The input file as either bytes, string, or pathlib.Path.

        Returns:
            DoclingDocument: The converted document.
        """
        # Prepare content for caching
        if isinstance(file_input, bytes):
            content_for_cache = file_input
        elif isinstance(file_input, pathlib.Path):
            content_for_cache = file_input.read_bytes()
        elif isinstance(file_input, str):
            raise TypeError(
                "ConverterTool expects bytes or pathlib.Path; "
                "use plain_text_to_docling_doc for raw text."
            )
        else:
            raise TypeError(f"Unsupported file input type: {type(file_input).__name__}")

        # Check cache first. The format version lives in the key, so bumping it
        # orphans stale entries in place rather than stranding a whole directory.
        config_dict = self.converter_config.model_dump(mode="json")
        config_dict["cache_format_version"] = CONVERTER_CACHE_FORMAT_VERSION
        cached_result = self.cache.get(content_for_cache, config=config_dict)
        if cached_result is not None:
            logger.debug("Cache hit for document conversion")
            docling_document = require(
                "docling_core.types.doc", feature="Document conversion"
            ).DoclingDocument
            if isinstance(cached_result, docling_document):
                return cached_result
            if isinstance(cached_result, str):
                return docling_document.model_validate_json(cached_result)
            if isinstance(cached_result, dict):
                return docling_document.model_validate(cached_result)

        converter = self.ensure_converter()

        # Deliberately outside the lock: conversion is the multi-second part, and
        # holding the build lock across it serialised every concurrent document
        # in the process behind one another. Docling's convert() is a per-call
        # pipeline over its own result objects.
        if isinstance(file_input, bytes):
            try:
                base_models_module = importlib.import_module(
                    "docling.datamodel.base_models"
                )
                DocumentStream = getattr(base_models_module, "DocumentStream")
                ds = DocumentStream(name="doc", stream=BytesIO(file_input))
            except ImportError:
                raise ImportError(f"Could not import DocumentConverter: {file_input}")
            result = converter.convert(ds)
            converted_result = result.document
        elif isinstance(file_input, pathlib.Path):
            result = converter.convert(file_input)
            converted_result = result.document
        else:
            raise TypeError(f"Unsupported file input type: {type(file_input).__name__}")

        converted_result = apply_text_sanitizers(
            converted_result,
            repair_ligature_gaps_enabled=self.converter_config.repair_ligature_gaps,
        )

        # Cache the result as JSON for stable serialization
        self.cache.set(
            content_for_cache,
            converted_result.model_dump_json(),
            config=config_dict,
        )
        logger.debug("Cached document conversion result")

        return converted_result

__call__(file_input)

Convert a document to a DoclingDocument.

Parameters:

Name Type Description Default
file_input bytes | str | Path

The input file as either bytes, string, or pathlib.Path.

required

Returns:

Name Type Description
DoclingDocument DoclingDocument

The converted document.

Source code in ontocast/tool/converter.py
def __call__(self, file_input: bytes | str | pathlib.Path) -> DoclingDocument:
    """Convert a document to a DoclingDocument.

    Args:
        file_input: The input file as either bytes, string, or pathlib.Path.

    Returns:
        DoclingDocument: The converted document.
    """
    # Prepare content for caching
    if isinstance(file_input, bytes):
        content_for_cache = file_input
    elif isinstance(file_input, pathlib.Path):
        content_for_cache = file_input.read_bytes()
    elif isinstance(file_input, str):
        raise TypeError(
            "ConverterTool expects bytes or pathlib.Path; "
            "use plain_text_to_docling_doc for raw text."
        )
    else:
        raise TypeError(f"Unsupported file input type: {type(file_input).__name__}")

    # Check cache first. The format version lives in the key, so bumping it
    # orphans stale entries in place rather than stranding a whole directory.
    config_dict = self.converter_config.model_dump(mode="json")
    config_dict["cache_format_version"] = CONVERTER_CACHE_FORMAT_VERSION
    cached_result = self.cache.get(content_for_cache, config=config_dict)
    if cached_result is not None:
        logger.debug("Cache hit for document conversion")
        docling_document = require(
            "docling_core.types.doc", feature="Document conversion"
        ).DoclingDocument
        if isinstance(cached_result, docling_document):
            return cached_result
        if isinstance(cached_result, str):
            return docling_document.model_validate_json(cached_result)
        if isinstance(cached_result, dict):
            return docling_document.model_validate(cached_result)

    converter = self.ensure_converter()

    # Deliberately outside the lock: conversion is the multi-second part, and
    # holding the build lock across it serialised every concurrent document
    # in the process behind one another. Docling's convert() is a per-call
    # pipeline over its own result objects.
    if isinstance(file_input, bytes):
        try:
            base_models_module = importlib.import_module(
                "docling.datamodel.base_models"
            )
            DocumentStream = getattr(base_models_module, "DocumentStream")
            ds = DocumentStream(name="doc", stream=BytesIO(file_input))
        except ImportError:
            raise ImportError(f"Could not import DocumentConverter: {file_input}")
        result = converter.convert(ds)
        converted_result = result.document
    elif isinstance(file_input, pathlib.Path):
        result = converter.convert(file_input)
        converted_result = result.document
    else:
        raise TypeError(f"Unsupported file input type: {type(file_input).__name__}")

    converted_result = apply_text_sanitizers(
        converted_result,
        repair_ligature_gaps_enabled=self.converter_config.repair_ligature_gaps,
    )

    # Cache the result as JSON for stable serialization
    self.cache.set(
        content_for_cache,
        converted_result.model_dump_json(),
        config=config_dict,
    )
    logger.debug("Cached document conversion result")

    return converted_result

__init__(cache=None, converter_config=None, **kwargs)

Initialize the converter tool.

Parameters:

Name Type Description Default
cache Cacher | None

Optional shared Cacher instance. If None, creates a new one.

None
**kwargs

Additional keyword arguments passed to the parent class.

{}
Source code in ontocast/tool/converter.py
def __init__(
    self,
    cache: Cacher | None = None,
    converter_config: ConverterConfig | None = None,
    **kwargs,
):
    """Initialize the converter tool.

    Args:
        cache: Optional shared Cacher instance. If None, creates a new one.
        **kwargs: Additional keyword arguments passed to the parent class.
    """
    super().__init__(**kwargs)
    self.converter_config = converter_config or ConverterConfig()
    self._converter = None
    self._converter_lock = threading.Lock()  # Lock for thread-safe converter access

    # Initialize cache - use shared cacher or create new one
    if cache is not None:
        self.cache = ToolCacher(cache, CONVERTER_CACHE_SUBDIR)
    else:
        # Standalone use (CLI helpers, direct library use): fall back to a
        # private Cacher on the configured/default directory.
        shared_cache = Cacher()
        self.cache = ToolCacher(shared_cache, CONVERTER_CACHE_SUBDIR)

ensure_converter()

Return the Docling converter, building it once on first use.

Exposed so a server can warm the models at startup instead of making the first request pay for loading the layout, OCR and table-structure models.

Returns:

Name Type Description
Any Any

The shared docling DocumentConverter. Untyped because

Any

docling is an optional dependency resolved lazily.

Source code in ontocast/tool/converter.py
def ensure_converter(self) -> Any:
    """Return the Docling converter, building it once on first use.

    Exposed so a server can warm the models at startup instead of making the
    first request pay for loading the layout, OCR and table-structure models.

    Returns:
        Any: The shared docling ``DocumentConverter``. Untyped because
        docling is an optional dependency resolved lazily.
    """
    converter = self._converter
    if converter is not None:
        return converter
    with self._converter_lock:
        if self._converter is None:
            logger.info("Building Docling DocumentConverter (first conversion)")
            try:
                self._converter = build_document_converter(self.converter_config)
            except ImportError as e:
                logger.error("Could not import DocumentConverter: %s", e)
                raise
        return self._converter

EmbeddingBasedAggregator

Main aggregator using embedding-based entity disambiguation.

Pipeline stages: 1. Entity normalisation (with semantic context) 2. Parallel embedding 3. Similarity-based clustering 4. Representative selection (prefer ontology, then simplicity) 5. URI normalisation (PascalCase/camelCase under DEFAULT_IRI) 6. Graph rewriting

ContentUnit types are handled as follows: - facts: entities under base_iri are normalised. - ontology: all other entities are considered ontology entities and preserved.

Source code in ontocast/tool/agg/aggregate.py
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
class EmbeddingBasedAggregator:
    """Main aggregator using embedding-based entity disambiguation.

    Pipeline stages:
    1. Entity normalisation (with semantic context)
    2. Parallel embedding
    3. Similarity-based clustering
    4. Representative selection (prefer ontology, then simplicity)
    5. URI normalisation (PascalCase/camelCase under DEFAULT_IRI)
    6. Graph rewriting

    ContentUnit types are handled as follows:
    - ``facts``: entities under ``base_iri`` are normalised.
    - ``ontology``: all other entities are considered ontology entities and preserved.
    """

    def __init__(
        self,
        config: AggregationConfig | None = None,
        *,
        add_sameas_links: bool = True,
        base_iri: str = DEFAULT_IRI,
        candidate_similarity_threshold: float | None = None,
    ):
        """Initialise the embedding-based aggregator.

        Every tunable lives on :class:`AggregationConfig`, so ``settings.py``
        stays the single source of their defaults rather than restating them in
        this signature and again at the call site.

        Args:
            config: Aggregation tunables. Defaults to :class:`AggregationConfig`,
                i.e. the environment-resolved settings.
            add_sameas_links: Whether to add ``owl:sameAs`` for merged entities.
                Not config-driven: callers choose it per use, and the entity
                aligner wants different behaviour from the pipeline.
            base_iri: Base IRI for fact entity URIs. Entities under this
                namespace are facts; everything else is treated as an ontology
                entity and left unchanged.
            candidate_similarity_threshold: Overrides the configured permissive
                candidate threshold. The entity aligner pins it to its own
                similarity threshold rather than the pipeline's.
        """
        cfg = config or AggregationConfig()

        self.base_iri = base_iri
        self.candidate_similarity_threshold = (
            cfg.candidate_similarity_threshold
            if candidate_similarity_threshold is None
            else candidate_similarity_threshold
        )
        self.lexical_label_jaccard = cfg.lexical_label_jaccard
        self.lexical_sequence_ratio = cfg.lexical_sequence_ratio
        self.lexical_token_jaccard = cfg.lexical_token_jaccard
        self.functional_min_empirical_support = cfg.functional_min_empirical_support
        self.sibling_guard_scope = str(cfg.sibling_guard_scope)

        # Pipeline components (EntityClusterer imports sklearn/ST lazily)
        from .clustering import EntityClusterer

        self.normalizer = EntityNormalizer(facts_iri=self.base_iri)
        self.clusterer = EntityClusterer(
            embedding_model=cfg.embedding_model,
            similarity_threshold=cfg.similarity_threshold,
        )
        self.selector = ClusterRepresentativeSelector()
        self.uri_builder = URIBuilder(base_iri=self.base_iri)
        self.rewriter = GraphRewriter(
            add_sameas_links=add_sameas_links,
            blocked_sameas_namespaces=(self.base_iri,),
        )

    @staticmethod
    def _entity_in_namespace(entity: URIRef, namespace: URIRef | str | None) -> bool:
        """Return True when *entity* is under the provided namespace."""
        if namespace is None:
            return False
        return is_in_namespace(str(entity), str(namespace), context="auto")

    def _is_fact_entity_in_unit(self, entity: URIRef, unit: ContentUnit) -> bool:
        """Classify whether an entity should be treated as a fact in this unit.

        Facts are entities in either:
        - the configured base facts namespace (``base_iri``), or
        - the unit document namespace (``unit.doc_iri``).
        """
        return self._entity_in_namespace(
            entity, self.base_iri
        ) or self._entity_in_namespace(entity, unit.doc_iri)

    @staticmethod
    def _is_standard_ontology_entity(entity: URIRef) -> bool:
        """Return True for entities from built-in standard RDF vocabularies."""
        entity_str = str(entity)
        return any(entity_str.startswith(prefix) for prefix in _STANDARD_NAMESPACES)

    def _build_known_ontology_entities(
        self, ontology_graph: RDFGraph | None
    ) -> set[URIRef]:
        """Build a set of known ontology entities from ontology and std vocabularies."""
        known_entities: set[URIRef] = set()

        if ontology_graph is not None:
            for s, p, o in ontology_graph:
                if isinstance(s, URIRef):
                    known_entities.add(s)
                if isinstance(p, URIRef):
                    known_entities.add(p)
                if isinstance(o, URIRef):
                    known_entities.add(o)

        return known_entities

    @staticmethod
    def _tokenize(text: str) -> set[str]:
        return {token for token in text.split() if len(token) > 2}

    @staticmethod
    def _role_key(representation: EntityRepresentation) -> str:
        role = (
            representation.role
            if representation.role is not None
            else EntityRole.INSTANCE
        )
        return str(role)

    @staticmethod
    def _jaccard(left: set[str], right: set[str]) -> float:
        if not left and not right:
            return 1.0
        union = left | right
        return len(left & right) / len(union)

    @staticmethod
    def _instance_like_local_name(entity: URIRef) -> str | None:
        """Return normalized local name when URI ends with numeric suffix."""
        local_name = normalize_uri_local_name(entity).replace(" ", "")
        if not local_name:
            return None
        match = _INSTANCE_LOCAL_NAME_RE.match(local_name)
        if match is None:
            return None
        if len(match.group("stem")) < 3:
            return None
        return local_name

    def _are_roles_compatible(
        self,
        left: URIRef,
        right: URIRef,
        representations: dict[URIRef, EntityRepresentation],
    ) -> bool:
        left_rep = representations.get(left)
        right_rep = representations.get(right)
        if left_rep is None or right_rep is None:
            return False
        return self._role_key(left_rep) == self._role_key(right_rep)

    def _are_types_compatible(
        self,
        left: URIRef,
        right: URIRef,
        representations: dict[URIRef, EntityRepresentation],
    ) -> bool:
        left_rep = representations.get(left)
        right_rep = representations.get(right)
        if left_rep is None or right_rep is None:
            return False
        left_types = set(left_rep.types)
        right_types = set(right_rep.types)
        if not left_types or not right_types:
            return True
        return bool(left_types & right_types)

    def _are_lexical_aliases(
        self,
        left: URIRef,
        right: URIRef,
        representations: dict[URIRef, EntityRepresentation],
    ) -> bool:
        left_rep = representations.get(left)
        right_rep = representations.get(right)
        if left_rep is None or right_rep is None:
            return False
        if left_rep.normal_form == right_rep.normal_form:
            return True

        left_instance_name = self._instance_like_local_name(left)
        right_instance_name = self._instance_like_local_name(right)
        if (
            left_instance_name is not None
            and right_instance_name is not None
            and left_instance_name == right_instance_name
        ):
            return True

        left_label_tokens = {
            self.normalizer.normalize_string(label)
            for label in left_rep.labels + left_rep.alt_labels
            if label.strip()
        }
        right_label_tokens = {
            self.normalizer.normalize_string(label)
            for label in right_rep.labels + right_rep.alt_labels
            if label.strip()
        }
        if left_label_tokens & right_label_tokens:
            return True

        # Abbreviation-aware tier: "baranov d" vs "dmitry baranov" alias when
        # every token of one label matches a token of the other exactly or as
        # a single-character initial, with at least one shared full token.
        if self._labels_alias_with_initials(left_label_tokens, right_label_tokens):
            return True

        # Guard-literal-bearing entities (measurements, dated events) are
        # individuated by their payload, not their phrasing: "PL red shift of
        # SL1" vs "PL red shift of SL2" share most tokens yet denote distinct
        # values. Only the exact tiers above may merge them. String literals
        # (names, descriptions) do not raise this bar — disjoint identifier
        # strings are handled by _have_conflicting_literals instead.
        if left_rep.has_guard_literal and right_rep.has_guard_literal:
            return False

        if left_label_tokens and right_label_tokens:
            max_label_overlap = 0.0
            for left_label in left_label_tokens:
                left_tokens = self._tokenize(left_label)
                for right_label in right_label_tokens:
                    right_tokens = self._tokenize(right_label)
                    overlap = self._jaccard(left_tokens, right_tokens)
                    max_label_overlap = max(max_label_overlap, overlap)
            if max_label_overlap >= self.lexical_label_jaccard:
                return True

        left_normalized = left_rep.normal_form.strip()
        right_normalized = right_rep.normal_form.strip()
        if left_normalized and right_normalized:
            if left_normalized != right_normalized and (
                left_normalized.startswith(f"{right_normalized} ")
                or right_normalized.startswith(f"{left_normalized} ")
            ):
                return False

        ratio = SequenceMatcher(
            None, left_rep.normal_form, right_rep.normal_form
        ).ratio()
        if ratio >= self.lexical_sequence_ratio:
            return True

        left_tokens = self._tokenize(left_rep.normal_form)
        right_tokens = self._tokenize(right_rep.normal_form)
        if len(left_tokens) >= 2 and len(right_tokens) >= 2:
            if self._jaccard(left_tokens, right_tokens) >= self.lexical_token_jaccard:
                return True

        return False

    @staticmethod
    def _tokens_alias_compatible(left: str, right: str) -> bool:
        """Exact token match, or a (possibly dotted) single-char initial of it."""
        if left == right:
            return True
        shorter, longer = (left, right) if len(left) <= len(right) else (right, left)
        return len(shorter) == 1 and longer.startswith(shorter)

    @classmethod
    def _labels_alias_with_initials(
        cls,
        left_labels: set[str],
        right_labels: set[str],
    ) -> bool:
        """True when a label pair matches token-injectively allowing initials.

        Every token of the shorter label must match a distinct token of the
        longer one (exactly, or as a single-character initial), and at least
        one matched token must be a full word (len > 2). Generic abbreviation
        structure — nothing person-specific.
        """
        for left_label in left_labels:
            left_tokens = left_label.split()
            for right_label in right_labels:
                right_tokens = right_label.split()
                if not left_tokens or not right_tokens:
                    continue
                shorter, longer = (
                    (left_tokens, right_tokens)
                    if len(left_tokens) <= len(right_tokens)
                    else (right_tokens, left_tokens)
                )
                available = list(longer)
                shared_full_token = False
                matched_all = True
                for token in shorter:
                    match_index = next(
                        (
                            index
                            for index, candidate in enumerate(available)
                            if cls._tokens_alias_compatible(token, candidate)
                        ),
                        None,
                    )
                    if match_index is None:
                        matched_all = False
                        break
                    if token == available[match_index] and len(token) > 2:
                        shared_full_token = True
                    del available[match_index]
                if matched_all and shared_full_token:
                    return True
        return False

    @classmethod
    def _string_values_compatible(cls, left: str, right: str) -> bool:
        """Compatible when equal, prefix-related, or initial-abbreviations."""
        if left == right:
            return True
        if left.startswith(right) or right.startswith(left):
            return True
        return cls._labels_alias_with_initials({left}, {right})

    @classmethod
    def _have_conflicting_literals(
        cls,
        left_rep: EntityRepresentation,
        right_rep: EntityRepresentation,
    ) -> bool:
        """Return True when the entities assert disjoint values per predicate.

        A shared predicate with two non-empty, disjoint canonical value sets
        (numeric/temporal) marks the entities as distinct individuals; overlap
        or one-sided values read as re-mention/enrichment and stay mergeable.
        String payloads (identifiers, codes) conflict only when NO cross-pair
        is compatible (equality, prefix, or initial-abbreviation) — "d" vs
        "dmitry" is a re-mention, "S-2024-001" vs "S-2024-002" is a conflict.
        """
        for predicate, left_values in left_rep.predicate_literals.items():
            right_values = right_rep.predicate_literals.get(predicate)
            if not right_values or not left_values:
                continue
            if left_values.isdisjoint(right_values):
                return True
        for predicate, left_strings in left_rep.predicate_string_literals.items():
            right_strings = right_rep.predicate_string_literals.get(predicate)
            if not right_strings or not left_strings:
                continue
            if not any(
                cls._string_values_compatible(left_value, right_value)
                for left_value in left_strings
                for right_value in right_strings
            ):
                return True
        return False

    @staticmethod
    def _have_conflicting_functional_objects(
        left_rep: EntityRepresentation,
        right_rep: EntityRepresentation,
        functional_predicates: set[URIRef],
    ) -> bool:
        """Return True when a max-1 object predicate points at disjoint IRIs.

        Catches conflicts invisible to value comparison — e.g. two "10"
        quantities whose ``qudt:unit`` objects are ``DEG_C`` vs ``KiloHZ``.
        """
        if not functional_predicates:
            return False
        for predicate, left_objects in left_rep.predicate_iri_objects.items():
            if predicate not in functional_predicates:
                continue
            right_objects = right_rep.predicate_iri_objects.get(predicate)
            if not right_objects or not left_objects:
                continue
            if left_objects.isdisjoint(right_objects):
                return True
        return False

    def _labels_confirm_identity(
        self,
        left: URIRef,
        right: URIRef,
        representations: dict[URIRef, EntityRepresentation],
    ) -> bool:
        """Exact or initials-aware label agreement strong enough to skip cosine."""
        left_rep = representations.get(left)
        right_rep = representations.get(right)
        if left_rep is None or right_rep is None:
            return False
        left_labels = {
            self.normalizer.normalize_string(label)
            for label in left_rep.labels + left_rep.alt_labels
            if label.strip()
        }
        right_labels = {
            self.normalizer.normalize_string(label)
            for label in right_rep.labels + right_rep.alt_labels
            if label.strip()
        }
        if not left_labels or not right_labels:
            return False
        if left_labels & right_labels:
            return True
        return self._labels_alias_with_initials(left_labels, right_labels)

    def _can_merge_as_identity(
        self,
        left: URIRef,
        right: URIRef,
        representations: dict[URIRef, EntityRepresentation],
        direct_relation_pairs: set[frozenset[URIRef]] | None = None,
        guard_context: MergeGuardContext | None = None,
    ) -> bool:
        pair = frozenset((left, right))
        if direct_relation_pairs is not None and pair in direct_relation_pairs:
            return False
        if guard_context is not None:
            if pair in guard_context.sibling_pairs:
                return False
            left_rep = representations.get(left)
            right_rep = representations.get(right)
            if left_rep is not None and right_rep is not None:
                if self._have_conflicting_literals(left_rep, right_rep):
                    return False
                if self._have_conflicting_functional_objects(
                    left_rep, right_rep, guard_context.functional_predicates
                ):
                    return False
        return (
            self._are_roles_compatible(left, right, representations)
            and self._are_types_compatible(left, right, representations)
            and self._are_lexical_aliases(left, right, representations)
        )

    def _cluster_entities_by_role(
        self, representations: dict[URIRef, EntityRepresentation]
    ) -> tuple[list[list[URIRef]], dict[URIRef, np.ndarray]]:
        grouped_entities: dict[str, dict[URIRef, EntityRepresentation]] = {}
        for entity, representation in representations.items():
            grouped_entities.setdefault(self._role_key(representation), {})[entity] = (
                representation
            )

        all_clusters: list[list[URIRef]] = []
        all_embeddings: dict[URIRef, np.ndarray] = {}
        original_threshold = self.clusterer.similarity_threshold
        self.clusterer.similarity_threshold = self.candidate_similarity_threshold
        try:
            for role_representations in grouped_entities.values():
                role_clusters, role_embeddings = self.clusterer.cluster_entities(
                    role_representations
                )
                all_clusters.extend(role_clusters)
                all_embeddings.update(role_embeddings)
        finally:
            self.clusterer.similarity_threshold = original_threshold
        return all_clusters, all_embeddings

    @staticmethod
    def _candidate_similarity(
        left: URIRef,
        right: URIRef,
        embeddings: dict[URIRef, np.ndarray],
    ) -> float | None:
        left_embedding = embeddings.get(left)
        right_embedding = embeddings.get(right)
        if left_embedding is None or right_embedding is None:
            return None

        denominator = float(
            np.linalg.norm(left_embedding) * np.linalg.norm(right_embedding)
        )
        if denominator == 0:
            return None
        return float(np.dot(left_embedding, right_embedding) / denominator)

    def _merge_validation_failures(
        self,
        left: URIRef,
        right: URIRef,
        representations: dict[URIRef, EntityRepresentation],
        guard_context: MergeGuardContext | None = None,
    ) -> list[str]:
        failures: list[str] = []
        if guard_context is not None:
            if frozenset((left, right)) in guard_context.sibling_pairs:
                failures.append("sibling")
            left_rep = representations.get(left)
            right_rep = representations.get(right)
            if left_rep is not None and right_rep is not None:
                if self._have_conflicting_literals(left_rep, right_rep):
                    failures.append("literal_conflict")
                if self._have_conflicting_functional_objects(
                    left_rep, right_rep, guard_context.functional_predicates
                ):
                    failures.append("functional_iri_conflict")
        if not self._are_roles_compatible(left, right, representations):
            failures.append("role")
        if not self._are_types_compatible(left, right, representations):
            failures.append("type")
        if not self._are_lexical_aliases(left, right, representations):
            failures.append("lexical")
        return failures

    def _build_identity_clusters(
        self,
        candidate_clusters: list[list[URIRef]],
        representations: dict[URIRef, EntityRepresentation],
        embeddings: dict[URIRef, np.ndarray],
        direct_relation_pairs: set[frozenset[URIRef]] | None = None,
        guard_context: MergeGuardContext | None = None,
    ) -> tuple[
        list[list[URIRef]], list[tuple[URIRef, URIRef, float | None, tuple[str, ...]]]
    ]:
        validated_clusters: list[list[URIRef]] = []
        rejected_merges: list[tuple[URIRef, URIRef, float | None, tuple[str, ...]]] = []

        for candidate_cluster in candidate_clusters:
            if len(candidate_cluster) <= 1:
                validated_clusters.append(candidate_cluster)
                continue

            parents: dict[URIRef, URIRef] = {
                entity: entity for entity in candidate_cluster
            }

            def find(entity: URIRef) -> URIRef:
                root = parents[entity]
                if root != entity:
                    parents[entity] = find(root)
                return parents[entity]

            def union(left: URIRef, right: URIRef) -> None:
                left_root = find(left)
                right_root = find(right)
                if left_root == right_root:
                    return
                if str(left_root) <= str(right_root):
                    parents[right_root] = left_root
                else:
                    parents[left_root] = right_root

            for left, right in combinations(candidate_cluster, 2):
                score = self._candidate_similarity(left, right, embeddings)
                if score is not None and score < self.candidate_similarity_threshold:
                    # Label-confirmed pairs bypass the cosine gate (mirrors
                    # EntityAligner): short-string embeddings of aliases like
                    # "Baranov, D." vs "Dmitry Baranov" hover around the
                    # threshold, which made identity linking nondeterministic.
                    if not self._labels_confirm_identity(left, right, representations):
                        continue
                if self._can_merge_as_identity(
                    left,
                    right,
                    representations,
                    direct_relation_pairs=direct_relation_pairs,
                    guard_context=guard_context,
                ):
                    union(left, right)
                    continue
                rejected_merges.append(
                    (
                        left,
                        right,
                        score,
                        tuple(
                            self._merge_validation_failures(
                                left,
                                right,
                                representations,
                                guard_context=guard_context,
                            )
                        ),
                    )
                )

            grouped: dict[URIRef, list[URIRef]] = {}
            for entity in candidate_cluster:
                grouped.setdefault(find(entity), []).append(entity)

            for group in grouped.values():
                sorted_group = sorted(group, key=str)
                validated_clusters.append(sorted_group)

        return validated_clusters, rejected_merges

    def _select_ontology_anchor_candidates(
        self,
        tentative_entities: list[URIRef],
        tentative_representations: dict[URIRef, EntityRepresentation],
        tentative_doc_iris: dict[URIRef, URIRef],
        ontology_graph: RDFGraph | None,
        known_ontology_entities: set[URIRef],
    ) -> dict[URIRef, URIRef]:
        """Pick ontology anchors and preserve the triggering document IRI."""
        if (
            ontology_graph is None
            or not tentative_entities
            or not known_ontology_entities
        ):
            return {}

        ontology_entities = [
            entity
            for entity in known_ontology_entities
            if not self._is_standard_ontology_entity(entity)
        ]
        if not ontology_entities:
            return {}

        ontology_graphs = {entity: ontology_graph for entity in ontology_entities}
        ontology_representations = self.normalizer.create_representations_batch(
            ontology_entities, ontology_graphs
        )

        token_index: dict[str, set[URIRef]] = {}
        for entity, representation in ontology_representations.items():
            for token in self._tokenize(representation.representation):
                token_index.setdefault(token, set()).add(entity)

        selected: dict[URIRef, URIRef] = {}
        for tentative_entity in tentative_entities:
            tentative_representation = tentative_representations.get(tentative_entity)
            if tentative_representation is None:
                continue
            tentative_doc_iri = tentative_doc_iris.get(tentative_entity)
            if tentative_doc_iri is None:
                continue
            tentative_tokens = self._tokenize(tentative_representation.representation)
            if not tentative_tokens:
                continue

            candidate_pool: set[URIRef] = set()
            for token in tentative_tokens:
                candidate_pool.update(token_index.get(token, set()))

            if not candidate_pool:
                continue

            scored: list[tuple[int, URIRef]] = []
            for candidate in candidate_pool:
                candidate_representation = ontology_representations.get(candidate)
                if candidate_representation is None:
                    continue
                candidate_tokens = self._tokenize(
                    candidate_representation.representation
                )
                overlap = len(tentative_tokens & candidate_tokens)
                if overlap >= 2:
                    scored.append((overlap, candidate))

            scored.sort(key=lambda item: (-item[0], str(item[1])))
            for _, candidate in scored[:3]:
                selected.setdefault(candidate, tentative_doc_iri)

        return selected

    def _classify_entity_for_unit(
        self,
        entity: URIRef,
        unit: ContentUnit,
        known_ontology_entities: set[URIRef],
    ) -> EntityClassification:
        """Classify an entity as fact, known ontology, or tentative ontology."""
        if unit.type == OutputType.ONTOLOGIES:
            return EntityClassification.KNOWN_ONTOLOGY

        if self._is_fact_entity_in_unit(entity, unit):
            return EntityClassification.FACT

        if entity in known_ontology_entities or self._is_standard_ontology_entity(
            entity
        ):
            return EntityClassification.KNOWN_ONTOLOGY

        return EntityClassification.TENTATIVE_ONTOLOGY

    @staticmethod
    def _classification_priority(classification: EntityClassification) -> int:
        """Return priority for multi-unit classification merging."""
        if classification == EntityClassification.KNOWN_ONTOLOGY:
            return 3
        if classification == EntityClassification.TENTATIVE_ONTOLOGY:
            return 2
        return 1

    @staticmethod
    def _merge_into_context_graph(target: RDFGraph, source: RDFGraph) -> None:
        """Merge source triples/namespaces into a per-entity context graph."""
        target += source

    def _register_entity(
        self,
        *,
        entity: URIRef,
        unit: ContentUnit,
        state: _EntityCollectionState,
    ) -> None:
        """Register one URI entity with merged context and stable classification."""
        state.entities.add(entity)
        state.source_entities.add(entity)
        if entity not in state.entity_graphs:
            state.entity_graphs[entity] = unit.graph.copy()
        else:
            self._merge_into_context_graph(state.entity_graphs[entity], unit.graph)
        state.entity_doc_iris.setdefault(entity, unit.doc_iri)
        current = state.entity_classification.get(entity, EntityClassification.FACT)
        candidate = self._classify_entity_for_unit(entity, unit, state.known_entities)
        state.entity_classification[entity] = (
            candidate
            if self._classification_priority(candidate)
            >= self._classification_priority(current)
            else current
        )

    @staticmethod
    def _register_direct_relation(
        state: _EntityCollectionState,
        subject: URIRef,
        obj: URIRef,
    ) -> None:
        """Record direct subject-object URI relation pair in collection state."""
        if subject == obj:
            return
        state.direct_relation_pairs.add(frozenset((subject, obj)))

    def _collect_all_entities(
        self,
        units: list[ContentUnit],
        known_ontology_entities: set[URIRef] | None = None,
    ) -> tuple[
        list[URIRef],
        set[URIRef],
        dict[URIRef, RDFGraph],
        dict[URIRef, URIRef],
        dict[URIRef, EntityClassification],
        set[frozenset[URIRef]],
        dict[tuple[URIRef, URIRef], set[URIRef]],
    ]:
        """Collect all entities from all content unit graphs.

        Each entity is associated with the graph it was found in and the
        ``doc_iri`` of the :class:`ContentUnit` that produced it.  When an
        entity appears in several units the *last-seen* ``doc_iri`` wins (in
        practice most pipelines aggregate chunks of the same document, so all
        ``doc_iri`` values are identical).

        Args:
            units: List of content units to aggregate.

        Returns:
            Tuple of (
                entities,
                entity_to_graph,
                entity_to_doc_iri,
                entity_to_is_ontology,
            ).
        """
        state = _EntityCollectionState(known_entities=known_ontology_entities or set())

        for unit in units:
            if unit.graph is None:
                continue
            unit.graph.sanitize_prefixes_namespaces()
            # Keep collection in the same URI space that rewrite/merge consumes
            # (unit.graph). Using graph_absolute here causes mapping keys to miss
            # during rewrite, because unit.graph still contains the original terms.
            for s, p, o in unit.graph:
                if isinstance(s, URIRef) and isinstance(o, URIRef):
                    self._register_direct_relation(state=state, subject=s, obj=o)
                    if isinstance(p, URIRef) and p != RDF.type:
                        state.object_groups.setdefault((s, p), set()).add(o)
                for term in (s, p, o):
                    if isinstance(term, URIRef):
                        self._register_entity(entity=term, unit=unit, state=state)

        return (
            list(state.entities),
            state.source_entities,
            state.entity_graphs,
            state.entity_doc_iris,
            state.entity_classification,
            state.direct_relation_pairs,
            state.object_groups,
        )

    def aggregate_graphs(
        self,
        units: list[ContentUnit],
        ontology_graph: RDFGraph,
        merge_vetoes: set[frozenset[URIRef]] | None = None,
    ) -> AggregationResult:
        """Aggregate multiple content unit graphs with embedding-based disambiguation.

        Args:
            units: List of ContentUnits to aggregate.
            ontology_graph: Selected ontology graph used to distinguish
                known ontology entities from tentative ontology-like aliases.
            merge_vetoes: Extra entity pairs that must never identity-merge —
                the targeted un-merge lever used by the post-aggregation
                validation gate. Unioned into the direct-relation veto set.

        Returns:
            :class:`AggregationResult` with the merged graph and merge
            bookkeeping (decisions, merged clusters, rejection count).
        """
        logger.info(f"Starting aggregation with metadata for {len(units)} units")
        if ontology_graph is None:
            raise ValueError("ontology_graph must not be None for facts aggregation")

        if not units:
            return AggregationResult(graph=RDFGraph())

        # Steps 1-3: Collect, normalise, candidate clustering
        known_ontology_entities = self._build_known_ontology_entities(ontology_graph)
        (
            entities,
            source_entities,
            entity_graphs,
            entity_doc_iris,
            entity_classification,
            direct_relation_pairs,
            object_groups,
        ) = self._collect_all_entities(units, known_ontology_entities)
        if merge_vetoes:
            direct_relation_pairs = direct_relation_pairs | merge_vetoes
        guard_context = MergeGuardContext(
            sibling_pairs=build_sibling_pairs(
                object_groups, scope=self.sibling_guard_scope
            ),
            functional_predicates=harvest_max_one_predicates(ontology_graph)
            | empirically_functional_predicates(
                object_groups,
                min_support=self.functional_min_empirical_support,
            ),
        )
        representations = self.normalizer.create_representations_batch(
            entities, entity_graphs
        )
        decisions: dict[URIRef, EntityDecision] = {
            entity: EntityDecision(
                classification=classification,
                identity_target=entity,
            )
            for entity, classification in entity_classification.items()
        }
        tentative_entities = [
            entity
            for entity, decision in decisions.items()
            if decision.classification == EntityClassification.TENTATIVE_ONTOLOGY
        ]
        anchor_candidates = self._select_ontology_anchor_candidates(
            tentative_entities=tentative_entities,
            tentative_representations=representations,
            tentative_doc_iris=entity_doc_iris,
            ontology_graph=ontology_graph,
            known_ontology_entities=known_ontology_entities,
        )
        if anchor_candidates:
            for ontology_entity, anchor_doc_iri in anchor_candidates.items():
                if ontology_entity in entity_graphs:
                    continue
                entities.append(ontology_entity)
                entity_graphs[ontology_entity] = ontology_graph
                entity_doc_iris[ontology_entity] = anchor_doc_iri
                entity_classification[ontology_entity] = (
                    EntityClassification.KNOWN_ONTOLOGY
                )
                decisions[ontology_entity] = EntityDecision(
                    classification=EntityClassification.KNOWN_ONTOLOGY,
                    identity_target=ontology_entity,
                )
                representations[ontology_entity] = (
                    self.normalizer.create_representation(
                        ontology_entity, ontology_graph
                    )
                )
        entity_is_known_ontology = {
            entity: decision.classification == EntityClassification.KNOWN_ONTOLOGY
            for entity, decision in decisions.items()
        }
        if logger.isEnabledFor(logging.INFO):
            known_count = sum(
                1 for is_known in entity_is_known_ontology.values() if is_known
            )
            fact_count = sum(
                1
                for decision in decisions.values()
                if decision.classification == EntityClassification.FACT
            )
            logger.info(
                "Aggregation entity classification stats: fact=%d known_ontology=%d "
                "tentative_ontology=%d",
                fact_count,
                known_count,
                len(tentative_entities),
            )

        candidate_clusters, embeddings = self._cluster_entities_by_role(representations)
        clusters, rejected_merges = self._build_identity_clusters(
            candidate_clusters=candidate_clusters,
            representations=representations,
            embeddings=embeddings,
            direct_relation_pairs=direct_relation_pairs,
            guard_context=guard_context,
        )
        if rejected_merges:
            logger.info(
                "Rejected %d candidate merges after symbolic validation",
                len(rejected_merges),
            )
            for left, right, score, failed_checks in rejected_merges:
                logger.debug(
                    "Rejected candidate merge: %s <-> %s (score=%s, failed=%s)",
                    left,
                    right,
                    f"{score:.3f}" if score is not None else "n/a",
                    ",".join(failed_checks) if failed_checks else "unknown",
                )

        # Step 4: Canonical identity mapping (no URI policy yet)
        identity_mapping = self.selector.create_mapping(
            clusters,
            representations,
            entity_is_known_ontology=entity_is_known_ontology,
        )

        # Keep known ontology entities stable. Tentative ontology-like entities are:
        # - mapped to known ontology representatives when present in a mixed cluster
        # - preserved as-is when only tentative entities are present
        suppress_sameas_origins: set[URIRef] = set()
        suppress_fact_subject_sources: set[URIRef] = set()
        for cluster in clusters:
            known_ontology_entities_in_cluster = [
                entity
                for entity in cluster
                if decisions.get(entity) is not None
                and decisions[entity].classification
                == EntityClassification.KNOWN_ONTOLOGY
            ]
            tentative_entities_in_cluster = [
                entity
                for entity in cluster
                if decisions.get(entity) is not None
                and decisions[entity].classification
                == EntityClassification.TENTATIVE_ONTOLOGY
            ]
            fact_entities_in_cluster = [
                entity
                for entity in cluster
                if decisions.get(entity) is not None
                and decisions[entity].classification == EntityClassification.FACT
            ]

            for entity in known_ontology_entities_in_cluster:
                identity_mapping[entity] = entity

            if known_ontology_entities_in_cluster:
                canonical_known_ontology = self.selector.select_representative(
                    known_ontology_entities_in_cluster,
                    representations,
                    entity_is_known_ontology=entity_is_known_ontology,
                )
                for tentative_entity in tentative_entities_in_cluster:
                    if self._can_merge_as_identity(
                        tentative_entity,
                        canonical_known_ontology,
                        representations,
                        direct_relation_pairs=direct_relation_pairs,
                        guard_context=guard_context,
                    ):
                        identity_mapping[tentative_entity] = canonical_known_ontology
                        decisions[tentative_entity].suppress_sameas = True
                    else:
                        identity_mapping[tentative_entity] = tentative_entity
                for fact_entity in fact_entities_in_cluster:
                    if self._can_merge_as_identity(
                        fact_entity,
                        canonical_known_ontology,
                        representations,
                        direct_relation_pairs=direct_relation_pairs,
                        guard_context=guard_context,
                    ):
                        identity_mapping[fact_entity] = canonical_known_ontology
                        decisions[fact_entity].suppress_sameas = True
                        decisions[fact_entity].suppress_fact_subject_assertions = True
                    else:
                        identity_mapping[fact_entity] = fact_entity

            elif tentative_entities_in_cluster:
                # In mixed FACT + TENTATIVE clusters with no known ontology
                # entity, prefer the FACT side when symbolic identity checks
                # agree (e.g. hallucinated ontology prefix on an instance).
                if fact_entities_in_cluster:
                    canonical_fact = self.selector.select_representative(
                        fact_entities_in_cluster,
                        representations,
                        entity_is_known_ontology=entity_is_known_ontology,
                    )
                    for fact_entity in fact_entities_in_cluster:
                        identity_mapping[fact_entity] = canonical_fact
                    for tentative_entity in tentative_entities_in_cluster:
                        if self._can_merge_as_identity(
                            tentative_entity,
                            canonical_fact,
                            representations,
                            direct_relation_pairs=direct_relation_pairs,
                            guard_context=guard_context,
                        ):
                            identity_mapping[tentative_entity] = canonical_fact
                            decisions[tentative_entity].suppress_sameas = True
                        else:
                            identity_mapping[tentative_entity] = tentative_entity
                else:
                    for tentative_entity in tentative_entities_in_cluster:
                        identity_mapping[tentative_entity] = tentative_entity

        for entity, target in identity_mapping.items():
            if entity in decisions:
                decisions[entity].identity_target = target

        suppress_sameas_origins = {
            entity for entity, decision in decisions.items() if decision.suppress_sameas
        }
        suppress_fact_subject_sources = {
            entity
            for entity, decision in decisions.items()
            if decision.suppress_fact_subject_assertions
        }

        # Step 5: URI assignment from canonical identity + namespace policy
        final_mapping = self.uri_builder.create_entity_uri_mapping(
            identity_mapping=identity_mapping,
            representations=representations,
            entity_doc_iris=entity_doc_iris,
            entity_is_ontology={
                entity: (
                    decisions.get(entity) is not None
                    and decisions[entity].classification != EntityClassification.FACT
                )
                for entity in representations
            },
        )
        for entity, final_uri in final_mapping.items():
            if entity in decisions:
                decisions[entity].final_uri = final_uri
        known_ontology_entities_all = {
            entity
            for entity, decision in decisions.items()
            if decision.classification == EntityClassification.KNOWN_ONTOLOGY
        }
        assert all(
            identity_mapping.get(entity, entity) == entity
            for entity in known_ontology_entities_all
        ), "Known ontology entities must remain identity-mapped"
        assert not (known_ontology_entities_all & suppress_sameas_origins), (
            "Known ontology entities cannot be suppress_sameas origins"
        )
        assert not (known_ontology_entities_all & suppress_fact_subject_sources), (
            "Known ontology entities cannot be suppress_fact_subject origins"
        )
        assert all(entity in decisions for entity in source_entities), (
            "Every source entity must have a decision record"
        )
        final_mapping = {
            entity: mapped
            for entity, mapped in final_mapping.items()
            if entity in source_entities
        }

        # Step 7: Rewrite and merge with provenance
        active_units = [u for u in units if u.graph is not None and len(u.graph) > 0]
        merged_graph = self.rewriter.merge_graphs_with_provenance(
            active_units,
            final_mapping,
            suppress_sameas_origins=suppress_sameas_origins,
            suppress_fact_subject_sources=suppress_fact_subject_sources,
        )

        merged_clusters = build_merged_clusters(final_mapping, identity_mapping)

        logger.info("Aggregation with metadata complete")
        return AggregationResult(
            graph=merged_graph,
            decisions=decisions,
            merged_clusters=merged_clusters,
            rejected_merge_count=len(rejected_merges),
        )

    def postprocess_facts_units(
        self,
        units: list[ContentUnit],
        ontology_graph: RDFGraph,
        *,
        doc_iri: URIRef | None = None,
        document_metadata: dict[str, Any] | None = None,
        doc_namespace: str | None = None,
        merge_vetoes: set[frozenset[URIRef]] | None = None,
    ) -> AggregationResult:
        """Sanitize facts units, then run aggregation/normalization.

        This method is intentionally safe for both single-unit and multi-unit
        inputs so unit-pipeline and graph-pipeline paths share the same
        post-processing behavior.

        When ``doc_iri`` and non-empty ``document_metadata`` are provided,
        caller-asserted document identity triples are attached to the merged
        facts graph. Business-oriented keys mint typed entities under
        ``doc_namespace`` (defaults to the document facts namespace).

        Args:
            units: Facts content units to aggregate.
            ontology_graph: Merged ontology context for classification/guards.
            doc_iri: Document IRI for metadata provenance attachment.
            document_metadata: Caller-asserted document identity metadata.
            doc_namespace: Namespace for metadata-minted entities.
            merge_vetoes: Entity pairs that must never identity-merge
                (validation-gate un-merge lever).

        Returns:
            :class:`AggregationResult`; its ``graph`` carries the merged facts
            plus any document-metadata provenance.
        """
        for unit in units:
            unit.sanitize()
        result = self.aggregate_graphs(
            units=units, ontology_graph=ontology_graph, merge_vetoes=merge_vetoes
        )
        if doc_iri is not None and document_metadata:
            apply_document_metadata_provenance(
                doc_iri,
                document_metadata,
                result.graph,
                entity_namespace=doc_namespace,
            )
        # Cross-unit prefix conflicts surface only on the merged graph (e.g.
        # aliases of one namespace arriving from different units), so sanitize
        # once more after aggregation.
        result.graph.sanitize_prefixes_namespaces()
        return result

__init__(config=None, *, add_sameas_links=True, base_iri=DEFAULT_IRI, candidate_similarity_threshold=None)

Initialise the embedding-based aggregator.

Every tunable lives on :class:AggregationConfig, so settings.py stays the single source of their defaults rather than restating them in this signature and again at the call site.

Parameters:

Name Type Description Default
config AggregationConfig | None

Aggregation tunables. Defaults to :class:AggregationConfig, i.e. the environment-resolved settings.

None
add_sameas_links bool

Whether to add owl:sameAs for merged entities. Not config-driven: callers choose it per use, and the entity aligner wants different behaviour from the pipeline.

True
base_iri str

Base IRI for fact entity URIs. Entities under this namespace are facts; everything else is treated as an ontology entity and left unchanged.

DEFAULT_IRI
candidate_similarity_threshold float | None

Overrides the configured permissive candidate threshold. The entity aligner pins it to its own similarity threshold rather than the pipeline's.

None
Source code in ontocast/tool/agg/aggregate.py
def __init__(
    self,
    config: AggregationConfig | None = None,
    *,
    add_sameas_links: bool = True,
    base_iri: str = DEFAULT_IRI,
    candidate_similarity_threshold: float | None = None,
):
    """Initialise the embedding-based aggregator.

    Every tunable lives on :class:`AggregationConfig`, so ``settings.py``
    stays the single source of their defaults rather than restating them in
    this signature and again at the call site.

    Args:
        config: Aggregation tunables. Defaults to :class:`AggregationConfig`,
            i.e. the environment-resolved settings.
        add_sameas_links: Whether to add ``owl:sameAs`` for merged entities.
            Not config-driven: callers choose it per use, and the entity
            aligner wants different behaviour from the pipeline.
        base_iri: Base IRI for fact entity URIs. Entities under this
            namespace are facts; everything else is treated as an ontology
            entity and left unchanged.
        candidate_similarity_threshold: Overrides the configured permissive
            candidate threshold. The entity aligner pins it to its own
            similarity threshold rather than the pipeline's.
    """
    cfg = config or AggregationConfig()

    self.base_iri = base_iri
    self.candidate_similarity_threshold = (
        cfg.candidate_similarity_threshold
        if candidate_similarity_threshold is None
        else candidate_similarity_threshold
    )
    self.lexical_label_jaccard = cfg.lexical_label_jaccard
    self.lexical_sequence_ratio = cfg.lexical_sequence_ratio
    self.lexical_token_jaccard = cfg.lexical_token_jaccard
    self.functional_min_empirical_support = cfg.functional_min_empirical_support
    self.sibling_guard_scope = str(cfg.sibling_guard_scope)

    # Pipeline components (EntityClusterer imports sklearn/ST lazily)
    from .clustering import EntityClusterer

    self.normalizer = EntityNormalizer(facts_iri=self.base_iri)
    self.clusterer = EntityClusterer(
        embedding_model=cfg.embedding_model,
        similarity_threshold=cfg.similarity_threshold,
    )
    self.selector = ClusterRepresentativeSelector()
    self.uri_builder = URIBuilder(base_iri=self.base_iri)
    self.rewriter = GraphRewriter(
        add_sameas_links=add_sameas_links,
        blocked_sameas_namespaces=(self.base_iri,),
    )

aggregate_graphs(units, ontology_graph, merge_vetoes=None)

Aggregate multiple content unit graphs with embedding-based disambiguation.

Parameters:

Name Type Description Default
units list[ContentUnit]

List of ContentUnits to aggregate.

required
ontology_graph RDFGraph

Selected ontology graph used to distinguish known ontology entities from tentative ontology-like aliases.

required
merge_vetoes set[frozenset[URIRef]] | None

Extra entity pairs that must never identity-merge — the targeted un-merge lever used by the post-aggregation validation gate. Unioned into the direct-relation veto set.

None

Returns:

Type Description
AggregationResult
AggregationResult

bookkeeping (decisions, merged clusters, rejection count).

Source code in ontocast/tool/agg/aggregate.py
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
def aggregate_graphs(
    self,
    units: list[ContentUnit],
    ontology_graph: RDFGraph,
    merge_vetoes: set[frozenset[URIRef]] | None = None,
) -> AggregationResult:
    """Aggregate multiple content unit graphs with embedding-based disambiguation.

    Args:
        units: List of ContentUnits to aggregate.
        ontology_graph: Selected ontology graph used to distinguish
            known ontology entities from tentative ontology-like aliases.
        merge_vetoes: Extra entity pairs that must never identity-merge —
            the targeted un-merge lever used by the post-aggregation
            validation gate. Unioned into the direct-relation veto set.

    Returns:
        :class:`AggregationResult` with the merged graph and merge
        bookkeeping (decisions, merged clusters, rejection count).
    """
    logger.info(f"Starting aggregation with metadata for {len(units)} units")
    if ontology_graph is None:
        raise ValueError("ontology_graph must not be None for facts aggregation")

    if not units:
        return AggregationResult(graph=RDFGraph())

    # Steps 1-3: Collect, normalise, candidate clustering
    known_ontology_entities = self._build_known_ontology_entities(ontology_graph)
    (
        entities,
        source_entities,
        entity_graphs,
        entity_doc_iris,
        entity_classification,
        direct_relation_pairs,
        object_groups,
    ) = self._collect_all_entities(units, known_ontology_entities)
    if merge_vetoes:
        direct_relation_pairs = direct_relation_pairs | merge_vetoes
    guard_context = MergeGuardContext(
        sibling_pairs=build_sibling_pairs(
            object_groups, scope=self.sibling_guard_scope
        ),
        functional_predicates=harvest_max_one_predicates(ontology_graph)
        | empirically_functional_predicates(
            object_groups,
            min_support=self.functional_min_empirical_support,
        ),
    )
    representations = self.normalizer.create_representations_batch(
        entities, entity_graphs
    )
    decisions: dict[URIRef, EntityDecision] = {
        entity: EntityDecision(
            classification=classification,
            identity_target=entity,
        )
        for entity, classification in entity_classification.items()
    }
    tentative_entities = [
        entity
        for entity, decision in decisions.items()
        if decision.classification == EntityClassification.TENTATIVE_ONTOLOGY
    ]
    anchor_candidates = self._select_ontology_anchor_candidates(
        tentative_entities=tentative_entities,
        tentative_representations=representations,
        tentative_doc_iris=entity_doc_iris,
        ontology_graph=ontology_graph,
        known_ontology_entities=known_ontology_entities,
    )
    if anchor_candidates:
        for ontology_entity, anchor_doc_iri in anchor_candidates.items():
            if ontology_entity in entity_graphs:
                continue
            entities.append(ontology_entity)
            entity_graphs[ontology_entity] = ontology_graph
            entity_doc_iris[ontology_entity] = anchor_doc_iri
            entity_classification[ontology_entity] = (
                EntityClassification.KNOWN_ONTOLOGY
            )
            decisions[ontology_entity] = EntityDecision(
                classification=EntityClassification.KNOWN_ONTOLOGY,
                identity_target=ontology_entity,
            )
            representations[ontology_entity] = (
                self.normalizer.create_representation(
                    ontology_entity, ontology_graph
                )
            )
    entity_is_known_ontology = {
        entity: decision.classification == EntityClassification.KNOWN_ONTOLOGY
        for entity, decision in decisions.items()
    }
    if logger.isEnabledFor(logging.INFO):
        known_count = sum(
            1 for is_known in entity_is_known_ontology.values() if is_known
        )
        fact_count = sum(
            1
            for decision in decisions.values()
            if decision.classification == EntityClassification.FACT
        )
        logger.info(
            "Aggregation entity classification stats: fact=%d known_ontology=%d "
            "tentative_ontology=%d",
            fact_count,
            known_count,
            len(tentative_entities),
        )

    candidate_clusters, embeddings = self._cluster_entities_by_role(representations)
    clusters, rejected_merges = self._build_identity_clusters(
        candidate_clusters=candidate_clusters,
        representations=representations,
        embeddings=embeddings,
        direct_relation_pairs=direct_relation_pairs,
        guard_context=guard_context,
    )
    if rejected_merges:
        logger.info(
            "Rejected %d candidate merges after symbolic validation",
            len(rejected_merges),
        )
        for left, right, score, failed_checks in rejected_merges:
            logger.debug(
                "Rejected candidate merge: %s <-> %s (score=%s, failed=%s)",
                left,
                right,
                f"{score:.3f}" if score is not None else "n/a",
                ",".join(failed_checks) if failed_checks else "unknown",
            )

    # Step 4: Canonical identity mapping (no URI policy yet)
    identity_mapping = self.selector.create_mapping(
        clusters,
        representations,
        entity_is_known_ontology=entity_is_known_ontology,
    )

    # Keep known ontology entities stable. Tentative ontology-like entities are:
    # - mapped to known ontology representatives when present in a mixed cluster
    # - preserved as-is when only tentative entities are present
    suppress_sameas_origins: set[URIRef] = set()
    suppress_fact_subject_sources: set[URIRef] = set()
    for cluster in clusters:
        known_ontology_entities_in_cluster = [
            entity
            for entity in cluster
            if decisions.get(entity) is not None
            and decisions[entity].classification
            == EntityClassification.KNOWN_ONTOLOGY
        ]
        tentative_entities_in_cluster = [
            entity
            for entity in cluster
            if decisions.get(entity) is not None
            and decisions[entity].classification
            == EntityClassification.TENTATIVE_ONTOLOGY
        ]
        fact_entities_in_cluster = [
            entity
            for entity in cluster
            if decisions.get(entity) is not None
            and decisions[entity].classification == EntityClassification.FACT
        ]

        for entity in known_ontology_entities_in_cluster:
            identity_mapping[entity] = entity

        if known_ontology_entities_in_cluster:
            canonical_known_ontology = self.selector.select_representative(
                known_ontology_entities_in_cluster,
                representations,
                entity_is_known_ontology=entity_is_known_ontology,
            )
            for tentative_entity in tentative_entities_in_cluster:
                if self._can_merge_as_identity(
                    tentative_entity,
                    canonical_known_ontology,
                    representations,
                    direct_relation_pairs=direct_relation_pairs,
                    guard_context=guard_context,
                ):
                    identity_mapping[tentative_entity] = canonical_known_ontology
                    decisions[tentative_entity].suppress_sameas = True
                else:
                    identity_mapping[tentative_entity] = tentative_entity
            for fact_entity in fact_entities_in_cluster:
                if self._can_merge_as_identity(
                    fact_entity,
                    canonical_known_ontology,
                    representations,
                    direct_relation_pairs=direct_relation_pairs,
                    guard_context=guard_context,
                ):
                    identity_mapping[fact_entity] = canonical_known_ontology
                    decisions[fact_entity].suppress_sameas = True
                    decisions[fact_entity].suppress_fact_subject_assertions = True
                else:
                    identity_mapping[fact_entity] = fact_entity

        elif tentative_entities_in_cluster:
            # In mixed FACT + TENTATIVE clusters with no known ontology
            # entity, prefer the FACT side when symbolic identity checks
            # agree (e.g. hallucinated ontology prefix on an instance).
            if fact_entities_in_cluster:
                canonical_fact = self.selector.select_representative(
                    fact_entities_in_cluster,
                    representations,
                    entity_is_known_ontology=entity_is_known_ontology,
                )
                for fact_entity in fact_entities_in_cluster:
                    identity_mapping[fact_entity] = canonical_fact
                for tentative_entity in tentative_entities_in_cluster:
                    if self._can_merge_as_identity(
                        tentative_entity,
                        canonical_fact,
                        representations,
                        direct_relation_pairs=direct_relation_pairs,
                        guard_context=guard_context,
                    ):
                        identity_mapping[tentative_entity] = canonical_fact
                        decisions[tentative_entity].suppress_sameas = True
                    else:
                        identity_mapping[tentative_entity] = tentative_entity
            else:
                for tentative_entity in tentative_entities_in_cluster:
                    identity_mapping[tentative_entity] = tentative_entity

    for entity, target in identity_mapping.items():
        if entity in decisions:
            decisions[entity].identity_target = target

    suppress_sameas_origins = {
        entity for entity, decision in decisions.items() if decision.suppress_sameas
    }
    suppress_fact_subject_sources = {
        entity
        for entity, decision in decisions.items()
        if decision.suppress_fact_subject_assertions
    }

    # Step 5: URI assignment from canonical identity + namespace policy
    final_mapping = self.uri_builder.create_entity_uri_mapping(
        identity_mapping=identity_mapping,
        representations=representations,
        entity_doc_iris=entity_doc_iris,
        entity_is_ontology={
            entity: (
                decisions.get(entity) is not None
                and decisions[entity].classification != EntityClassification.FACT
            )
            for entity in representations
        },
    )
    for entity, final_uri in final_mapping.items():
        if entity in decisions:
            decisions[entity].final_uri = final_uri
    known_ontology_entities_all = {
        entity
        for entity, decision in decisions.items()
        if decision.classification == EntityClassification.KNOWN_ONTOLOGY
    }
    assert all(
        identity_mapping.get(entity, entity) == entity
        for entity in known_ontology_entities_all
    ), "Known ontology entities must remain identity-mapped"
    assert not (known_ontology_entities_all & suppress_sameas_origins), (
        "Known ontology entities cannot be suppress_sameas origins"
    )
    assert not (known_ontology_entities_all & suppress_fact_subject_sources), (
        "Known ontology entities cannot be suppress_fact_subject origins"
    )
    assert all(entity in decisions for entity in source_entities), (
        "Every source entity must have a decision record"
    )
    final_mapping = {
        entity: mapped
        for entity, mapped in final_mapping.items()
        if entity in source_entities
    }

    # Step 7: Rewrite and merge with provenance
    active_units = [u for u in units if u.graph is not None and len(u.graph) > 0]
    merged_graph = self.rewriter.merge_graphs_with_provenance(
        active_units,
        final_mapping,
        suppress_sameas_origins=suppress_sameas_origins,
        suppress_fact_subject_sources=suppress_fact_subject_sources,
    )

    merged_clusters = build_merged_clusters(final_mapping, identity_mapping)

    logger.info("Aggregation with metadata complete")
    return AggregationResult(
        graph=merged_graph,
        decisions=decisions,
        merged_clusters=merged_clusters,
        rejected_merge_count=len(rejected_merges),
    )

postprocess_facts_units(units, ontology_graph, *, doc_iri=None, document_metadata=None, doc_namespace=None, merge_vetoes=None)

Sanitize facts units, then run aggregation/normalization.

This method is intentionally safe for both single-unit and multi-unit inputs so unit-pipeline and graph-pipeline paths share the same post-processing behavior.

When doc_iri and non-empty document_metadata are provided, caller-asserted document identity triples are attached to the merged facts graph. Business-oriented keys mint typed entities under doc_namespace (defaults to the document facts namespace).

Parameters:

Name Type Description Default
units list[ContentUnit]

Facts content units to aggregate.

required
ontology_graph RDFGraph

Merged ontology context for classification/guards.

required
doc_iri URIRef | None

Document IRI for metadata provenance attachment.

None
document_metadata dict[str, Any] | None

Caller-asserted document identity metadata.

None
doc_namespace str | None

Namespace for metadata-minted entities.

None
merge_vetoes set[frozenset[URIRef]] | None

Entity pairs that must never identity-merge (validation-gate un-merge lever).

None

Returns:

Type Description
AggregationResult
AggregationResult

plus any document-metadata provenance.

Source code in ontocast/tool/agg/aggregate.py
def postprocess_facts_units(
    self,
    units: list[ContentUnit],
    ontology_graph: RDFGraph,
    *,
    doc_iri: URIRef | None = None,
    document_metadata: dict[str, Any] | None = None,
    doc_namespace: str | None = None,
    merge_vetoes: set[frozenset[URIRef]] | None = None,
) -> AggregationResult:
    """Sanitize facts units, then run aggregation/normalization.

    This method is intentionally safe for both single-unit and multi-unit
    inputs so unit-pipeline and graph-pipeline paths share the same
    post-processing behavior.

    When ``doc_iri`` and non-empty ``document_metadata`` are provided,
    caller-asserted document identity triples are attached to the merged
    facts graph. Business-oriented keys mint typed entities under
    ``doc_namespace`` (defaults to the document facts namespace).

    Args:
        units: Facts content units to aggregate.
        ontology_graph: Merged ontology context for classification/guards.
        doc_iri: Document IRI for metadata provenance attachment.
        document_metadata: Caller-asserted document identity metadata.
        doc_namespace: Namespace for metadata-minted entities.
        merge_vetoes: Entity pairs that must never identity-merge
            (validation-gate un-merge lever).

    Returns:
        :class:`AggregationResult`; its ``graph`` carries the merged facts
        plus any document-metadata provenance.
    """
    for unit in units:
        unit.sanitize()
    result = self.aggregate_graphs(
        units=units, ontology_graph=ontology_graph, merge_vetoes=merge_vetoes
    )
    if doc_iri is not None and document_metadata:
        apply_document_metadata_provenance(
            doc_iri,
            document_metadata,
            result.graph,
            entity_namespace=doc_namespace,
        )
    # Cross-unit prefix conflicts surface only on the merged graph (e.g.
    # aliases of one namespace arriving from different units), so sanitize
    # once more after aggregation.
    result.graph.sanitize_prefixes_namespaces()
    return result

EmbeddingTool

Bases: Tool

Base embedding tool with provider-specific implementations.

Source code in ontocast/tool/vector_store/embedding.py
class EmbeddingTool(Tool):
    """Base embedding tool with provider-specific implementations."""

    config: EmbeddingConfig = Field(default_factory=EmbeddingConfig)

    @abc.abstractmethod
    def _embed_raw(self, texts: list[str]) -> list[list[float]]:
        """Return vectors for all given texts, prefixes already applied."""

    def embed(self, texts: list[str]) -> list[list[float]]:
        """Return vectors for all given texts as *documents*.

        Serialisation, where it is needed, belongs to whatever owns the model —
        the shared encoder for local checkpoints, nothing for remote providers.
        """
        if not texts:
            return []
        return self._embed_raw(self._apply(self.config.document_prefix, texts))

    def embed_query(self, texts: list[str]) -> list[list[float]]:
        """Return vectors for all given texts as *queries*.

        Asymmetric retrieval models are trained with distinct query and document
        instructions and lose accuracy when both sides are encoded identically. With
        empty prefixes — the default, suiting a symmetric paraphrase model — this is
        exactly :meth:`embed`.
        """
        if not texts:
            return []
        return self._embed_raw(self._apply(self.config.query_prefix, texts))

    @staticmethod
    def _apply(prefix: str, texts: list[str]) -> list[str]:
        return texts if not prefix else [f"{prefix}{text}" for text in texts]

    def embed_one(self, text: str) -> list[float]:
        """Return a vector for one query text."""
        vectors = self.embed_query([text])
        if not vectors:
            raise ValueError("Embedding provider returned no vectors for query text")
        return vectors[0]

    @classmethod
    def create(cls, config: EmbeddingConfig) -> "EmbeddingTool":
        """Factory for provider-specific embedding tools."""
        if config.provider == EmbeddingProvider.HUGGINGFACE:
            return HuggingFaceEmbeddingTool(config=config)
        if config.provider == EmbeddingProvider.OPENAI:
            return OpenAIEmbeddingTool(config=config)
        if config.provider == EmbeddingProvider.OLLAMA:
            return OllamaEmbeddingTool(config=config)
        raise ValueError(f"Unsupported embedding provider: {config.provider}")

create(config) classmethod

Factory for provider-specific embedding tools.

Source code in ontocast/tool/vector_store/embedding.py
@classmethod
def create(cls, config: EmbeddingConfig) -> "EmbeddingTool":
    """Factory for provider-specific embedding tools."""
    if config.provider == EmbeddingProvider.HUGGINGFACE:
        return HuggingFaceEmbeddingTool(config=config)
    if config.provider == EmbeddingProvider.OPENAI:
        return OpenAIEmbeddingTool(config=config)
    if config.provider == EmbeddingProvider.OLLAMA:
        return OllamaEmbeddingTool(config=config)
    raise ValueError(f"Unsupported embedding provider: {config.provider}")

embed(texts)

Return vectors for all given texts as documents.

Serialisation, where it is needed, belongs to whatever owns the model — the shared encoder for local checkpoints, nothing for remote providers.

Source code in ontocast/tool/vector_store/embedding.py
def embed(self, texts: list[str]) -> list[list[float]]:
    """Return vectors for all given texts as *documents*.

    Serialisation, where it is needed, belongs to whatever owns the model —
    the shared encoder for local checkpoints, nothing for remote providers.
    """
    if not texts:
        return []
    return self._embed_raw(self._apply(self.config.document_prefix, texts))

embed_one(text)

Return a vector for one query text.

Source code in ontocast/tool/vector_store/embedding.py
def embed_one(self, text: str) -> list[float]:
    """Return a vector for one query text."""
    vectors = self.embed_query([text])
    if not vectors:
        raise ValueError("Embedding provider returned no vectors for query text")
    return vectors[0]

embed_query(texts)

Return vectors for all given texts as queries.

Asymmetric retrieval models are trained with distinct query and document instructions and lose accuracy when both sides are encoded identically. With empty prefixes — the default, suiting a symmetric paraphrase model — this is exactly :meth:embed.

Source code in ontocast/tool/vector_store/embedding.py
def embed_query(self, texts: list[str]) -> list[list[float]]:
    """Return vectors for all given texts as *queries*.

    Asymmetric retrieval models are trained with distinct query and document
    instructions and lose accuracy when both sides are encoded identically. With
    empty prefixes — the default, suiting a symmetric paraphrase model — this is
    exactly :meth:`embed`.
    """
    if not texts:
        return []
    return self._embed_raw(self._apply(self.config.query_prefix, texts))

FusekiTripleStoreManager

Bases: TripleStoreManagerWithAuth

Fuseki-based triple store manager.

This class provides a concrete implementation of triple store management using Apache Fuseki. It stores ontologies as named graphs using their URIs as graph names, and supports dataset creation and cleanup.

URI shape: uri must be the Fuseki HTTP server root (e.g. http://localhost:3032), not a dataset path or UI URL. Dataset names are dataset / ontologies_dataset; the client calls {uri}/{dataset_name}/sparql and similar. The UI route /#/dataset/dataset_name is only for the browser; paste the origin (and optional non-dataset path prefix) into FUSEKI_URI, and set FUSEKI_DATASET to dataset_name.

The manager uses Fuseki's REST API for all operations, including: - Dataset creation and management - Named graph operations for ontologies - SPARQL queries for ontology discovery - Graph-level data operations

Attributes:

Name Type Description
dataset str | None

Facts dataset name (first path segment in Fuseki HTTP API).

ontologies_dataset str

Ontologies dataset name.

Source code in ontocast/tool/triple_manager/fuseki.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
class FusekiTripleStoreManager(TripleStoreManagerWithAuth):
    """Fuseki-based triple store manager.

    This class provides a concrete implementation of triple store management
    using Apache Fuseki. It stores ontologies as named graphs using their
    URIs as graph names, and supports dataset creation and cleanup.

    **URI shape:** ``uri`` must be the Fuseki **HTTP server root** (e.g.
    ``http://localhost:3032``), not a dataset path or UI URL. Dataset names are
    ``dataset`` / ``ontologies_dataset``; the client calls
    ``{uri}/{dataset_name}/sparql`` and similar. The UI route
    ``/#/dataset/dataset_name`` is only for the browser; paste the origin (and
    optional non-dataset path prefix) into ``FUSEKI_URI``, and set
    ``FUSEKI_DATASET`` to ``dataset_name``.

    The manager uses Fuseki's REST API for all operations, including:
    - Dataset creation and management
    - Named graph operations for ontologies
    - SPARQL queries for ontology discovery
    - Graph-level data operations

    Attributes:
        dataset: Facts dataset name (first path segment in Fuseki HTTP API).
        ontologies_dataset: Ontologies dataset name.
    """

    dataset: str | None = Field(default=None, description="Fuseki dataset name")
    ontologies_dataset: str = Field(
        default=DEFAULT_ONTOLOGIES_DATASET,
        description="Fuseki dataset name for ontologies",
    )

    def __init__(
        self,
        uri=None,
        auth=None,
        dataset=None,
        ontologies_dataset=None,
        **kwargs,
    ):
        """Initialize the Fuseki triple store manager.

        This method sets up the connection to Fuseki and creates the dataset
        if it doesn't exist. The dataset is NOT cleaned on initialization.

        Args:
            uri: Fuseki HTTP service root (e.g. ``http://localhost:3030``), not
                ``.../dataset/name`` and not a ``#/dataset/...`` UI link.
            auth: Authentication tuple (username, password) or string in "user/password" format.
            dataset: Facts dataset name (Fuseki API path segment).
            ontologies_dataset: Ontologies dataset name (separate Fuseki dataset).
            **kwargs: Additional keyword arguments passed to the parent class.

        Example:
            >>> manager = FusekiTripleStoreManager(
            ...     uri="http://localhost:3030",
            ...     dataset="acme--demo--facts",
            ...     ontologies_dataset="acme--demo--ontologies",
            ... )
            >>> await manager.clean()
        """
        super().__init__(
            uri=uri, auth=auth, env_uri="FUSEKI_URI", env_auth="FUSEKI_AUTH", **kwargs
        )
        self.uri = normalize_fuseki_server_uri(self.uri)
        if dataset is None:
            self.dataset = DEFAULT_DATASET
        else:
            self.dataset = dataset
        self.ontologies_dataset = ontologies_dataset or DEFAULT_ONTOLOGIES_DATASET

        # Initialize httpx client for async operations (recreated per event loop;
        # httpx.AsyncClient is bound to the loop it was created on).
        self._client: httpx.AsyncClient | None = None
        self._client_loop: asyncio.AbstractEventLoop | None = None

        self._full_catalog_fetches = 0
        self._graph_fetches = 0
        self._select_queries = 0
        self._construct_queries = 0
        self._last_catalog_was_partial = False

    async def async_init(self) -> None:
        """Initialize configured Fuseki datasets explicitly.

        Constructors stay side-effect free so callers can resolve tenancy first
        and then create datasets for the final dataset names.
        """
        # Use a temporary client to keep initialization independent from any
        # loop-bound long-lived client state.
        async with httpx.AsyncClient(
            auth=self._prepare_auth(), timeout=30.0
        ) as temp_client:
            # Temporarily replace the client
            original_client = self._client
            self._client = temp_client
            try:
                await self._initialize_datasets()
            finally:
                # Restore original client
                self._client = original_client

    async def _initialize_datasets(self) -> None:
        """Create configured facts/ontologies datasets when missing."""
        await self.init_dataset(self.dataset)
        if self.ontologies_dataset != self.dataset:
            await self.init_dataset(self.ontologies_dataset)

    def _prepare_auth(self) -> httpx.BasicAuth | None:
        """Prepare httpx BasicAuth from self.auth.

        Accepts ``user/password`` and ``user:password``. Both forms appear in
        the wild -- the colon form is what Fuseki's own docs and most HTTP
        tooling use -- and previously only the slash form parsed, so
        ``FUSEKI_AUTH=admin:secret`` silently produced *no* auth header and
        surfaced as an opaque 401. The separator that appears first wins, so a
        password containing the other character still round-trips.

        Returns:
            httpx.BasicAuth instance, or None when no auth is configured.
        """
        if not self.auth:
            return None
        if isinstance(self.auth, tuple):
            return httpx.BasicAuth(*self.auth)
        if isinstance(self.auth, str):
            positions = [
                (self.auth.index(sep), sep) for sep in ("/", ":") if sep in self.auth
            ]
            if positions:
                index, _ = min(positions)
                username, password = self.auth[:index], self.auth[index + 1 :]
                if username:
                    return httpx.BasicAuth(username, password)
            logger.warning(
                "FUSEKI_AUTH is set but is not in 'user/password' or 'user:password' "
                "form; proceeding without authentication."
            )
        return None

    async def _get_client(self) -> httpx.AsyncClient:
        """Get or create the httpx async client for the current running event loop."""
        loop = asyncio.get_running_loop()
        if self._client is not None and self._client_loop is loop:
            return self._client
        # Client from a prior asyncio.run() is bound to a closed loop; do not await
        # aclose() on it (that schedules callbacks on the dead loop).
        self._client = None
        self._client_loop = None
        auth = self._prepare_auth()
        self._client = httpx.AsyncClient(auth=auth, timeout=30.0)
        self._client_loop = loop
        return self._client

    async def close(self):
        """Close the httpx client."""
        if self._client is not None:
            await self._client.aclose()
            self._client = None
        self._client_loop = None

    def last_catalog_was_complete(self) -> bool:
        """False when the last full catalog fetch could not materialize every graph."""
        return not self._last_catalog_was_partial

    def supports_tenancy_partition(self) -> bool:
        return True

    async def update_tenancy(
        self,
        tenant: str,
        project: str,
        *,
        sep: str = TENANCY_SEP,
    ) -> None:
        """Switch facts and ontologies Fuseki datasets for ``tenant`` / ``project``."""
        facts = tenant_project_facts_name(tenant, project, sep=sep)
        ontos = tenant_project_ontologies_name(tenant, project, sep=sep)
        self.dataset = facts
        self.ontologies_dataset = ontos
        await self.init_dataset(self.dataset)
        if self.ontologies_dataset != self.dataset:
            await self.init_dataset(self.ontologies_dataset)
        logger.info(
            "Fuseki tenancy set to tenant=%r project=%r (facts=%s ontologies=%s)",
            tenant,
            project,
            self.dataset,
            self.ontologies_dataset,
        )

    async def clean(self) -> None:
        """Clear the configured facts dataset and ontologies dataset (when distinct)."""
        assert self.dataset is not None, "Dataset should never be None"
        await self._clean_dataset_by_name(self.dataset)
        logger.info("Fuseki dataset '%s' cleaned (all data deleted)", self.dataset)

        if self.ontologies_dataset != self.dataset:
            await self._clean_dataset_by_name(self.ontologies_dataset)
            logger.info(
                "Fuseki ontologies dataset '%s' cleaned (all data deleted)",
                self.ontologies_dataset,
            )

    async def clean_tenancy(
        self,
        tenant: str,
        project: str,
        *,
        sep: str = TENANCY_SEP,
    ) -> None:
        """Flush facts and ontologies datasets for ``tenant`` / ``project`` (by derived names)."""
        facts = tenant_project_facts_name(tenant, project, sep=sep)
        ontos = tenant_project_ontologies_name(tenant, project, sep=sep)
        await self._clean_dataset_by_name(facts)
        if ontos != facts:
            await self._clean_dataset_by_name(ontos)
        logger.info(
            "Fuseki tenancy flush tenant=%r project=%r (facts=%s ontologies=%s)",
            tenant,
            project,
            facts,
            ontos,
        )

    async def _clean_dataset_by_name(self, dataset_name: str) -> None:
        """Clean a specific dataset by name.

        This is a helper method that performs the actual cleaning of a single dataset.
        It deletes all named graphs and clears the default graph.

        Uses a temporary client to avoid event loop cleanup issues when called
        from different async contexts.

        Args:
            dataset_name: Name of the dataset to clean.

        Raises:
            Exception: If the cleanup operation fails.
        """
        # Use a temporary client to avoid event loop cleanup issues
        async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
            try:
                dataset_url = f"{self.uri}/{dataset_name}"
                sparql_update_url = f"{dataset_url}/update"
                sparql_url = f"{dataset_url}/sparql"

                # Delete all named graphs
                query = """
                SELECT DISTINCT ?g WHERE {
                  GRAPH ?g { ?s ?p ?o }
                }
                """
                response = await client.post(
                    sparql_url,
                    data={"query": query, "format": "application/sparql-results+json"},
                )

                if response.status_code == 200:
                    results = response.json()
                    tasks = []
                    for binding in results.get("results", {}).get("bindings", []):
                        graph_uri = binding["g"]["value"]
                        # Delete the named graph using SPARQL UPDATE
                        drop_query = f"DROP GRAPH <{graph_uri}>"
                        tasks.append(
                            client.post(
                                sparql_update_url,
                                data={"update": drop_query},
                            )
                        )

                    # Execute all deletions in parallel
                    delete_responses = await asyncio.gather(
                        *tasks, return_exceptions=True
                    )
                    for i, delete_response in enumerate(delete_responses):
                        graph_uri = results["results"]["bindings"][i]["g"]["value"]
                        if isinstance(delete_response, Exception):
                            logger.warning(
                                f"Failed to delete graph {graph_uri}: {delete_response}"
                            )
                        elif isinstance(delete_response, httpx.Response):
                            if delete_response.status_code in (200, 204):
                                logger.debug(f"Deleted named graph: {graph_uri}")
                            else:
                                logger.warning(
                                    f"Failed to delete graph {graph_uri}: {delete_response.status_code}"
                                )

                # Clear the default graph using SPARQL UPDATE
                clear_query = "CLEAR DEFAULT"
                clear_response = await client.post(
                    sparql_update_url,
                    data={"update": clear_query},
                )
                if clear_response.status_code in (200, 204):
                    logger.debug(f"Cleared default graph in dataset '{dataset_name}'")
                else:
                    logger.warning(
                        f"Failed to clear default graph in dataset '{dataset_name}': {clear_response.status_code}"
                    )
            except Exception as e:
                logger.error(f"Failed to clean dataset '{dataset_name}': {e}")
                raise

    async def init_dataset(self, dataset_name):
        """Initialize a Fuseki dataset.

        This method creates a new dataset in Fuseki if it doesn't already exist.
        It uses Fuseki's admin API to create the dataset with TDB2 storage.

        Uses a temporary client to avoid event loop cleanup issues when called
        from different async contexts.

        Args:
            dataset_name: Name of the dataset to create.

        Note:
            This method will not fail if the dataset already exists.
        """
        # Use a temporary client to avoid event loop cleanup issues
        async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
            fuseki_admin_url = f"{self.uri}/$/datasets"

            payload = {"dbName": dataset_name, "dbType": "tdb2"}

            headers = {"Content-Type": "application/x-www-form-urlencoded"}

            response = await client.post(
                fuseki_admin_url, data=payload, headers=headers
            )

            if response.status_code == 200 or response.status_code == 201:
                logger.info(f"Fuseki dataset '{dataset_name}' created successfully.")
            elif response.status_code == 409:
                logger.info(
                    f"Fuseki status code: {response.status_code}; {response.text.strip()}"
                )
            else:
                logger.error(
                    f"Failed to create dataset {dataset_name}. Status code: {response.status_code}"
                )
                logger.error(f"Response: {response.text.strip()}")

    def _get_dataset_url(self):
        """Get the full URL for the dataset.

        Returns:
            str: The complete URL for the dataset endpoint.
        """
        return f"{self.uri}/{self.dataset}"

    def _get_ontologies_dataset_url(self):
        """Get the full URL for the ontologies dataset.

        Returns:
            str: The complete URL for the ontologies dataset endpoint.
        """
        return f"{self.uri}/{self.ontologies_dataset}"

    async def drop_named_graph(
        self, graph_uri: str, *, use_ontologies_dataset: bool = True
    ) -> None:
        """Drop a single named graph in the ontologies or main dataset."""
        dataset_url = (
            self._get_ontologies_dataset_url()
            if use_ontologies_dataset
            else self._get_dataset_url()
        )
        update_url = f"{dataset_url}/update"
        drop_query = f"DROP GRAPH <{graph_uri}>"
        async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
            response = await client.post(update_url, data={"update": drop_query})
            if response.status_code not in (200, 204):
                logger.warning(
                    "Fuseki DROP GRAPH failed for %s: %s %s",
                    graph_uri,
                    response.status_code,
                    response.text,
                )

    async def drop_all_ontology_graphs_for_iri(self, ontology_iri: str) -> None:
        """Remove named graphs for ``ontology_iri`` (base and ``iri#...`` versioned)."""
        prefix = f"{ontology_iri}#"
        async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
            sparql_url = f"{self._get_ontologies_dataset_url()}/sparql"
            list_query = """
            SELECT DISTINCT ?g WHERE {
              GRAPH ?g { ?s ?p ?o }
            }
            """
            response = await client.post(
                sparql_url,
                data={"query": list_query, "format": "application/sparql-results+json"},
            )
            if response.status_code != 200:
                logger.error(
                    "Failed to list graphs from Fuseki ontologies dataset: %s",
                    response.text,
                )
                return
            to_drop: list[str] = []
            for binding in response.json().get("results", {}).get("bindings", []):
                g = binding["g"]["value"]
                if g == ontology_iri or g.startswith(prefix):
                    to_drop.append(g)
            update_url = f"{self._get_ontologies_dataset_url()}/update"
            for graph_uri in to_drop:
                drop_query = f"DROP GRAPH <{graph_uri}>"
                dr = await client.post(update_url, data={"update": drop_query})
                if dr.status_code not in (200, 204):
                    logger.warning(
                        "Failed to drop graph %s: %s %s",
                        graph_uri,
                        dr.status_code,
                        dr.text,
                    )

    def fetch_ontologies(self) -> list[Ontology]:
        """Synchronous wrapper for fetch_ontologies.

        For async usage, use afetch_ontologies() instead.

        Raises:
            RuntimeError: If called from inside a running event loop; await
                :meth:`afetch_ontologies` there.
        """
        require_no_running_loop(
            "FusekiTripleStoreManager.fetch_ontologies",
            "FusekiTripleStoreManager.afetch_ontologies",
        )
        # Use a temporary client for this operation to avoid event loop cleanup issues
        return asyncio.run(self._fetch_ontologies_with_cleanup())

    async def afetch_ontologies(self) -> list[Ontology]:
        """Async version of fetch_ontologies.

        This is the preferred method when running in an async context.
        """
        return await self._fetch_ontologies_async()

    async def _fetch_ontologies_with_cleanup(self) -> list[Ontology]:
        """Wrapper that ensures proper cleanup when using asyncio.run().

        This method creates a temporary client and ensures it's properly closed
        before returning, preventing "Event loop is closed" errors.
        """
        async with httpx.AsyncClient(
            auth=self._prepare_auth(), timeout=30.0
        ) as temp_client:
            # Temporarily replace the client
            original_client = self._client
            self._client = temp_client
            try:
                return await self._fetch_ontologies_async()
            finally:
                # Restore original client
                self._client = original_client

    async def _sparql_select_rows(
        self, client: httpx.AsyncClient, sparql_url: str, query: str
    ) -> list[dict[str, str]]:
        """POST a SPARQL SELECT and flatten its JSON bindings to lexical values."""
        self._select_queries += 1
        response = await client.post(
            sparql_url,
            data={"query": query, "format": "application/sparql-results+json"},
        )
        response.raise_for_status()
        return [
            {var: binding[var]["value"] for var in binding}
            for binding in response.json().get("results", {}).get("bindings", [])
        ]

    def _sparql_endpoint(self, *, use_ontologies_dataset: bool) -> str:
        """Resolve the SPARQL query endpoint for the active tenancy partition."""
        dataset_url = (
            self._get_ontologies_dataset_url()
            if use_ontologies_dataset
            else self._get_dataset_url()
        )
        return f"{dataset_url}/sparql"

    def supports_sparql_select(self) -> bool:
        return True

    def supports_sparql_construct(self) -> bool:
        return True

    async def aconstruct(
        self, query: str, *, use_ontologies_dataset: bool = True
    ) -> RDFGraph:
        """Run a SPARQL CONSTRUCT against the active dataset, parsing Turtle back.

        Tenancy is implicit, as for :meth:`aselect`.
        """
        client = await self._get_client()
        self._construct_queries += 1
        response = await client.post(
            self._sparql_endpoint(use_ontologies_dataset=use_ontologies_dataset),
            data={"query": query},
            headers={"Accept": "text/turtle"},
        )
        response.raise_for_status()
        result = RDFGraph()
        text = response.text
        if text.strip():
            result.parse(data=text, format="turtle")
        return result

    async def aselect(
        self, query: str, *, use_ontologies_dataset: bool = True
    ) -> list[dict[str, str]]:
        """Run a SPARQL SELECT against the active dataset.

        Tenancy is implicit: :meth:`update_tenancy` rewrites the dataset names this
        resolves through.
        """
        client = await self._get_client()
        return await self._sparql_select_rows(
            client,
            self._sparql_endpoint(use_ontologies_dataset=use_ontologies_dataset),
            query,
        )

    async def afetch_ontology_catalog(self) -> list[OntologyHeader]:
        """Read one header per stored ontology version via a single SELECT."""
        rows = await self.aselect(ONTOLOGY_HEADER_QUERY)
        return headers_from_select_rows(rows)

    async def afetch_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
        """Fetch only the named graphs backing ``iris``, skipping the rest."""
        if not iris:
            return await self.afetch_ontologies()
        wanted = set(iris)
        headers = dedupe_terminal_ontologies(await self.afetch_ontology_catalog())
        graph_uris = [header.graph_uri for header in headers if header.iri in wanted]
        if not graph_uris:
            return []
        client = await self._get_client()
        return await self._fetch_ontology_graphs(client, graph_uris)

    def catalog_io_stats(self) -> dict[str, int]:
        """Counters for catalog I/O, for tests and diagnostics."""
        return {
            "full_catalog_fetches": self._full_catalog_fetches,
            "graph_fetches": self._graph_fetches,
            "select_queries": self._select_queries,
            "construct_queries": self._construct_queries,
        }

    async def _list_ontology_graph_uris(self, client: httpx.AsyncClient) -> list[str]:
        """List every named graph in the ontologies dataset.

        Raises:
            TripleStoreUnavailableError: the listing could not be performed.
                This deliberately does **not** degrade to an empty list: an
                empty catalog is indistinguishable from "no ontologies stored",
                and ``ToolBox.initialize`` treats an empty catalog as grounds to
                prune every indexed ontology IRI from the vector store. A
                transient network error must not be able to wipe the index.
                Mirrors the contract stated for ``aselect``/``aconstruct`` on
                :class:`~ontocast.tool.triple_manager.core.TripleStoreManager`.
        """
        sparql_url = f"{self._get_ontologies_dataset_url()}/sparql"
        try:
            rows = await self._sparql_select_rows(
                client, sparql_url, LIST_NAMED_GRAPHS_QUERY
            )
        except httpx.HTTPError as exc:
            logger.error("Failed to list graphs from Fuseki: %s", exc)
            raise TripleStoreUnavailableError(
                f"Could not list named graphs in {sparql_url}: {exc}"
            ) from exc
        graph_uris = [row["g"] for row in rows if "g" in row]
        logger.debug("Found %d named graphs: %s", len(graph_uris), graph_uris)
        return graph_uris

    async def _fetch_ontology_graphs(
        self, client: httpx.AsyncClient, graph_uris: Sequence[str]
    ) -> list[Ontology]:
        """Materialize the named graphs in ``graph_uris`` in parallel."""

        async def fetch_single_ontology(graph_uri: str) -> Ontology | None:
            """Fetch a single ontology from a graph URI."""
            try:
                self._graph_fetches += 1
                graph = RDFGraph()
                # URL encode the graph URI to handle special characters like #
                encoded_graph_uri = quote(str(graph_uri), safe="/:")
                export_url = f"{self._get_ontologies_dataset_url()}/get?graph={encoded_graph_uri}"
                export_resp = await client.get(
                    export_url, headers={"Accept": "text/turtle"}
                )

                if export_resp.status_code == 200:
                    graph.parse(data=export_resp.text, format="turtle")
                    return ontology_from_named_graph(graph_uri, graph)
                else:
                    logger.warning(
                        f"Failed to fetch graph {graph_uri}: {export_resp.status_code}"
                    )
            except Exception as e:
                logger.warning(f"Error fetching ontology from {graph_uri}: {e}")
            return None

        results = await asyncio.gather(
            *[fetch_single_ontology(uri) for uri in graph_uris], return_exceptions=True
        )

        ontologies: list[Ontology] = []
        for result in results:
            if isinstance(result, Exception):
                logger.warning(f"Exception fetching ontology: {result}")
            elif isinstance(result, Ontology):
                ontologies.append(result)

        missing = len(graph_uris) - len(ontologies)
        if missing:
            # A partial catalog is as dangerous as an empty one: the ontologies
            # that failed to materialize look like orphans to the vector-store
            # prune. Record it so callers can refuse to treat this catalog as
            # authoritative.
            self._last_catalog_was_partial = True
            logger.error(
                "Fetched %d of %d ontology graphs; %d failed. The catalog is "
                "incomplete and must not be treated as authoritative.",
                len(ontologies),
                len(graph_uris),
                missing,
            )
        else:
            self._last_catalog_was_partial = False
        return ontologies

    async def _fetch_ontologies_async(self) -> list[Ontology]:
        """Fetch all ontologies from their corresponding named graphs.

        This method discovers all ontologies in the Fuseki ontologies dataset and
        fetches each one from its corresponding named graph. For versioned ontologies,
        it returns only the latest version for each unique ontology IRI.

        1. Discovery: List all named graphs (which may be versioned URIs)
        2. Fetching: Retrieve each ontology from its named graph (in parallel)
        3. Deduplication: For versioned ontologies, keep only the latest version

        Returns:
            list[Ontology]: List of the latest version of each ontology found.

        Example:
            >>> ontologies = await manager.fetch_ontologies()
            >>> for onto in ontologies:
            ...     print(f"Found ontology: {onto.iri} v{onto.version}")
        """
        self._full_catalog_fetches += 1
        client = await self._get_client()
        graph_uris = await self._list_ontology_graph_uris(client)
        if not graph_uris:
            return []
        all_ontologies = await self._fetch_ontology_graphs(client, graph_uris)
        ontologies = dedupe_terminal_ontologies(all_ontologies)
        logger.info(
            "Successfully loaded %d unique ontologies from Fuseki", len(ontologies)
        )
        return ontologies

    def serialize_graph(self, graph: Graph, **kwargs) -> bool:
        """Synchronous wrapper for serialize_graph.

        For async usage, use aserialize_graph() instead.

        Raises:
            RuntimeError: If called from inside a running event loop; await
                :meth:`aserialize_graph` there.
        """
        require_no_running_loop(
            "FusekiTripleStoreManager.serialize_graph",
            "FusekiTripleStoreManager.aserialize_graph",
        )
        return asyncio.run(self._serialize_graph_with_cleanup(graph, **kwargs))

    async def aserialize_graph(self, graph: Graph, **kwargs) -> bool:
        """Async version of serialize_graph.

        This is the preferred method when running in an async context.
        """
        return await self._serialize_graph_async(graph, **kwargs)

    async def _serialize_graph_with_cleanup(self, graph: Graph, **kwargs) -> bool:
        """Wrapper that ensures proper cleanup when using asyncio.run().

        This method creates a temporary client and ensures it's properly closed
        before returning, preventing "Event loop is closed" errors.
        """
        async with httpx.AsyncClient(
            auth=self._prepare_auth(), timeout=30.0
        ) as temp_client:
            # Temporarily replace the client
            original_client = self._client
            self._client = temp_client
            try:
                return await self._serialize_graph_async(graph, **kwargs)
            finally:
                # Restore original client
                self._client = original_client

    async def _serialize_graph_async(self, graph: Graph, **kwargs) -> bool:
        """Store an RDF graph as a named graph in a specific Fuseki dataset.

        This is a private helper method that handles the common logic for storing
        graphs in Fuseki datasets.

        Args:
            graph: The RDF graph to store.
            **kwargs: Additional parameters including graph_uri, dataset_url, default_graph_uri, log_prefix.

        Returns:
            bool: True if the graph was successfully stored, False otherwise.
        """
        client = await self._get_client()
        graph_uri = kwargs.get("graph_uri")
        dataset_url = kwargs.get("dataset_url")
        default_graph_uri = kwargs.get("default_graph_uri")
        log_prefix = kwargs.get("log_prefix")

        if isinstance(graph, RDFGraph):
            turtle_data = graph.serialize_canonical_turtle()
        else:
            rdf_graph = RDFGraph()
            for triple in graph:
                rdf_graph.add(triple)
            for prefix, namespace in graph.namespaces():
                rdf_graph.bind(prefix, namespace)
            turtle_data = rdf_graph.serialize_canonical_turtle()
        if graph_uri is None:
            graph_uri = default_graph_uri

        # URL encode the graph URI to handle special characters like #
        encoded_graph_uri = quote(str(graph_uri), safe="/:")
        url = f"{dataset_url}/data?graph={encoded_graph_uri}"
        headers = {"Content-Type": "text/turtle;charset=utf-8"}
        response = await client.put(url, headers=headers, content=turtle_data)
        if response.status_code in (200, 201, 204):
            logger.info(
                f"{log_prefix} graph {graph_uri} uploaded to Fuseki as named graph."
            )
            return True
        else:
            logger.error(
                f"Failed to upload {log_prefix.lower() if log_prefix else 'unknown'} graph {graph_uri}. Status code: {response.status_code}"
            )
            logger.error(f"Response: {response.text}")
            return False

    def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        """Synchronous wrapper for serialize.

        For async usage, use aserialize() instead.

        Raises:
            RuntimeError: If called from inside a running event loop; await
                :meth:`aserialize` there.
        """
        require_no_running_loop(
            "FusekiTripleStoreManager.serialize",
            "FusekiTripleStoreManager.aserialize",
        )
        return asyncio.run(self._serialize_with_cleanup(o, **kwargs))

    async def aserialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        """Async version of serialize.

        This is the preferred method when running in an async context.
        """
        return await self._serialize_async(o, **kwargs)

    async def _serialize_with_cleanup(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        """Wrapper that ensures proper cleanup when using asyncio.run().

        This method creates a temporary client and ensures it's properly closed
        before returning, preventing "Event loop is closed" errors.
        """
        async with httpx.AsyncClient(
            auth=self._prepare_auth(), timeout=30.0
        ) as temp_client:
            # Temporarily replace the client
            original_client = self._client
            self._client = temp_client
            try:
                return await self._serialize_async(o, **kwargs)
            finally:
                # Restore original client
                self._client = original_client

    async def _serialize_async(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        """Store an RDF graph as a named graph in Fuseki.

        This method stores the given RDF graph as a named graph in Fuseki.
        The graph name is taken from the graph_uri parameter or defaults to
        "urn:data:default".

        Args:
            o: RDF graph or Ontology object.
            **kwargs: Additional parameters including graph_uri.

        Returns:
            bool: True if the graph was successfully stored, False otherwise.

        Example:
            >>> graph = RDFGraph()
            >>> success = await manager.serialize(graph)

            >>> success = await manager.serialize(graph, graph_uri="http://example.org/chunk1")
        """
        graph_uri = kwargs.get("graph_uri")

        if isinstance(o, Ontology):
            if o.iri and not o.is_null():
                # Persist author @prefix names as triples before they die at
                # the store boundary (idempotent, excluded from content hash).
                o.graph.materialize_prefix_declarations(URIRef(o.iri))
            graph = o.graph
            # Use versioned IRI for storage to enable multiple versions to coexist
            graph_uri = o.versioned_iri
            default_graph_uri = "urn:ontology:default"
            log_prefix = "Ontology"
            # Use ontologies dataset for ontology storage
            dataset_url = self._get_ontologies_dataset_url()
        elif isinstance(o, RDFGraph):
            graph = o
            default_graph_uri = "urn:data:default"
            log_prefix = "Graph"
            # Use regular dataset for facts storage
            dataset_url = self._get_dataset_url()
        else:
            raise TypeError(f"unsupported obj of type {type(o)} received")

        return await self._serialize_graph_async(
            graph=graph,
            graph_uri=graph_uri,
            dataset_url=dataset_url,
            default_graph_uri=default_graph_uri,
            log_prefix=log_prefix,
        )

__init__(uri=None, auth=None, dataset=None, ontologies_dataset=None, **kwargs)

Initialize the Fuseki triple store manager.

This method sets up the connection to Fuseki and creates the dataset if it doesn't exist. The dataset is NOT cleaned on initialization.

Parameters:

Name Type Description Default
uri

Fuseki HTTP service root (e.g. http://localhost:3030), not .../dataset/name and not a #/dataset/... UI link.

None
auth

Authentication tuple (username, password) or string in "user/password" format.

None
dataset

Facts dataset name (Fuseki API path segment).

None
ontologies_dataset

Ontologies dataset name (separate Fuseki dataset).

None
**kwargs

Additional keyword arguments passed to the parent class.

{}
Example

manager = FusekiTripleStoreManager( ... uri="http://localhost:3030", ... dataset="acme--demo--facts", ... ontologies_dataset="acme--demo--ontologies", ... ) await manager.clean()

Source code in ontocast/tool/triple_manager/fuseki.py
def __init__(
    self,
    uri=None,
    auth=None,
    dataset=None,
    ontologies_dataset=None,
    **kwargs,
):
    """Initialize the Fuseki triple store manager.

    This method sets up the connection to Fuseki and creates the dataset
    if it doesn't exist. The dataset is NOT cleaned on initialization.

    Args:
        uri: Fuseki HTTP service root (e.g. ``http://localhost:3030``), not
            ``.../dataset/name`` and not a ``#/dataset/...`` UI link.
        auth: Authentication tuple (username, password) or string in "user/password" format.
        dataset: Facts dataset name (Fuseki API path segment).
        ontologies_dataset: Ontologies dataset name (separate Fuseki dataset).
        **kwargs: Additional keyword arguments passed to the parent class.

    Example:
        >>> manager = FusekiTripleStoreManager(
        ...     uri="http://localhost:3030",
        ...     dataset="acme--demo--facts",
        ...     ontologies_dataset="acme--demo--ontologies",
        ... )
        >>> await manager.clean()
    """
    super().__init__(
        uri=uri, auth=auth, env_uri="FUSEKI_URI", env_auth="FUSEKI_AUTH", **kwargs
    )
    self.uri = normalize_fuseki_server_uri(self.uri)
    if dataset is None:
        self.dataset = DEFAULT_DATASET
    else:
        self.dataset = dataset
    self.ontologies_dataset = ontologies_dataset or DEFAULT_ONTOLOGIES_DATASET

    # Initialize httpx client for async operations (recreated per event loop;
    # httpx.AsyncClient is bound to the loop it was created on).
    self._client: httpx.AsyncClient | None = None
    self._client_loop: asyncio.AbstractEventLoop | None = None

    self._full_catalog_fetches = 0
    self._graph_fetches = 0
    self._select_queries = 0
    self._construct_queries = 0
    self._last_catalog_was_partial = False

aconstruct(query, *, use_ontologies_dataset=True) async

Run a SPARQL CONSTRUCT against the active dataset, parsing Turtle back.

Tenancy is implicit, as for :meth:aselect.

Source code in ontocast/tool/triple_manager/fuseki.py
async def aconstruct(
    self, query: str, *, use_ontologies_dataset: bool = True
) -> RDFGraph:
    """Run a SPARQL CONSTRUCT against the active dataset, parsing Turtle back.

    Tenancy is implicit, as for :meth:`aselect`.
    """
    client = await self._get_client()
    self._construct_queries += 1
    response = await client.post(
        self._sparql_endpoint(use_ontologies_dataset=use_ontologies_dataset),
        data={"query": query},
        headers={"Accept": "text/turtle"},
    )
    response.raise_for_status()
    result = RDFGraph()
    text = response.text
    if text.strip():
        result.parse(data=text, format="turtle")
    return result

afetch_ontologies() async

Async version of fetch_ontologies.

This is the preferred method when running in an async context.

Source code in ontocast/tool/triple_manager/fuseki.py
async def afetch_ontologies(self) -> list[Ontology]:
    """Async version of fetch_ontologies.

    This is the preferred method when running in an async context.
    """
    return await self._fetch_ontologies_async()

afetch_ontologies_by_iri(iris) async

Fetch only the named graphs backing iris, skipping the rest.

Source code in ontocast/tool/triple_manager/fuseki.py
async def afetch_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
    """Fetch only the named graphs backing ``iris``, skipping the rest."""
    if not iris:
        return await self.afetch_ontologies()
    wanted = set(iris)
    headers = dedupe_terminal_ontologies(await self.afetch_ontology_catalog())
    graph_uris = [header.graph_uri for header in headers if header.iri in wanted]
    if not graph_uris:
        return []
    client = await self._get_client()
    return await self._fetch_ontology_graphs(client, graph_uris)

afetch_ontology_catalog() async

Read one header per stored ontology version via a single SELECT.

Source code in ontocast/tool/triple_manager/fuseki.py
async def afetch_ontology_catalog(self) -> list[OntologyHeader]:
    """Read one header per stored ontology version via a single SELECT."""
    rows = await self.aselect(ONTOLOGY_HEADER_QUERY)
    return headers_from_select_rows(rows)

aselect(query, *, use_ontologies_dataset=True) async

Run a SPARQL SELECT against the active dataset.

Tenancy is implicit: :meth:update_tenancy rewrites the dataset names this resolves through.

Source code in ontocast/tool/triple_manager/fuseki.py
async def aselect(
    self, query: str, *, use_ontologies_dataset: bool = True
) -> list[dict[str, str]]:
    """Run a SPARQL SELECT against the active dataset.

    Tenancy is implicit: :meth:`update_tenancy` rewrites the dataset names this
    resolves through.
    """
    client = await self._get_client()
    return await self._sparql_select_rows(
        client,
        self._sparql_endpoint(use_ontologies_dataset=use_ontologies_dataset),
        query,
    )

aserialize(o, **kwargs) async

Async version of serialize.

This is the preferred method when running in an async context.

Source code in ontocast/tool/triple_manager/fuseki.py
async def aserialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
    """Async version of serialize.

    This is the preferred method when running in an async context.
    """
    return await self._serialize_async(o, **kwargs)

aserialize_graph(graph, **kwargs) async

Async version of serialize_graph.

This is the preferred method when running in an async context.

Source code in ontocast/tool/triple_manager/fuseki.py
async def aserialize_graph(self, graph: Graph, **kwargs) -> bool:
    """Async version of serialize_graph.

    This is the preferred method when running in an async context.
    """
    return await self._serialize_graph_async(graph, **kwargs)

async_init() async

Initialize configured Fuseki datasets explicitly.

Constructors stay side-effect free so callers can resolve tenancy first and then create datasets for the final dataset names.

Source code in ontocast/tool/triple_manager/fuseki.py
async def async_init(self) -> None:
    """Initialize configured Fuseki datasets explicitly.

    Constructors stay side-effect free so callers can resolve tenancy first
    and then create datasets for the final dataset names.
    """
    # Use a temporary client to keep initialization independent from any
    # loop-bound long-lived client state.
    async with httpx.AsyncClient(
        auth=self._prepare_auth(), timeout=30.0
    ) as temp_client:
        # Temporarily replace the client
        original_client = self._client
        self._client = temp_client
        try:
            await self._initialize_datasets()
        finally:
            # Restore original client
            self._client = original_client

catalog_io_stats()

Counters for catalog I/O, for tests and diagnostics.

Source code in ontocast/tool/triple_manager/fuseki.py
def catalog_io_stats(self) -> dict[str, int]:
    """Counters for catalog I/O, for tests and diagnostics."""
    return {
        "full_catalog_fetches": self._full_catalog_fetches,
        "graph_fetches": self._graph_fetches,
        "select_queries": self._select_queries,
        "construct_queries": self._construct_queries,
    }

clean() async

Clear the configured facts dataset and ontologies dataset (when distinct).

Source code in ontocast/tool/triple_manager/fuseki.py
async def clean(self) -> None:
    """Clear the configured facts dataset and ontologies dataset (when distinct)."""
    assert self.dataset is not None, "Dataset should never be None"
    await self._clean_dataset_by_name(self.dataset)
    logger.info("Fuseki dataset '%s' cleaned (all data deleted)", self.dataset)

    if self.ontologies_dataset != self.dataset:
        await self._clean_dataset_by_name(self.ontologies_dataset)
        logger.info(
            "Fuseki ontologies dataset '%s' cleaned (all data deleted)",
            self.ontologies_dataset,
        )

clean_tenancy(tenant, project, *, sep=TENANCY_SEP) async

Flush facts and ontologies datasets for tenant / project (by derived names).

Source code in ontocast/tool/triple_manager/fuseki.py
async def clean_tenancy(
    self,
    tenant: str,
    project: str,
    *,
    sep: str = TENANCY_SEP,
) -> None:
    """Flush facts and ontologies datasets for ``tenant`` / ``project`` (by derived names)."""
    facts = tenant_project_facts_name(tenant, project, sep=sep)
    ontos = tenant_project_ontologies_name(tenant, project, sep=sep)
    await self._clean_dataset_by_name(facts)
    if ontos != facts:
        await self._clean_dataset_by_name(ontos)
    logger.info(
        "Fuseki tenancy flush tenant=%r project=%r (facts=%s ontologies=%s)",
        tenant,
        project,
        facts,
        ontos,
    )

close() async

Close the httpx client.

Source code in ontocast/tool/triple_manager/fuseki.py
async def close(self):
    """Close the httpx client."""
    if self._client is not None:
        await self._client.aclose()
        self._client = None
    self._client_loop = None

drop_all_ontology_graphs_for_iri(ontology_iri) async

Remove named graphs for ontology_iri (base and iri#... versioned).

Source code in ontocast/tool/triple_manager/fuseki.py
async def drop_all_ontology_graphs_for_iri(self, ontology_iri: str) -> None:
    """Remove named graphs for ``ontology_iri`` (base and ``iri#...`` versioned)."""
    prefix = f"{ontology_iri}#"
    async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
        sparql_url = f"{self._get_ontologies_dataset_url()}/sparql"
        list_query = """
        SELECT DISTINCT ?g WHERE {
          GRAPH ?g { ?s ?p ?o }
        }
        """
        response = await client.post(
            sparql_url,
            data={"query": list_query, "format": "application/sparql-results+json"},
        )
        if response.status_code != 200:
            logger.error(
                "Failed to list graphs from Fuseki ontologies dataset: %s",
                response.text,
            )
            return
        to_drop: list[str] = []
        for binding in response.json().get("results", {}).get("bindings", []):
            g = binding["g"]["value"]
            if g == ontology_iri or g.startswith(prefix):
                to_drop.append(g)
        update_url = f"{self._get_ontologies_dataset_url()}/update"
        for graph_uri in to_drop:
            drop_query = f"DROP GRAPH <{graph_uri}>"
            dr = await client.post(update_url, data={"update": drop_query})
            if dr.status_code not in (200, 204):
                logger.warning(
                    "Failed to drop graph %s: %s %s",
                    graph_uri,
                    dr.status_code,
                    dr.text,
                )

drop_named_graph(graph_uri, *, use_ontologies_dataset=True) async

Drop a single named graph in the ontologies or main dataset.

Source code in ontocast/tool/triple_manager/fuseki.py
async def drop_named_graph(
    self, graph_uri: str, *, use_ontologies_dataset: bool = True
) -> None:
    """Drop a single named graph in the ontologies or main dataset."""
    dataset_url = (
        self._get_ontologies_dataset_url()
        if use_ontologies_dataset
        else self._get_dataset_url()
    )
    update_url = f"{dataset_url}/update"
    drop_query = f"DROP GRAPH <{graph_uri}>"
    async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
        response = await client.post(update_url, data={"update": drop_query})
        if response.status_code not in (200, 204):
            logger.warning(
                "Fuseki DROP GRAPH failed for %s: %s %s",
                graph_uri,
                response.status_code,
                response.text,
            )

fetch_ontologies()

Synchronous wrapper for fetch_ontologies.

For async usage, use afetch_ontologies() instead.

Raises:

Type Description
RuntimeError

If called from inside a running event loop; await :meth:afetch_ontologies there.

Source code in ontocast/tool/triple_manager/fuseki.py
def fetch_ontologies(self) -> list[Ontology]:
    """Synchronous wrapper for fetch_ontologies.

    For async usage, use afetch_ontologies() instead.

    Raises:
        RuntimeError: If called from inside a running event loop; await
            :meth:`afetch_ontologies` there.
    """
    require_no_running_loop(
        "FusekiTripleStoreManager.fetch_ontologies",
        "FusekiTripleStoreManager.afetch_ontologies",
    )
    # Use a temporary client for this operation to avoid event loop cleanup issues
    return asyncio.run(self._fetch_ontologies_with_cleanup())

init_dataset(dataset_name) async

Initialize a Fuseki dataset.

This method creates a new dataset in Fuseki if it doesn't already exist. It uses Fuseki's admin API to create the dataset with TDB2 storage.

Uses a temporary client to avoid event loop cleanup issues when called from different async contexts.

Parameters:

Name Type Description Default
dataset_name

Name of the dataset to create.

required
Note

This method will not fail if the dataset already exists.

Source code in ontocast/tool/triple_manager/fuseki.py
async def init_dataset(self, dataset_name):
    """Initialize a Fuseki dataset.

    This method creates a new dataset in Fuseki if it doesn't already exist.
    It uses Fuseki's admin API to create the dataset with TDB2 storage.

    Uses a temporary client to avoid event loop cleanup issues when called
    from different async contexts.

    Args:
        dataset_name: Name of the dataset to create.

    Note:
        This method will not fail if the dataset already exists.
    """
    # Use a temporary client to avoid event loop cleanup issues
    async with httpx.AsyncClient(auth=self._prepare_auth(), timeout=30.0) as client:
        fuseki_admin_url = f"{self.uri}/$/datasets"

        payload = {"dbName": dataset_name, "dbType": "tdb2"}

        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        response = await client.post(
            fuseki_admin_url, data=payload, headers=headers
        )

        if response.status_code == 200 or response.status_code == 201:
            logger.info(f"Fuseki dataset '{dataset_name}' created successfully.")
        elif response.status_code == 409:
            logger.info(
                f"Fuseki status code: {response.status_code}; {response.text.strip()}"
            )
        else:
            logger.error(
                f"Failed to create dataset {dataset_name}. Status code: {response.status_code}"
            )
            logger.error(f"Response: {response.text.strip()}")

last_catalog_was_complete()

False when the last full catalog fetch could not materialize every graph.

Source code in ontocast/tool/triple_manager/fuseki.py
def last_catalog_was_complete(self) -> bool:
    """False when the last full catalog fetch could not materialize every graph."""
    return not self._last_catalog_was_partial

serialize(o, **kwargs)

Synchronous wrapper for serialize.

For async usage, use aserialize() instead.

Raises:

Type Description
RuntimeError

If called from inside a running event loop; await :meth:aserialize there.

Source code in ontocast/tool/triple_manager/fuseki.py
def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
    """Synchronous wrapper for serialize.

    For async usage, use aserialize() instead.

    Raises:
        RuntimeError: If called from inside a running event loop; await
            :meth:`aserialize` there.
    """
    require_no_running_loop(
        "FusekiTripleStoreManager.serialize",
        "FusekiTripleStoreManager.aserialize",
    )
    return asyncio.run(self._serialize_with_cleanup(o, **kwargs))

serialize_graph(graph, **kwargs)

Synchronous wrapper for serialize_graph.

For async usage, use aserialize_graph() instead.

Raises:

Type Description
RuntimeError

If called from inside a running event loop; await :meth:aserialize_graph there.

Source code in ontocast/tool/triple_manager/fuseki.py
def serialize_graph(self, graph: Graph, **kwargs) -> bool:
    """Synchronous wrapper for serialize_graph.

    For async usage, use aserialize_graph() instead.

    Raises:
        RuntimeError: If called from inside a running event loop; await
            :meth:`aserialize_graph` there.
    """
    require_no_running_loop(
        "FusekiTripleStoreManager.serialize_graph",
        "FusekiTripleStoreManager.aserialize_graph",
    )
    return asyncio.run(self._serialize_graph_with_cleanup(graph, **kwargs))

update_tenancy(tenant, project, *, sep=TENANCY_SEP) async

Switch facts and ontologies Fuseki datasets for tenant / project.

Source code in ontocast/tool/triple_manager/fuseki.py
async def update_tenancy(
    self,
    tenant: str,
    project: str,
    *,
    sep: str = TENANCY_SEP,
) -> None:
    """Switch facts and ontologies Fuseki datasets for ``tenant`` / ``project``."""
    facts = tenant_project_facts_name(tenant, project, sep=sep)
    ontos = tenant_project_ontologies_name(tenant, project, sep=sep)
    self.dataset = facts
    self.ontologies_dataset = ontos
    await self.init_dataset(self.dataset)
    if self.ontologies_dataset != self.dataset:
        await self.init_dataset(self.ontologies_dataset)
    logger.info(
        "Fuseki tenancy set to tenant=%r project=%r (facts=%s ontologies=%s)",
        tenant,
        project,
        self.dataset,
        self.ontologies_dataset,
    )

InMemoryTripleStoreManager

Bases: TripleStoreManager

pyoxigraph-backed in-memory triple store with tenant/project partitions.

Source code in ontocast/tool/triple_manager/in_memory.py
class InMemoryTripleStoreManager(TripleStoreManager):
    """pyoxigraph-backed in-memory triple store with tenant/project partitions."""

    model_config = {"arbitrary_types_allowed": True}

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._partitions: dict[tuple[str, str], _TenantPartition] = {}
        self._active: tuple[str, str] = (DEFAULT_TENANT, DEFAULT_PROJECT)
        self._lock = asyncio.Lock()
        self._full_catalog_fetches = 0
        self._graph_fetches = 0
        self._select_queries = 0
        self._construct_queries = 0
        self._ensure_partition(self._active[0], self._active[1])

    def _ensure_partition(self, tenant: str, project: str) -> _TenantPartition:
        key = (tenant.strip(), project.strip())
        if key not in self._partitions:
            self._partitions[key] = _TenantPartition()
        return self._partitions[key]

    def _active_partition(self) -> _TenantPartition:
        return self._ensure_partition(self._active[0], self._active[1])

    def supports_tenancy_partition(self) -> bool:
        return True

    async def update_tenancy(
        self,
        tenant: str,
        project: str,
        *,
        sep: str = TENANCY_SEP,
    ) -> None:
        _ = sep
        t, p = tenant.strip(), project.strip()
        if not t or not p:
            raise ValueError("tenant and project must be non-empty")
        async with self._lock:
            self._active = (t, p)
            self._ensure_partition(t, p)
        logger.info("In-memory tenancy set to tenant=%r project=%r", tenant, project)

    async def clean(self) -> None:
        async with self._lock:
            partition = self._active_partition()
            partition.facts = ox.Store()
            partition.ontologies = ox.Store()

    async def clean_tenancy(
        self,
        tenant: str,
        project: str,
        *,
        sep: str = TENANCY_SEP,
    ) -> None:
        _ = sep
        key = (tenant.strip(), project.strip())
        async with self._lock:
            self._partitions.pop(key, None)
            if self._active == key:
                self._ensure_partition(key[0], key[1])
        logger.info("In-memory tenancy flush tenant=%r project=%r", tenant, project)

    async def drop_named_graph(
        self, graph_uri: str, *, use_ontologies_dataset: bool = True
    ) -> None:
        async with self._lock:
            partition = self._active_partition()
            store = partition.ontologies if use_ontologies_dataset else partition.facts
            _clear_named_graph(store, _to_ox_graph(graph_uri))

    async def drop_all_ontology_graphs_for_iri(self, ontology_iri: str) -> None:
        prefix = f"{ontology_iri}#"
        async with self._lock:
            partition = self._active_partition()
            for graph_uri in _list_named_graph_uris(partition.ontologies):
                if graph_uri == ontology_iri or graph_uri.startswith(prefix):
                    _clear_named_graph(partition.ontologies, _to_ox_graph(graph_uri))

    def supports_sparql_select(self) -> bool:
        return True

    async def aselect(
        self, query: str, *, use_ontologies_dataset: bool = True
    ) -> list[dict[str, str]]:
        """Evaluate a SPARQL SELECT against the active partition."""
        async with self._lock:
            partition = self._active_partition()
            store = partition.ontologies if use_ontologies_dataset else partition.facts
        self._select_queries += 1
        return await asyncio.to_thread(_run_select, store, query)

    def supports_sparql_construct(self) -> bool:
        return True

    async def aconstruct(
        self, query: str, *, use_ontologies_dataset: bool = True
    ) -> RDFGraph:
        """Evaluate a SPARQL CONSTRUCT against the active partition."""
        async with self._lock:
            partition = self._active_partition()
            store = partition.ontologies if use_ontologies_dataset else partition.facts
        self._construct_queries += 1
        return await asyncio.to_thread(_run_construct, store, query)

    async def afetch_ontology_catalog(self) -> list[OntologyHeader]:
        """Read one header per stored ontology version via a single SELECT."""
        rows = await self.aselect(ONTOLOGY_HEADER_QUERY)
        return headers_from_select_rows(rows)

    async def afetch_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
        """Materialize only the named graphs backing ``iris``."""
        if not iris:
            return await self.afetch_ontologies()
        wanted = set(iris)
        headers = dedupe_terminal_ontologies(await self.afetch_ontology_catalog())
        graph_uris = [header.graph_uri for header in headers if header.iri in wanted]
        if not graph_uris:
            return []
        return await asyncio.to_thread(self._materialize_graphs, graph_uris)

    def _materialize_graphs(self, graph_uris: Sequence[str]) -> list[Ontology]:
        """Build ontologies from an explicit list of named graph URIs."""
        partition = self._active_partition()
        ontologies: list[Ontology] = []
        for graph_uri in graph_uris:
            self._graph_fetches += 1
            graph = _export_named_graph(partition.ontologies, graph_uri)
            onto = ontology_from_named_graph(graph_uri, graph)
            if onto is not None:
                ontologies.append(onto)
        return ontologies

    def catalog_io_stats(self) -> dict[str, int]:
        """Counters for catalog I/O, for tests and diagnostics."""
        return {
            "full_catalog_fetches": self._full_catalog_fetches,
            "graph_fetches": self._graph_fetches,
            "select_queries": self._select_queries,
            "construct_queries": self._construct_queries,
        }

    def fetch_ontologies(self) -> list[Ontology]:
        self._full_catalog_fetches += 1
        partition = self._active_partition()
        result = dedupe_terminal_ontologies(
            self._materialize_graphs(_list_named_graph_uris(partition.ontologies))
        )
        logger.info("Loaded %d unique ontologies from in-memory store", len(result))
        return result

    def serialize_graph(self, graph: Graph, **kwargs) -> bool:
        graph_uri = kwargs.get("graph_uri")
        use_ontologies = kwargs.pop("use_ontologies_dataset", False)
        if graph_uri is None:
            graph_uri = kwargs.get("default_graph_uri", "urn:data:default")

        partition = self._active_partition()
        store = partition.ontologies if use_ontologies else partition.facts
        graph_ctx = _to_ox_graph(str(graph_uri))
        _clear_named_graph(store, graph_ctx)
        quads = _rdflib_graph_to_quads(graph, graph_ctx)
        if quads:
            store.extend(quads)
        return True

    def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        if isinstance(o, Ontology):
            if o.iri and not o.is_null():
                # Persist author @prefix names as triples before they die at
                # the store boundary (idempotent, excluded from content hash).
                o.graph.materialize_prefix_declarations(URIRef(o.iri))
            return self.serialize_graph(
                o.graph,
                graph_uri=o.versioned_iri,
                use_ontologies_dataset=True,
            )
        if isinstance(o, RDFGraph):
            graph_uri = kwargs.get("graph_uri", "urn:data:default")
            return self.serialize_graph(
                o,
                graph_uri=graph_uri,
                use_ontologies_dataset=False,
            )
        raise TypeError(f"unsupported obj of type {type(o)} received")

aconstruct(query, *, use_ontologies_dataset=True) async

Evaluate a SPARQL CONSTRUCT against the active partition.

Source code in ontocast/tool/triple_manager/in_memory.py
async def aconstruct(
    self, query: str, *, use_ontologies_dataset: bool = True
) -> RDFGraph:
    """Evaluate a SPARQL CONSTRUCT against the active partition."""
    async with self._lock:
        partition = self._active_partition()
        store = partition.ontologies if use_ontologies_dataset else partition.facts
    self._construct_queries += 1
    return await asyncio.to_thread(_run_construct, store, query)

afetch_ontologies_by_iri(iris) async

Materialize only the named graphs backing iris.

Source code in ontocast/tool/triple_manager/in_memory.py
async def afetch_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
    """Materialize only the named graphs backing ``iris``."""
    if not iris:
        return await self.afetch_ontologies()
    wanted = set(iris)
    headers = dedupe_terminal_ontologies(await self.afetch_ontology_catalog())
    graph_uris = [header.graph_uri for header in headers if header.iri in wanted]
    if not graph_uris:
        return []
    return await asyncio.to_thread(self._materialize_graphs, graph_uris)

afetch_ontology_catalog() async

Read one header per stored ontology version via a single SELECT.

Source code in ontocast/tool/triple_manager/in_memory.py
async def afetch_ontology_catalog(self) -> list[OntologyHeader]:
    """Read one header per stored ontology version via a single SELECT."""
    rows = await self.aselect(ONTOLOGY_HEADER_QUERY)
    return headers_from_select_rows(rows)

aselect(query, *, use_ontologies_dataset=True) async

Evaluate a SPARQL SELECT against the active partition.

Source code in ontocast/tool/triple_manager/in_memory.py
async def aselect(
    self, query: str, *, use_ontologies_dataset: bool = True
) -> list[dict[str, str]]:
    """Evaluate a SPARQL SELECT against the active partition."""
    async with self._lock:
        partition = self._active_partition()
        store = partition.ontologies if use_ontologies_dataset else partition.facts
    self._select_queries += 1
    return await asyncio.to_thread(_run_select, store, query)

catalog_io_stats()

Counters for catalog I/O, for tests and diagnostics.

Source code in ontocast/tool/triple_manager/in_memory.py
def catalog_io_stats(self) -> dict[str, int]:
    """Counters for catalog I/O, for tests and diagnostics."""
    return {
        "full_catalog_fetches": self._full_catalog_fetches,
        "graph_fetches": self._graph_fetches,
        "select_queries": self._select_queries,
        "construct_queries": self._construct_queries,
    }

LLMTool

Bases: Tool

Tool for interacting with language models.

This class provides a unified interface for working with different language model providers (OpenAI, Ollama, Anthropic, Google) through LangChain. It supports both synchronous and asynchronous operations.

Attributes:

Name Type Description
config LLMConfig

LLMConfig object containing all LLM settings.

cache Any

Cacher instance for caching LLM responses.

Source code in ontocast/tool/llm.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
class LLMTool(Tool):
    """Tool for interacting with language models.

    This class provides a unified interface for working with different language model
    providers (OpenAI, Ollama, Anthropic, Google) through LangChain. It supports both
    synchronous and
    asynchronous operations.

    Attributes:
        config: LLMConfig object containing all LLM settings.
        cache: Cacher instance for caching LLM responses.
    """

    config: LLMConfig = Field(default_factory=LLMConfig)
    cache: Any = Field(default=None, exclude=True)
    budget_tracker: Any = Field(default=None, exclude=True)
    _cache_hits: int = PrivateAttr(default=0)
    _cache_misses: int = PrivateAttr(default=0)

    def __init__(
        self,
        cache: Cacher | None = None,
        budget_tracker: Any = None,
        **kwargs,
    ):
        """Initialize the LLM tool.

        Args:
            cache: Optional shared Cacher instance. If None, creates a new one.
            budget_tracker: Optional budget tracker instance for usage statistics.
            **kwargs: Additional keyword arguments passed to the parent class.
        """
        super().__init__(**kwargs)
        self._llm = None
        self.budget_tracker = budget_tracker

        # Initialize cache - use shared cacher or create new one
        if cache is not None:
            self.cache = ToolCacher(cache, LLM_CACHE_SUBDIR)
        else:
            # Standalone use (CLI helpers, direct library use): fall back to a
            # private Cacher on the configured/default directory.
            shared_cache = Cacher()
            self.cache = ToolCacher(shared_cache, LLM_CACHE_SUBDIR)

    @classmethod
    def create(
        cls,
        config: LLMConfig,
        cache: Cacher | None = None,
        budget_tracker: Any = None,
        **kwargs,
    ):
        """Create a new LLM tool instance synchronously.

        Args:
            config: LLMConfig object containing LLM settings.
            cache: Optional shared Cacher instance.
            budget_tracker: Optional budget tracker instance for usage statistics.
            **kwargs: Additional keyword arguments for initialization.

        Returns:
            LLMTool: A new instance of the LLM tool.

        Raises:
            RuntimeError: If called from inside a running event loop; use
                :meth:`acreate` there.
        """
        require_no_running_loop("LLMTool.create", "LLMTool.acreate")
        return asyncio.run(
            cls.acreate(
                config=config, cache=cache, budget_tracker=budget_tracker, **kwargs
            )
        )

    @classmethod
    async def acreate(
        cls,
        config: LLMConfig,
        cache: Cacher | None = None,
        budget_tracker: Any = None,
        **kwargs,
    ):
        """Create a new LLM tool instance asynchronously.

        Args:
            config: LLMConfig object containing LLM settings.
            cache: Optional shared Cacher instance.
            budget_tracker: Optional budget tracker instance for usage statistics.
            **kwargs: Additional keyword arguments for initialization.

        Returns:
            LLMTool: A new instance of the LLM tool.
        """
        # Create and initialize the instance with the config
        self = cls(config=config, cache=cache, budget_tracker=budget_tracker, **kwargs)
        await self.setup()
        return self

    async def setup(self):
        """Set up the language model based on the configured provider.

        Raises:
            ValueError: If the provider is not supported.
        """
        if self.config.provider == LLMProvider.OPENAI:
            if self.config.model_name.startswith("gpt-5"):
                self.config.temperature = 1.0
                logger.warning(
                    f"Setting temperature to {self.config.temperature} for gpt-5 class "
                    f"model {self.config.model_name}"
                )
            ChatOpenAI = require(
                "langchain_openai", feature="The OpenAI LLM provider"
            ).ChatOpenAI
            self._llm = ChatOpenAI(
                model=self.config.model_name,
                temperature=self.config.temperature,
                base_url=self.config.base_url,
                api_key=(
                    SecretStr(self.config.api_key) if self.config.api_key else None
                ),
            )
        elif self.config.provider == LLMProvider.OLLAMA:
            ollama_kwargs: dict[str, Any] = {
                "model": self.config.model_name,
                "base_url": self.config.base_url,
                "temperature": self.config.temperature,
            }
            if self.config.think is not None:
                ollama_kwargs["reasoning"] = self.config.think
            if self.config.num_predict is not None:
                ollama_kwargs["num_predict"] = self.config.num_predict
            if self.config.num_ctx is not None:
                ollama_kwargs["num_ctx"] = self.config.num_ctx
            ChatOllama = require(
                "langchain_ollama", feature="The Ollama LLM provider"
            ).ChatOllama
            self._llm = ChatOllama(**ollama_kwargs)
        elif self.config.provider == LLMProvider.ANTHROPIC:
            anthropic_kwargs: dict[str, Any] = {
                "model": self.config.model_name,
                "temperature": self.config.temperature,
            }
            if self.config.api_key:
                anthropic_kwargs["anthropic_api_key"] = SecretStr(self.config.api_key)
            if self.config.base_url:
                anthropic_kwargs["anthropic_api_url"] = self.config.base_url
            ChatAnthropic = require(
                "langchain_anthropic", feature="The Anthropic LLM provider"
            ).ChatAnthropic
            self._llm = ChatAnthropic(**anthropic_kwargs)
        elif self.config.provider == LLMProvider.GOOGLE:
            ChatGoogleGenerativeAI = require(
                "langchain_google_genai", feature="The Google LLM provider"
            ).ChatGoogleGenerativeAI
            self._llm = ChatGoogleGenerativeAI(
                model=self.config.model_name,
                temperature=self.config.temperature,
                google_api_key=self.config.api_key,
            )
        else:
            raise ValueError(f"Unsupported provider: {self.config.provider}")

    def _cache_config_dict(self, **extra: Any) -> dict[str, Any]:
        """Cache-key config for this tool's settings; see :func:`llm_cache_config`."""
        return dict(llm_cache_config(self.config, **extra))

    def _cache_key_content(self, *args: Any) -> str:
        """Stable string for disk cache keys from invoke arguments."""
        if not args:
            return ""
        primary = self._prompt_to_string(args[0])
        if len(args) == 1:
            return primary
        extra = [self._prompt_to_string(arg) for arg in args[1:]]
        return primary + "\n---\n" + "\n---\n".join(extra)

    def _current_budget_tracker(self) -> Any:
        """Tracker for the running task, falling back to the instance default.

        The context-local tracker wins so parallel unit workers charge their own
        budgets; ``self.budget_tracker`` remains for direct library use of a
        single ``LLMTool``.
        """
        scoped = _active_budget_tracker.get()
        return scoped if scoped is not None else self.budget_tracker

    def _record_cache_hit(
        self, prompt_str: str, content_str: str, usage: TokenUsage | None
    ) -> None:
        self._cache_hits += 1
        bt = self._current_budget_tracker()
        if bt is not None:
            bt.add_cache_hit(len(prompt_str), len(content_str), usage=usage)

    def record_span(self, name: str, seconds: float) -> None:
        """Charge a latency span to this call's budget tracker.

        Uses the same context-local tracker as usage accounting, so per-unit
        attribution under ``asyncio.gather`` is correct for free, and falls back
        to this tool's own tracker for direct library use. Callers without an
        :class:`LLMTool` instance should use :func:`record_active_span`.

        Args:
            name: Duration key, e.g. ``"llm/provider"``.
            seconds: Elapsed seconds to accumulate.
        """
        bt = self._current_budget_tracker()
        if bt is not None:
            bt.add_duration(name, seconds)

    def _record_api_usage(self, prompt_str: str, result: Any) -> None:
        self._cache_misses += 1
        bt = self._current_budget_tracker()
        if bt is None:
            return
        bt.add_usage(
            len(prompt_str),
            _chars_received_from_result(result),
            usage=_usage_from_llm_result(result),
        )

    def get_cache_stats(
        self, include_disk: bool = True
    ) -> dict[str, int | dict[str, int | dict[str, int] | dict[str, dict[str, int]]]]:
        """Return in-memory hit/miss counters and, optionally, on-disk file stats.

        Args:
            include_disk: Whether to walk the cache directory. The walk stats
                every file, so callers on a hot path (or on an event loop)
                should pass False or use :meth:`aget_cache_stats`.
        """
        stats: dict[
            str, int | dict[str, int | dict[str, int] | dict[str, dict[str, int]]]
        ] = {
            "cache_hits": self._cache_hits,
            "cache_misses": self._cache_misses,
        }
        if include_disk:
            stats["disk"] = self.cache.get_cache_stats()
        return stats

    async def aget_cache_stats(
        self,
    ) -> dict[str, int | dict[str, int | dict[str, int] | dict[str, dict[str, int]]]]:
        """Async :meth:`get_cache_stats`, with the directory walk off the loop."""
        stats = self.get_cache_stats(include_disk=False)
        stats["disk"] = await asyncio.to_thread(self.cache.get_cache_stats)
        return stats

    async def _invoke_cached(
        self,
        *args: Any,
        cache_config_extra: dict[str, Any] | None = None,
        **kwds: Any,
    ) -> AIMessage:
        """Invoke the LLM with optional disk cache and global in-flight limiting.

        This is the single cache-aware entry point; :meth:`__call__`,
        :meth:`acall`, :meth:`complete`, and :meth:`extract` all route through
        it so that content normalisation, key construction, budget accounting,
        and in-flight limiting cannot drift apart between them.

        Args:
            *args: Positional arguments forwarded to the provider's ``ainvoke``.
                The first is treated as the prompt for keying and accounting.
            cache_config_extra: Extra cache-key discriminators beyond the LLM
                config (e.g. the structured-output schema name).
            **kwds: Keyword arguments forwarded to ``ainvoke`` and folded into
                the cache key.

        Returns:
            AIMessage: Response with content normalised to a plain string.
        """
        prompt_key = self._cache_key_content(*args)
        prompt_str = self._prompt_to_string(args[0]) if args else ""
        config_dict = self._cache_config_dict(**(cache_config_extra or {}))

        if self.config.cache_enabled:
            lookup_start = time.perf_counter()
            cached_response = await self.cache.aget(
                prompt_key, config=config_dict, **kwds
            )
            self.record_span("llm/cache_lookup", time.perf_counter() - lookup_start)
            if cached_response is not None:
                logger.debug("Cache hit: %s...", prompt_str[:50])
                entry = CachedResponse.model_validate(cached_response)
                self._record_cache_hit(prompt_str, entry.content, entry.usage)
                return AIMessage(
                    content=entry.content,
                    response_metadata=entry.response_metadata,
                    usage_metadata=(
                        _usage_metadata_from(entry.usage)
                        if entry.usage is not None
                        else None
                    ),
                )

        logger.debug("Cache miss, calling LLM: %s...", prompt_str[:50])

        # Three spans, because they have three different fixes: queueing behind
        # llm_max_inflight wants a higher cap, provider time wants a faster
        # model or fewer calls, and neither is visible in the node's wall clock.
        max_inflight = max(1, self.config.llm_max_inflight)
        wait_start = time.perf_counter()
        async with _inflight_semaphore(max_inflight):
            provider_start = time.perf_counter()
            self.record_span("llm/inflight_wait", provider_start - wait_start)
            timeout = self.config.request_timeout_seconds
            try:
                if timeout is None:
                    response = await self.llm.ainvoke(*args, **kwds)
                else:
                    response = await asyncio.wait_for(
                        self.llm.ainvoke(*args, **kwds), timeout=timeout
                    )
            except asyncio.TimeoutError as exc:
                bt = self._current_budget_tracker()
                if bt is not None:
                    bt.incr("llm/timeouts")
                # Re-raised as a plain error so the unit loop's handler treats
                # it as a failed render rather than a cancellation: letting a
                # bare TimeoutError escape asyncio.gather would abort the whole
                # fan-out and orphan its siblings.
                raise LLMRequestTimeoutError(
                    f"LLM request exceeded {timeout}s "
                    f"({self.config.provider}/{self.config.model_name})"
                ) from exc
            finally:
                self.record_span("llm/provider", time.perf_counter() - provider_start)

        bt = self._current_budget_tracker()
        if bt is not None:
            bt.incr("llm/calls_timed")
        self._record_api_usage(prompt_str, response)

        content_str = _content_to_str(response.content)
        response_metadata = getattr(response, "response_metadata", {}) or {}
        usage = _usage_from_llm_result(response)
        if self.config.cache_enabled and not self.config.cache_read_only:
            entry = CachedResponse(
                content=content_str,
                prompt=prompt_str,
                response_metadata=response_metadata,
                kwargs=kwds,
                usage=None if usage.is_empty() else usage,
            )
            await self.cache.aset(
                prompt_key, entry.model_dump(), config=config_dict, **kwds
            )

        return AIMessage(
            content=content_str,
            response_metadata=response_metadata,
            usage_metadata=_usage_metadata_from(usage),
        )

    async def __call__(self, *args: Any, **kwds: Any) -> Any:
        """Call the language model directly (asynchronous)."""
        return await self._invoke_cached(*args, **kwds)

    async def acall(self, *args: Any, **kwds: Any) -> Any:
        """Alias for :meth:`__call__`."""
        return await self._invoke_cached(*args, **kwds)

    @property
    def llm(self) -> BaseChatModel:
        """Get the underlying language model instance.

        Returns:
            BaseChatModel: The configured language model.

        Raises:
            RuntimeError: If the LLM has not been properly initialized.
        """
        if self._llm is None:
            raise RuntimeError(
                "LLM resource not properly initialized. Call setup() first."
            )
        return self._llm

    def _prompt_to_string(self, prompt) -> str:
        """Convert various prompt types to string for caching.

        Args:
            prompt: The prompt object (string, StringPromptValue, etc.)

        Returns:
            str: String representation of the prompt.
        """
        if isinstance(prompt, str):
            return prompt
        to_string = getattr(prompt, "to_string", None)
        if callable(to_string):
            return str(to_string())
        text_attr = getattr(prompt, "text", None)
        if isinstance(text_attr, str):
            return text_attr
        content_attr = getattr(prompt, "content", None)
        if content_attr is not None:
            return str(content_attr)
        return str(prompt)

    async def complete(self, prompt: str, **kwargs) -> str:
        """Generate a completion for the given prompt.

        Args:
            prompt: The prompt to complete.
            **kwargs: Forwarded to the provider and folded into the cache key.

        Returns:
            str: The response text, normalised from provider content blocks.
        """
        response = await self._invoke_cached(prompt, **kwargs)
        return _content_to_str(response.content)

    async def extract(self, prompt: str, output_schema: Type[T], **kwargs) -> T:
        """Extract structured data from the prompt according to a schema.

        Args:
            prompt: The prompt describing what to extract.
            output_schema: Pydantic model the response is parsed into.
            **kwargs: Forwarded to the provider and folded into the cache key.

        Returns:
            T: The parsed model instance.
        """
        parser = PydanticOutputParser(pydantic_object=output_schema)
        format_instructions = parser.get_format_instructions()

        # The format instructions embed the full JSON schema, so schema changes
        # already alter the key; the name is carried as an explicit
        # discriminator so entries stay attributable when inspected on disk.
        full_prompt = f"{prompt}\n\n{format_instructions}"
        response = await self._invoke_cached(
            full_prompt,
            cache_config_extra={"output_schema": output_schema.__name__},
            **kwargs,
        )
        return parser.parse(_content_to_str(response.content))

llm property

Get the underlying language model instance.

Returns:

Name Type Description
BaseChatModel BaseChatModel

The configured language model.

Raises:

Type Description
RuntimeError

If the LLM has not been properly initialized.

__call__(*args, **kwds) async

Call the language model directly (asynchronous).

Source code in ontocast/tool/llm.py
async def __call__(self, *args: Any, **kwds: Any) -> Any:
    """Call the language model directly (asynchronous)."""
    return await self._invoke_cached(*args, **kwds)

__init__(cache=None, budget_tracker=None, **kwargs)

Initialize the LLM tool.

Parameters:

Name Type Description Default
cache Cacher | None

Optional shared Cacher instance. If None, creates a new one.

None
budget_tracker Any

Optional budget tracker instance for usage statistics.

None
**kwargs

Additional keyword arguments passed to the parent class.

{}
Source code in ontocast/tool/llm.py
def __init__(
    self,
    cache: Cacher | None = None,
    budget_tracker: Any = None,
    **kwargs,
):
    """Initialize the LLM tool.

    Args:
        cache: Optional shared Cacher instance. If None, creates a new one.
        budget_tracker: Optional budget tracker instance for usage statistics.
        **kwargs: Additional keyword arguments passed to the parent class.
    """
    super().__init__(**kwargs)
    self._llm = None
    self.budget_tracker = budget_tracker

    # Initialize cache - use shared cacher or create new one
    if cache is not None:
        self.cache = ToolCacher(cache, LLM_CACHE_SUBDIR)
    else:
        # Standalone use (CLI helpers, direct library use): fall back to a
        # private Cacher on the configured/default directory.
        shared_cache = Cacher()
        self.cache = ToolCacher(shared_cache, LLM_CACHE_SUBDIR)

acall(*args, **kwds) async

Alias for :meth:__call__.

Source code in ontocast/tool/llm.py
async def acall(self, *args: Any, **kwds: Any) -> Any:
    """Alias for :meth:`__call__`."""
    return await self._invoke_cached(*args, **kwds)

acreate(config, cache=None, budget_tracker=None, **kwargs) async classmethod

Create a new LLM tool instance asynchronously.

Parameters:

Name Type Description Default
config LLMConfig

LLMConfig object containing LLM settings.

required
cache Cacher | None

Optional shared Cacher instance.

None
budget_tracker Any

Optional budget tracker instance for usage statistics.

None
**kwargs

Additional keyword arguments for initialization.

{}

Returns:

Name Type Description
LLMTool

A new instance of the LLM tool.

Source code in ontocast/tool/llm.py
@classmethod
async def acreate(
    cls,
    config: LLMConfig,
    cache: Cacher | None = None,
    budget_tracker: Any = None,
    **kwargs,
):
    """Create a new LLM tool instance asynchronously.

    Args:
        config: LLMConfig object containing LLM settings.
        cache: Optional shared Cacher instance.
        budget_tracker: Optional budget tracker instance for usage statistics.
        **kwargs: Additional keyword arguments for initialization.

    Returns:
        LLMTool: A new instance of the LLM tool.
    """
    # Create and initialize the instance with the config
    self = cls(config=config, cache=cache, budget_tracker=budget_tracker, **kwargs)
    await self.setup()
    return self

aget_cache_stats() async

Async :meth:get_cache_stats, with the directory walk off the loop.

Source code in ontocast/tool/llm.py
async def aget_cache_stats(
    self,
) -> dict[str, int | dict[str, int | dict[str, int] | dict[str, dict[str, int]]]]:
    """Async :meth:`get_cache_stats`, with the directory walk off the loop."""
    stats = self.get_cache_stats(include_disk=False)
    stats["disk"] = await asyncio.to_thread(self.cache.get_cache_stats)
    return stats

complete(prompt, **kwargs) async

Generate a completion for the given prompt.

Parameters:

Name Type Description Default
prompt str

The prompt to complete.

required
**kwargs

Forwarded to the provider and folded into the cache key.

{}

Returns:

Name Type Description
str str

The response text, normalised from provider content blocks.

Source code in ontocast/tool/llm.py
async def complete(self, prompt: str, **kwargs) -> str:
    """Generate a completion for the given prompt.

    Args:
        prompt: The prompt to complete.
        **kwargs: Forwarded to the provider and folded into the cache key.

    Returns:
        str: The response text, normalised from provider content blocks.
    """
    response = await self._invoke_cached(prompt, **kwargs)
    return _content_to_str(response.content)

create(config, cache=None, budget_tracker=None, **kwargs) classmethod

Create a new LLM tool instance synchronously.

Parameters:

Name Type Description Default
config LLMConfig

LLMConfig object containing LLM settings.

required
cache Cacher | None

Optional shared Cacher instance.

None
budget_tracker Any

Optional budget tracker instance for usage statistics.

None
**kwargs

Additional keyword arguments for initialization.

{}

Returns:

Name Type Description
LLMTool

A new instance of the LLM tool.

Raises:

Type Description
RuntimeError

If called from inside a running event loop; use :meth:acreate there.

Source code in ontocast/tool/llm.py
@classmethod
def create(
    cls,
    config: LLMConfig,
    cache: Cacher | None = None,
    budget_tracker: Any = None,
    **kwargs,
):
    """Create a new LLM tool instance synchronously.

    Args:
        config: LLMConfig object containing LLM settings.
        cache: Optional shared Cacher instance.
        budget_tracker: Optional budget tracker instance for usage statistics.
        **kwargs: Additional keyword arguments for initialization.

    Returns:
        LLMTool: A new instance of the LLM tool.

    Raises:
        RuntimeError: If called from inside a running event loop; use
            :meth:`acreate` there.
    """
    require_no_running_loop("LLMTool.create", "LLMTool.acreate")
    return asyncio.run(
        cls.acreate(
            config=config, cache=cache, budget_tracker=budget_tracker, **kwargs
        )
    )

extract(prompt, output_schema, **kwargs) async

Extract structured data from the prompt according to a schema.

Parameters:

Name Type Description Default
prompt str

The prompt describing what to extract.

required
output_schema Type[T]

Pydantic model the response is parsed into.

required
**kwargs

Forwarded to the provider and folded into the cache key.

{}

Returns:

Name Type Description
T T

The parsed model instance.

Source code in ontocast/tool/llm.py
async def extract(self, prompt: str, output_schema: Type[T], **kwargs) -> T:
    """Extract structured data from the prompt according to a schema.

    Args:
        prompt: The prompt describing what to extract.
        output_schema: Pydantic model the response is parsed into.
        **kwargs: Forwarded to the provider and folded into the cache key.

    Returns:
        T: The parsed model instance.
    """
    parser = PydanticOutputParser(pydantic_object=output_schema)
    format_instructions = parser.get_format_instructions()

    # The format instructions embed the full JSON schema, so schema changes
    # already alter the key; the name is carried as an explicit
    # discriminator so entries stay attributable when inspected on disk.
    full_prompt = f"{prompt}\n\n{format_instructions}"
    response = await self._invoke_cached(
        full_prompt,
        cache_config_extra={"output_schema": output_schema.__name__},
        **kwargs,
    )
    return parser.parse(_content_to_str(response.content))

get_cache_stats(include_disk=True)

Return in-memory hit/miss counters and, optionally, on-disk file stats.

Parameters:

Name Type Description Default
include_disk bool

Whether to walk the cache directory. The walk stats every file, so callers on a hot path (or on an event loop) should pass False or use :meth:aget_cache_stats.

True
Source code in ontocast/tool/llm.py
def get_cache_stats(
    self, include_disk: bool = True
) -> dict[str, int | dict[str, int | dict[str, int] | dict[str, dict[str, int]]]]:
    """Return in-memory hit/miss counters and, optionally, on-disk file stats.

    Args:
        include_disk: Whether to walk the cache directory. The walk stats
            every file, so callers on a hot path (or on an event loop)
            should pass False or use :meth:`aget_cache_stats`.
    """
    stats: dict[
        str, int | dict[str, int | dict[str, int] | dict[str, dict[str, int]]]
    ] = {
        "cache_hits": self._cache_hits,
        "cache_misses": self._cache_misses,
    }
    if include_disk:
        stats["disk"] = self.cache.get_cache_stats()
    return stats

record_span(name, seconds)

Charge a latency span to this call's budget tracker.

Uses the same context-local tracker as usage accounting, so per-unit attribution under asyncio.gather is correct for free, and falls back to this tool's own tracker for direct library use. Callers without an :class:LLMTool instance should use :func:record_active_span.

Parameters:

Name Type Description Default
name str

Duration key, e.g. "llm/provider".

required
seconds float

Elapsed seconds to accumulate.

required
Source code in ontocast/tool/llm.py
def record_span(self, name: str, seconds: float) -> None:
    """Charge a latency span to this call's budget tracker.

    Uses the same context-local tracker as usage accounting, so per-unit
    attribution under ``asyncio.gather`` is correct for free, and falls back
    to this tool's own tracker for direct library use. Callers without an
    :class:`LLMTool` instance should use :func:`record_active_span`.

    Args:
        name: Duration key, e.g. ``"llm/provider"``.
        seconds: Elapsed seconds to accumulate.
    """
    bt = self._current_budget_tracker()
    if bt is not None:
        bt.add_duration(name, seconds)

setup() async

Set up the language model based on the configured provider.

Raises:

Type Description
ValueError

If the provider is not supported.

Source code in ontocast/tool/llm.py
async def setup(self):
    """Set up the language model based on the configured provider.

    Raises:
        ValueError: If the provider is not supported.
    """
    if self.config.provider == LLMProvider.OPENAI:
        if self.config.model_name.startswith("gpt-5"):
            self.config.temperature = 1.0
            logger.warning(
                f"Setting temperature to {self.config.temperature} for gpt-5 class "
                f"model {self.config.model_name}"
            )
        ChatOpenAI = require(
            "langchain_openai", feature="The OpenAI LLM provider"
        ).ChatOpenAI
        self._llm = ChatOpenAI(
            model=self.config.model_name,
            temperature=self.config.temperature,
            base_url=self.config.base_url,
            api_key=(
                SecretStr(self.config.api_key) if self.config.api_key else None
            ),
        )
    elif self.config.provider == LLMProvider.OLLAMA:
        ollama_kwargs: dict[str, Any] = {
            "model": self.config.model_name,
            "base_url": self.config.base_url,
            "temperature": self.config.temperature,
        }
        if self.config.think is not None:
            ollama_kwargs["reasoning"] = self.config.think
        if self.config.num_predict is not None:
            ollama_kwargs["num_predict"] = self.config.num_predict
        if self.config.num_ctx is not None:
            ollama_kwargs["num_ctx"] = self.config.num_ctx
        ChatOllama = require(
            "langchain_ollama", feature="The Ollama LLM provider"
        ).ChatOllama
        self._llm = ChatOllama(**ollama_kwargs)
    elif self.config.provider == LLMProvider.ANTHROPIC:
        anthropic_kwargs: dict[str, Any] = {
            "model": self.config.model_name,
            "temperature": self.config.temperature,
        }
        if self.config.api_key:
            anthropic_kwargs["anthropic_api_key"] = SecretStr(self.config.api_key)
        if self.config.base_url:
            anthropic_kwargs["anthropic_api_url"] = self.config.base_url
        ChatAnthropic = require(
            "langchain_anthropic", feature="The Anthropic LLM provider"
        ).ChatAnthropic
        self._llm = ChatAnthropic(**anthropic_kwargs)
    elif self.config.provider == LLMProvider.GOOGLE:
        ChatGoogleGenerativeAI = require(
            "langchain_google_genai", feature="The Google LLM provider"
        ).ChatGoogleGenerativeAI
        self._llm = ChatGoogleGenerativeAI(
            model=self.config.model_name,
            temperature=self.config.temperature,
            google_api_key=self.config.api_key,
        )
    else:
        raise ValueError(f"Unsupported provider: {self.config.provider}")

OntologyManager

Bases: Tool

Manager for handling multiple ontologies with version tracking.

This class provides functionality for managing a collection of ontologies, tracking version lineage using hash-based identifiers. For each IRI, it maintains a tree/graph of all versions identified by their hashes.

Attributes:

Name Type Description
ontology_versions dict[str, list[Ontology]]

Dictionary mapping IRI to list of all ontology versions (identified by hash). Each IRI can have multiple versions forming a lineage tree.

Source code in ontocast/tool/ontology_manager.py
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
class OntologyManager(Tool):
    """Manager for handling multiple ontologies with version tracking.

    This class provides functionality for managing a collection of ontologies,
    tracking version lineage using hash-based identifiers. For each IRI,
    it maintains a tree/graph of all versions identified by their hashes.

    Attributes:
        ontology_versions: Dictionary mapping IRI to list of all
            ontology versions (identified by hash). Each IRI can have
            multiple versions forming a lineage tree.
    """

    ontology_versions: dict[str, list[Ontology]] = Field(default_factory=dict)

    def __init__(self, **kwargs):
        """Initialize the ontology manager.

        Args:
            **kwargs: Additional keyword arguments passed to the parent class.
        """
        super().__init__(**kwargs)
        # Cache dictionary mapping IRI to hash of freshest terminal ontology.
        # Updated incrementally when ontologies are added.
        self._cached_ontologies: dict[str, str] = {}
        self._patch_retriever: OntologyPatchRetriever | None = None
        self._triple_store_manager: TripleStoreManager | None = None
        # Canonical short handle per IRI (ontology_id); prefix may differ.
        self._iri_to_ontology_id: dict[str, str] = {}
        # Lowercased alias (ontology_id, author prefix, …) → IRI.
        self._alias_to_iri: dict[str, str] = {}
        # Preferred author prefix per namespace URI (for sanitize preference).
        self._namespace_to_author_prefix: dict[str, str] = {}
        # Content-addressed caches. An entry can never go stale on read: a
        # concurrent writer produces a *new* key, which is a miss, never an
        # incorrect hit. Both are bounded -- they hold whole rdflib graphs, and
        # a long-lived server would otherwise grow without limit.
        #
        # _graph_cache is keyed by the header's ``graph_uri`` (see
        # :meth:`_cache_graph`), *not* by ``versioned_iri``: the two coincide
        # only while content hashing is round-trip stable. Eviction must use the
        # same key, so the graph URI each IRI was cached under is tracked here.
        self._graph_cache: OrderedDict[str, Ontology] = OrderedDict()
        self._graph_uris_by_iri: dict[str, set[str]] = {}
        self._merged_cache: OrderedDict[
            frozenset[str], tuple[RDFGraph, dict[str, str]]
        ] = OrderedDict()
        self._graph_cache_hits = 0
        self._graph_cache_misses = 0
        self._merged_cache_hits = 0
        self._merged_cache_misses = 0

    @staticmethod
    def _primary_ontology_id(ontology: Ontology) -> str:
        identity = (ontology.ontology_id or "").strip().lower()
        if not identity:
            raise ValueError(
                "Ontology identity is missing: ontology_id is required for catalog registration"
            )
        return identity

    def _collect_aliases(self, ontology: Ontology) -> list[tuple[str, str]]:
        """Collect ``(alias, kind)`` pairs; kind is ``ontology_id`` or ``prefix``.

        When ``ontology_id`` and author prefix coincide, the alias keeps the
        stricter ``ontology_id`` kind.
        """
        aliases: list[tuple[str, str]] = []
        seen: set[str] = set()
        for candidate, kind in (
            (ontology.ontology_id, "ontology_id"),
            (ontology.prefix, "prefix"),
        ):
            if not candidate:
                continue
            cleaned = candidate.strip().lower()
            if cleaned and cleaned not in seen:
                seen.add(cleaned)
                aliases.append((cleaned, kind))
        return aliases

    def validate_identity_uniqueness(self, ontology: Ontology) -> None:
        """Validate catalog IRI and alias uniqueness across the manager.

        Same IRI may not change its primary ``ontology_id``. The same
        ``ontology_id`` alias may not point at two different IRIs. Author
        ``prefix`` may differ from ``ontology_id`` (both register as aliases of
        the same IRI); a *prefix* collision across IRIs does not block ingest —
        the colliding prefix alias is simply skipped at registration and the
        ontology stays addressable by IRI and ``ontology_id``.
        """
        iri = (ontology.iri or "").strip()
        if not iri:
            raise ValueError("Ontology IRI is missing")
        if iri == NULL_ONTOLOGY.iri:
            raise ValueError("Null ontology IRI cannot be registered")

        primary = self._primary_ontology_id(ontology)

        existing_primary = self._iri_to_ontology_id.get(iri)
        if existing_primary is not None and existing_primary != primary:
            raise ValueError(
                "Ontology identity conflict: IRI "
                f"'{iri}' is already bound to identity '{existing_primary}', "
                f"received '{primary}'"
            )

        for alias, kind in self._collect_aliases(ontology):
            existing_iri = self._alias_to_iri.get(alias)
            if existing_iri is None or existing_iri == iri:
                continue
            if kind == "prefix":
                # Convenience alias only; degrades to IRI-only addressing.
                continue
            raise ValueError(
                "Ontology identity conflict: identity "
                f"'{alias}' is already bound to IRI '{existing_iri}', "
                f"received '{iri}'"
            )

    def _register_identity(self, ontology: Ontology) -> None:
        iri = ontology.iri.strip()
        primary = self._primary_ontology_id(ontology)
        self._iri_to_ontology_id[iri] = primary
        for alias, _kind in self._collect_aliases(ontology):
            existing_iri = self._alias_to_iri.get(alias)
            if existing_iri is not None and existing_iri != iri:
                # validate_identity_uniqueness raises on ontology_id conflicts,
                # so only author-prefix aliases can reach this branch.
                logger.warning(
                    "Author prefix alias '%s' is already bound to IRI %s; "
                    "skipping alias registration for %s (addressable by IRI "
                    "and ontology_id only).",
                    alias,
                    existing_iri,
                    iri,
                )
                continue
            self._alias_to_iri[alias] = iri
        # Also allow looking up by the raw IRI string and its normalized form.
        self._alias_to_iri[iri.lower()] = iri
        normalized = normalize_ontology_iri(iri).lower()
        if normalized:
            self._alias_to_iri[normalized] = iri
        prefix = ontology.prefix
        if prefix and ontology.namespace:
            self._namespace_to_author_prefix[str(ontology.namespace)] = prefix

    def resolve_ontology_ref(self, ref: str) -> str | None:
        """Resolve an absolute IRI or registered alias to a catalog ontology IRI."""
        if not ref or not str(ref).strip():
            return None
        cleaned = str(ref).strip()
        if cleaned in self.ontology_versions:
            return cleaned
        normalized = normalize_ontology_iri(cleaned)
        if normalized in self.ontology_versions:
            return normalized
        for key in (cleaned.lower(), normalized.lower()):
            iri = self._alias_to_iri.get(key)
            if iri is not None:
                return iri
        return None

    def author_prefix_for_namespace(self, namespace: str) -> str | None:
        """Return the catalog-registered author prefix for a namespace, if any."""
        direct = self._namespace_to_author_prefix.get(namespace)
        if direct is not None:
            return direct
        stripped = namespace.rstrip("/#")
        for key, value in self._namespace_to_author_prefix.items():
            if key.rstrip("/#") == stripped:
                return value
        return None

    @property
    def preferred_namespace_prefixes(self) -> dict[str, str]:
        """Namespace URI → author prefix for sanitize preference."""
        return dict(self._namespace_to_author_prefix)

    def __contains__(self, item):
        """Check if an item (IRI or alias) is in the ontology manager.

        Args:
            item: The IRI, ontology_id, or author prefix to check.

        Returns:
            bool: True if the item resolves to a tracked ontology IRI.
        """
        return self.resolve_ontology_ref(str(item)) is not None

    def _prepare_ontology_for_catalog(self, ontology: Ontology) -> bool:
        """Validate and register ``ontology``; return True if a new hash was appended."""
        if not ontology.iri or ontology.iri == NULL_ONTOLOGY.iri:
            logger.warning(
                f"Cannot add ontology without valid IRI (ontology_id: {ontology.ontology_id})"
            )
            return False

        if not ontology.hash:
            logger.warning(f"Cannot add ontology without hash (IRI: {ontology.iri})")
            return False

        # Author @prefix names die at the triple-store boundary; persisting them
        # as sh:declare triples here (hash-neutral, idempotent) lets any later
        # export rebind them instead of inventing synthetic stem-derived names.
        ontology.graph.materialize_prefix_declarations(URIRef(ontology.iri))

        self.validate_identity_uniqueness(ontology)
        self._register_identity(ontology)

        if not ontology.created_at:
            ontology.created_at = datetime.now(timezone.utc)
            logger.debug(
                f"Set created_at for ontology {ontology.iri} with hash {ontology.hash[:8]}..."
            )

        if ontology.iri not in self.ontology_versions:
            self.ontology_versions[ontology.iri] = []

        existing_hashes = {o.hash for o in self.ontology_versions[ontology.iri]}
        if ontology.hash in existing_hashes:
            logger.debug(
                f"Ontology {ontology.iri} with hash {ontology.hash[:8]}... already exists"
            )
            return False

        self.ontology_versions[ontology.iri].append(ontology)
        freshest = self.get_freshest_terminal_ontology_by_iri(ontology.iri)
        if freshest and freshest.hash:
            self._cached_ontologies[ontology.iri] = freshest.hash
        logger.debug(f"Added ontology {ontology.iri} with hash {ontology.hash[:8]}...")
        return True

    def _reindex_ontology_sync(self, ontology: Ontology) -> None:
        """Sync vector reindex (caller must ensure no running event loop)."""
        if self._patch_retriever is None:
            return
        self._patch_retriever.vector_store.reindex_ontology(ontology)

    def _ensure_sync_reindex_allowed(self, *, skip_vector_index: bool) -> None:
        """Raise if sync reindex would block a running event loop."""
        if skip_vector_index or self._patch_retriever is None:
            return
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            return
        raise RuntimeError(
            "add_ontology() cannot reindex inside async code; use await aadd_ontology()"
        )

    async def _reindex_ontology_async(self, ontology: Ontology) -> None:
        if self._patch_retriever is None:
            return
        await asyncio.to_thread(
            self._patch_retriever.vector_store.reindex_ontology, ontology
        )

    def add_ontology(
        self, ontology: Ontology, *, skip_vector_index: bool = False
    ) -> None:
        """Add an ontology to the version tree for its IRI.

        If an ontology with the same hash already exists, it is not added again.
        Ensures that created_at is set if not already present.

        Args:
            ontology: The ontology to add.
            skip_vector_index: If True, do not call the vector store (caller
                already materialized embeddings, e.g. during ToolBox.initialize).

        Raises:
            RuntimeError: If vector reindex would run while an event loop is
                already active. Use :meth:`aadd_ontology` from async code.
        """
        self._ensure_sync_reindex_allowed(skip_vector_index=skip_vector_index)
        if not self._prepare_ontology_for_catalog(ontology):
            return
        if not skip_vector_index:
            self._reindex_ontology_sync(ontology)

    async def aadd_ontology(
        self, ontology: Ontology, *, skip_vector_index: bool = False
    ) -> None:
        """Async variant of :meth:`add_ontology` (reindex off the event loop)."""
        if not self._prepare_ontology_for_catalog(ontology):
            return
        if not skip_vector_index:
            await self._reindex_ontology_async(ontology)

    def remove_ontology_by_iri(self, iri: str) -> None:
        """Drop all tracked versions for an ontology IRI and clear caches."""
        # Evict under the key entries were *inserted* with. Popping
        # ``versioned_iri`` here -- as this did once -- silently missed every
        # entry whenever the recomputed hash differed from the stored graph URI,
        # leaving a removed ontology still resolvable from cache.
        for graph_uri in self._graph_uris_by_iri.pop(iri, set()):
            self._graph_cache.pop(graph_uri, None)
        for ontology in self.ontology_versions.get(iri, []):
            self._graph_cache.pop(ontology.versioned_iri, None)
        stale_merges = [
            key
            for key in self._merged_cache
            # An ontology with no hash falls back to the bare IRI as its
            # versioned IRI, so match that exactly as well as the `#hash` form.
            if any(
                versioned == iri or versioned.startswith(f"{iri}#") for versioned in key
            )
        ]
        for key in stale_merges:
            del self._merged_cache[key]
        self.ontology_versions.pop(iri, None)
        self._cached_ontologies.pop(iri, None)
        self._iri_to_ontology_id.pop(iri, None)
        # Drop all aliases pointing at this IRI.
        stale = [alias for alias, bound in self._alias_to_iri.items() if bound == iri]
        for alias in stale:
            del self._alias_to_iri[alias]
        # Drop author-prefix entries whose IRI matches (by scanning versions was already removed).
        # Namespace map is best-effort; rebuild from remaining ontologies.
        self._namespace_to_author_prefix = {}
        for versions in self.ontology_versions.values():
            if not versions:
                continue
            onto = versions[-1]
            if onto.prefix and onto.namespace:
                self._namespace_to_author_prefix[str(onto.namespace)] = onto.prefix

    def register_vector_store(self, retriever: "OntologyPatchRetriever") -> None:
        """Register a patch retriever for vector context lookups."""
        self._patch_retriever = retriever

    def register_triple_store(self, manager: TripleStoreManager | None) -> None:
        """Register the triple store this catalog reads through on a cache miss."""
        self._triple_store_manager = manager

    def reset_catalog(self) -> None:
        """Drop every tracked ontology, identity binding, and cached graph.

        Called when the active tenant/project changes: the catalog, the alias
        collision ledger, and the graph caches are all partition-scoped, and
        carrying them across a switch leaks one tenant's ontologies into another's
        requests.
        """
        self.ontology_versions.clear()
        self._cached_ontologies.clear()
        self._iri_to_ontology_id.clear()
        self._alias_to_iri.clear()
        self._namespace_to_author_prefix.clear()
        self._graph_cache.clear()
        self._graph_uris_by_iri.clear()
        self._merged_cache.clear()

    def _require_triple_store(self) -> TripleStoreManager:
        if self._triple_store_manager is None:
            raise RuntimeError(
                "OntologyManager has no triple store registered; "
                "call register_triple_store() before reading the catalog"
            )
        return self._triple_store_manager

    async def aget_catalog_headers(self) -> list[OntologyHeader]:
        """Read ontology header metadata for every stored version.

        Deliberately **not** cached. Headers are what terminal-version selection
        runs on, so caching them would let this process miss another worker's
        writes to a shared store -- the one thing the graph cache cannot go wrong
        about, and the one thing this would.

        Returns:
            list[OntologyHeader]: One header per stored ontology version.
        """
        return await self._require_triple_store().afetch_ontology_catalog()

    async def aget_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
        """Return terminal ontologies for ``iris``, fetching only cache misses.

        Terminal selection always runs against freshly read headers; only the
        graph bytes come from cache, keyed by the content-addressed
        ``versioned_iri``.

        Args:
            iris: Ontology IRIs to resolve. Empty means "no restriction", matching
                :meth:`~ontocast.tool.triple_manager.core.TripleStoreManager.afetch_ontologies_by_iri`.

        Returns:
            list[Ontology]: Terminal ontologies with graphs. Callers must treat
            these as shared read-only references.
        """
        store = self._require_triple_store()
        headers = dedupe_terminal_ontologies(await self.aget_catalog_headers())
        if iris:
            wanted = set(iris)
            headers = [header for header in headers if header.iri in wanted]

        resolved: list[Ontology] = []
        missing_iris: list[str] = []
        graph_uri_by_iri: dict[str, str] = {}
        for header in headers:
            cached = self._graph_cache.get(header.graph_uri)
            if cached is not None:
                self._graph_cache_hits += 1
                self._graph_cache.move_to_end(header.graph_uri)
                resolved.append(cached)
            else:
                self._graph_cache_misses += 1
                missing_iris.append(header.iri)
                graph_uri_by_iri[header.iri] = header.graph_uri

        if missing_iris:
            fetched = await store.afetch_ontologies_by_iri(missing_iris)
            for ontology in fetched:
                self._cache_graph(ontology, graph_uri_by_iri.get(ontology.iri))
            resolved.extend(fetched)
        return resolved

    async def aget_merged_graph(
        self, ontologies: Sequence[Ontology]
    ) -> tuple[RDFGraph, dict[str, str]]:
        """Return the prefix-bound union of ``ontologies``, cached by version set.

        The induced-subgraph builder reads this union without mutating it, so one
        merge can be shared by every content unit that selects the same ontology
        versions -- which is the common case inside a document.

        Args:
            ontologies: Ontology versions to merge.

        Returns:
            tuple: ``(merged_graph, prefix_map)``. The graph **must not be mutated
            by callers**; it is shared.
        """
        from .sparql import merge_ontology_graphs

        key = frozenset(onto.versioned_iri for onto in ontologies)
        cached = self._merged_cache.get(key)
        if cached is not None:
            self._merged_cache_hits += 1
            self._merged_cache.move_to_end(key)
            return cached

        self._merged_cache_misses += 1
        merged = await asyncio.to_thread(merge_ontology_graphs, list(ontologies))
        self._merged_cache[key] = merged
        while len(self._merged_cache) > _MERGED_CACHE_MAX_ENTRIES:
            self._merged_cache.popitem(last=False)
        return merged

    def catalog_cache_stats(self) -> dict[str, int]:
        """Cache hit/miss counters, for tests and retrieval diagnostics."""
        return {
            "catalog_graph_cache_hits": self._graph_cache_hits,
            "catalog_graph_cache_misses": self._graph_cache_misses,
            "catalog_merge_cache_hits": self._merged_cache_hits,
            "catalog_merge_cache_misses": self._merged_cache_misses,
        }

    def _cache_graph(self, ontology: Ontology, graph_uri: str | None = None) -> None:
        """Register a store-read ``ontology`` under the graph URI it was read from.

        Only ever called with graphs that came *from* the triple store. Seeding the
        cache from :meth:`add_ontology` instead would be tempting -- those graphs are
        already in memory -- but a registered ontology and its persisted form are not
        byte-identical: writing round-trips through deterministic Turtle, which
        relabels blank nodes. Snapshot expansion tie-breaks on ``str(triple)``, so
        mixing the two makes retrieval depend on whether a graph happened to be
        written by this process.

        The key must be the *header's* ``graph_uri``, since that is what
        :meth:`aget_ontologies_by_iri` looks up. Keying on the recomputed
        ``versioned_iri`` instead is only equivalent while content hashing is
        round-trip stable; when it is not, the two never coincide and every
        lookup misses forever.

        Args:
            ontology: Ontology materialized from the triple store.
            graph_uri: Named graph it was read from. Falls back to the
                content-addressed ``versioned_iri`` when the caller has no header.
        """
        key = graph_uri or (ontology.versioned_iri if ontology.hash else None)
        if not key:
            return
        self._graph_cache.setdefault(key, ontology)
        self._graph_cache.move_to_end(key)
        self._graph_uris_by_iri.setdefault(ontology.iri, set()).add(key)
        while len(self._graph_cache) > _GRAPH_CACHE_MAX_ENTRIES:
            evicted_key, evicted = self._graph_cache.popitem(last=False)
            uris = self._graph_uris_by_iri.get(evicted.iri)
            if uris is not None:
                uris.discard(evicted_key)
                if not uris:
                    self._graph_uris_by_iri.pop(evicted.iri, None)

    def _effective_patch_top_k(self, top_k: int | None) -> int:
        if top_k is not None:
            return top_k
        if self._patch_retriever is not None:
            return self._patch_retriever.vector_store.store_config.top_k
        return 10

    def _fallback_patch_results(
        self, queries: list[str]
    ) -> list[tuple[RDFGraph | None, list[str]]]:
        """Per-query independent copies of the freshest terminal ontology graph."""
        fallback = self.get_freshest_terminal_ontology_by_iri(None)
        if fallback is None:
            return [(None, []) for _ in queries]
        sources = [fallback.iri]
        return [(fallback.graph.copy(), sources) for _ in queries]

    @staticmethod
    def _normalize_patch_graph(
        graph: RDFGraph, sources: list[str]
    ) -> tuple[RDFGraph, list[str]]:
        return (graph, sources) if len(graph) > 0 else (RDFGraph(), sources)

    def get_patch_context(
        self,
        query: str,
        top_k: int | None = None,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> RDFGraph | None:
        """Retrieve multi-ontology patch context for a query.

        Falls back to the freshest available ontology graph if vector retrieval
        is not configured or yields no atoms.
        """
        graph, _ = self.get_patch_context_with_sources(
            query=query,
            top_k=top_k,
            subgraph_depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
        )
        return graph

    async def aget_patch_context(
        self,
        query: str,
        top_k: int | None = None,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> RDFGraph | None:
        """Async variant of :meth:`get_patch_context`."""
        graph, _ = await self.aget_patch_context_with_sources(
            query=query,
            top_k=top_k,
            subgraph_depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
        )
        return graph

    def get_patch_context_with_sources(
        self,
        query: str,
        top_k: int | None = None,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> tuple[RDFGraph | None, list[str]]:
        """Retrieve patch context and contributing ontology IRIs."""
        results = self.get_patch_contexts_with_sources(
            queries=[query],
            top_k=top_k,
            subgraph_depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
        )
        if not results:
            return None, []
        return results[0]

    async def aget_patch_context_with_sources(
        self,
        query: str,
        top_k: int | None = None,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> tuple[RDFGraph | None, list[str]]:
        """Async variant of :meth:`get_patch_context_with_sources`."""
        results = await self.aget_patch_contexts_with_sources(
            queries=[query],
            top_k=top_k,
            subgraph_depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
        )
        if not results:
            return None, []
        return results[0]

    def get_patch_contexts_with_sources(
        self,
        queries: list[str],
        top_k: int | None = None,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> list[tuple[RDFGraph | None, list[str]]]:
        """Retrieve patch contexts for many queries in a batched pass.

        With a patch retriever, the list has length 1 (ensemble graph + sources).
        Without it, length matches ``queries`` (fallback ontology per query).

        Raises:
            RuntimeError: If called while an event loop is running. Use
                :meth:`aget_patch_contexts_with_sources` from async code.
        """
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            return asyncio.run(
                self.aget_patch_contexts_with_sources(
                    queries=queries,
                    top_k=top_k,
                    subgraph_depth=subgraph_depth,
                    max_total_triples=max_total_triples,
                    estimated_triples_per_query=estimated_triples_per_query,
                )
            )
        raise RuntimeError(
            "get_patch_contexts_with_sources() cannot be called from async code; "
            "use await aget_patch_contexts_with_sources()"
        )

    async def aget_patch_contexts_with_sources(
        self,
        queries: list[str],
        top_k: int | None = None,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> list[tuple[RDFGraph | None, list[str]]]:
        """Async patch retrieval (vector + induced subgraph) for many queries.

        With a patch retriever, returns a one-element list: a single induced graph for
        the union of hits over ``queries``, plus contributing ontology IRIs.
        """
        if not queries:
            return []
        if self._patch_retriever is not None:
            graph, sources = await self._patch_retriever.aretrieve_ensemble(
                queries=queries,
                top_k=self._effective_patch_top_k(top_k),
                subgraph_depth=subgraph_depth,
                max_total_triples=max_total_triples,
                estimated_triples_per_query=estimated_triples_per_query,
            )
            return [self._normalize_patch_graph(graph, sources)]

        return self._fallback_patch_results(queries)

    def get_terminal_ontologies_by_iri(self, iri: str | None = None) -> list[Ontology]:
        """Get terminal (leaf) ontologies in the version graph.

        Terminal ontologies are those that are not parents of any other ontology
        in the version tree. If iri is provided, returns terminals for
        that ontology only; otherwise returns terminals for all ontologies.

        Args:
            iri: Optional IRI to filter by.

        Returns:
            list[Ontology]: List of terminal ontologies.
        """
        if iri:
            if iri not in self.ontology_versions:
                return []
            ontologies = self.ontology_versions[iri]
        else:
            ontologies = [
                o for versions in self.ontology_versions.values() for o in versions
            ]

        if not ontologies:
            return []

        # Build a set of all parent hashes
        all_parent_hashes = set()
        for o in ontologies:
            all_parent_hashes.update(o.parent_hashes)

        # Terminal nodes are those whose hash is not in any parent_hashes
        terminal_hashes = {o.hash for o in ontologies} - all_parent_hashes

        return [o for o in ontologies if o.hash in terminal_hashes]

    def get_terminal_ontologies(self, ontology_id: str | None = None) -> list[Ontology]:
        """Get terminal (leaf) ontologies by ontology_id or alias.

        Args:
            ontology_id: Optional ontology_id / alias / IRI to filter by.

        Returns:
            list[Ontology]: List of terminal ontologies.
        """
        if ontology_id:
            iri = self.resolve_ontology_ref(ontology_id)
            if iri is None:
                return []
            return self.get_terminal_ontologies_by_iri(iri)
        return self.get_terminal_ontologies_by_iri(None)

    def get_freshest_terminal_ontology_by_iri(
        self, iri: str | None = None
    ) -> Ontology | None:
        """Get the freshest terminal ontology based on created_at timestamp.

        Returns the terminal ontology with the most recent `created_at` timestamp.
        If multiple terminal ontologies exist, returns the one that was most recently
        created. If no created_at is set, falls back to the first terminal ontology.

        Args:
            iri: Optional IRI to filter by. If None, searches across
                all ontologies.

        Returns:
            Ontology: The freshest terminal ontology, or None if no terminal
                ontologies exist.
        """
        terminals = self.get_terminal_ontologies_by_iri(iri)

        if not terminals:
            return None

        # Filter out ontologies without created_at and sort by created_at
        with_timestamp = [o for o in terminals if o.created_at is not None]
        without_timestamp = [o for o in terminals if o.created_at is None]

        if with_timestamp:
            # Sort by created_at descending (most recent first)
            freshest = max(
                with_timestamp,
                key=lambda o: cast(datetime, o.created_at),
            )
            return freshest
        elif without_timestamp:
            # Fallback to first terminal if no timestamps available
            return without_timestamp[0]

        return None

    def get_freshest_terminal_ontology(
        self, ontology_id: str | None = None
    ) -> Ontology | None:
        """Get the freshest terminal ontology by ontology_id, alias, or IRI.

        Args:
            ontology_id: Optional ontology_id / alias / IRI to filter by.

        Returns:
            Ontology: The freshest terminal ontology, or None if no terminal
                ontologies exist.
        """
        if ontology_id:
            iri = self.resolve_ontology_ref(ontology_id)
            if iri is None:
                return None
            return self.get_freshest_terminal_ontology_by_iri(iri)
        return self.get_freshest_terminal_ontology_by_iri(None)

    def get_ontology_versions_by_iri(self, iri: str) -> list[Ontology]:
        """Get all versions of an ontology by IRI.

        Args:
            iri: The IRI to retrieve versions for.

        Returns:
            list[Ontology]: List of all versions of the ontology.
        """
        return self.ontology_versions.get(iri, [])

    def get_ontology_versions(self, ontology_id: str) -> list[Ontology]:
        """Get all versions of an ontology by ontology_id, alias, or IRI.

        Args:
            ontology_id: The ontology_id / alias / IRI to retrieve versions for.

        Returns:
            list[Ontology]: List of all versions of the ontology.
        """
        iri = self.resolve_ontology_ref(ontology_id)
        if iri is None:
            return []
        return self.get_ontology_versions_by_iri(iri)

    def get_lineage_graph_by_iri(self, iri: str):
        """Get the lineage graph for a specific IRI.

        Args:
            iri: The IRI to get the lineage graph for.

        Returns:
            networkx.DiGraph: The lineage graph for the ontology, or None if not found.
        """
        if iri not in self.ontology_versions:
            return None

        return Ontology.build_lineage_graph(self.ontology_versions[iri])

    def get_lineage_graph(self, ontology_id: str):
        """Get the lineage graph for a specific ontology_id, alias, or IRI.

        Args:
            ontology_id: The ontology_id / alias / IRI to get the lineage graph for.

        Returns:
            networkx.DiGraph: The lineage graph for the ontology, or None if not found.
        """
        iri = self.resolve_ontology_ref(ontology_id)
        if iri is None:
            return None
        return self.get_lineage_graph_by_iri(iri)

    def get_ontology(
        self,
        ontology_id: str | None = None,
        ontology_iri: str | None = None,
        hash: str | None = None,
    ) -> Ontology:
        """Get an ontology by its IRI, ontology_id/alias, or hash.

        If hash is provided, returns the specific version. Otherwise, returns
        a terminal (most recent) version if multiple versions exist.
        IRI is preferred over ontology_id for lookup.

        Args:
            ontology_id: Short name, author prefix, or IRI (optional).
            ontology_iri: The IRI of the ontology to retrieve (preferred).
            hash: The hash of a specific version to retrieve (optional).

        Returns:
            Ontology: The matching ontology if found, NULL_ONTOLOGY otherwise.
        """
        # If hash is provided, search by hash first
        if hash:
            for versions in self.ontology_versions.values():
                for o in versions:
                    if o.hash == hash:
                        return o

        resolved_iri: str | None = None
        if ontology_iri is not None:
            resolved_iri = self.resolve_ontology_ref(ontology_iri)
        if resolved_iri is None and ontology_id is not None:
            resolved_iri = self.resolve_ontology_ref(ontology_id)

        if resolved_iri is not None and resolved_iri in self.ontology_versions:
            versions = self.ontology_versions[resolved_iri]
            if hash:
                for o in versions:
                    if o.hash == hash:
                        return o
            else:
                terminals = self.get_terminal_ontologies_by_iri(resolved_iri)
                if terminals:
                    return terminals[0]
                if versions:
                    return versions[0]

            if (
                ontology_iri
                and ontology_id
                and self.resolve_ontology_ref(ontology_id) not in (None, resolved_iri)
            ):
                logger.warning(
                    "Ontology id '%s' resolves differently from IRI '%s'",
                    ontology_id,
                    ontology_iri,
                )

        return NULL_ONTOLOGY

    def get_ontology_iris(self) -> list[str]:
        """Get a list of all ontology IRIs.

        Returns:
            list[str]: List of ontology IRIs.
        """
        return list(self.ontology_versions.keys())

    def get_ontology_names(self) -> list[str]:
        """Return unique catalog ``ontology_id`` values currently tracked.

        Returns:
            list[str]: Sorted unique ontology short names.
        """
        names = set()
        for versions in self.ontology_versions.values():
            for o in versions:
                if o.ontology_id:
                    names.add(o.ontology_id)
        return sorted(list(names))

    @property
    def has_ontologies(self) -> bool:
        """Check if there are any ontologies available.

        Returns:
            bool: True if there are any ontologies, False otherwise.
        """
        return len(self._cached_ontologies) > 0 or len(self.ontology_versions) > 0

    @property
    def ontologies(self) -> list[Ontology]:
        """Return the freshest terminal ontology for each catalog IRI.

        The result is cached per IRI (as hashes) and updated incrementally
        when ontologies are added.

        Returns:
            list[Ontology]: List of freshest terminal ontologies, one per IRI.
        """
        result = []

        # Ensure cache is up to date for all IRIs
        for iri in self.ontology_versions.keys():
            if iri not in self._cached_ontologies:
                freshest = self.get_freshest_terminal_ontology_by_iri(iri)
                if freshest and freshest.hash:
                    self._cached_ontologies[iri] = freshest.hash

        # Remove entries for IRIs that no longer exist
        cached_iris = set(self._cached_ontologies.keys())
        current_iris = set(self.ontology_versions.keys())
        for removed_iri in cached_iris - current_iris:
            del self._cached_ontologies[removed_iri]

        # Look up actual ontology objects by hash
        for iri, cached_hash in self._cached_ontologies.items():
            if iri in self.ontology_versions:
                # Find ontology with matching hash
                for ontology in self.ontology_versions[iri]:
                    if ontology.hash == cached_hash:
                        result.append(ontology)
                        break

        return result

    def update_ontology(self, ontology_id: str, ontology_addendum: RDFGraph):
        """Update an existing ontology with additional triples.

        Note: This method is deprecated. Use add_ontology() with a new version
        that has the current hash in parent_hashes instead.

        Args:
            ontology_id: The short name of the ontology to update.
            ontology_addendum: The RDF graph containing additional triples to add.
        """
        logger.warning(
            "update_ontology() is deprecated. Use add_ontology() with version tracking instead."
        )
        terminals = self.get_terminal_ontologies(ontology_id)
        if terminals:
            terminals[0] += ontology_addendum
            # Update cache for the IRI (though this method is deprecated)
            iri = terminals[0].iri
            freshest = self.get_freshest_terminal_ontology_by_iri(iri)
            if freshest and freshest.hash:
                self._cached_ontologies[iri] = freshest.hash

has_ontologies property

Check if there are any ontologies available.

Returns:

Name Type Description
bool bool

True if there are any ontologies, False otherwise.

ontologies property

Return the freshest terminal ontology for each catalog IRI.

The result is cached per IRI (as hashes) and updated incrementally when ontologies are added.

Returns:

Type Description
list[Ontology]

list[Ontology]: List of freshest terminal ontologies, one per IRI.

preferred_namespace_prefixes property

Namespace URI → author prefix for sanitize preference.

__contains__(item)

Check if an item (IRI or alias) is in the ontology manager.

Parameters:

Name Type Description Default
item

The IRI, ontology_id, or author prefix to check.

required

Returns:

Name Type Description
bool

True if the item resolves to a tracked ontology IRI.

Source code in ontocast/tool/ontology_manager.py
def __contains__(self, item):
    """Check if an item (IRI or alias) is in the ontology manager.

    Args:
        item: The IRI, ontology_id, or author prefix to check.

    Returns:
        bool: True if the item resolves to a tracked ontology IRI.
    """
    return self.resolve_ontology_ref(str(item)) is not None

__init__(**kwargs)

Initialize the ontology manager.

Parameters:

Name Type Description Default
**kwargs

Additional keyword arguments passed to the parent class.

{}
Source code in ontocast/tool/ontology_manager.py
def __init__(self, **kwargs):
    """Initialize the ontology manager.

    Args:
        **kwargs: Additional keyword arguments passed to the parent class.
    """
    super().__init__(**kwargs)
    # Cache dictionary mapping IRI to hash of freshest terminal ontology.
    # Updated incrementally when ontologies are added.
    self._cached_ontologies: dict[str, str] = {}
    self._patch_retriever: OntologyPatchRetriever | None = None
    self._triple_store_manager: TripleStoreManager | None = None
    # Canonical short handle per IRI (ontology_id); prefix may differ.
    self._iri_to_ontology_id: dict[str, str] = {}
    # Lowercased alias (ontology_id, author prefix, …) → IRI.
    self._alias_to_iri: dict[str, str] = {}
    # Preferred author prefix per namespace URI (for sanitize preference).
    self._namespace_to_author_prefix: dict[str, str] = {}
    # Content-addressed caches. An entry can never go stale on read: a
    # concurrent writer produces a *new* key, which is a miss, never an
    # incorrect hit. Both are bounded -- they hold whole rdflib graphs, and
    # a long-lived server would otherwise grow without limit.
    #
    # _graph_cache is keyed by the header's ``graph_uri`` (see
    # :meth:`_cache_graph`), *not* by ``versioned_iri``: the two coincide
    # only while content hashing is round-trip stable. Eviction must use the
    # same key, so the graph URI each IRI was cached under is tracked here.
    self._graph_cache: OrderedDict[str, Ontology] = OrderedDict()
    self._graph_uris_by_iri: dict[str, set[str]] = {}
    self._merged_cache: OrderedDict[
        frozenset[str], tuple[RDFGraph, dict[str, str]]
    ] = OrderedDict()
    self._graph_cache_hits = 0
    self._graph_cache_misses = 0
    self._merged_cache_hits = 0
    self._merged_cache_misses = 0

aadd_ontology(ontology, *, skip_vector_index=False) async

Async variant of :meth:add_ontology (reindex off the event loop).

Source code in ontocast/tool/ontology_manager.py
async def aadd_ontology(
    self, ontology: Ontology, *, skip_vector_index: bool = False
) -> None:
    """Async variant of :meth:`add_ontology` (reindex off the event loop)."""
    if not self._prepare_ontology_for_catalog(ontology):
        return
    if not skip_vector_index:
        await self._reindex_ontology_async(ontology)

add_ontology(ontology, *, skip_vector_index=False)

Add an ontology to the version tree for its IRI.

If an ontology with the same hash already exists, it is not added again. Ensures that created_at is set if not already present.

Parameters:

Name Type Description Default
ontology Ontology

The ontology to add.

required
skip_vector_index bool

If True, do not call the vector store (caller already materialized embeddings, e.g. during ToolBox.initialize).

False

Raises:

Type Description
RuntimeError

If vector reindex would run while an event loop is already active. Use :meth:aadd_ontology from async code.

Source code in ontocast/tool/ontology_manager.py
def add_ontology(
    self, ontology: Ontology, *, skip_vector_index: bool = False
) -> None:
    """Add an ontology to the version tree for its IRI.

    If an ontology with the same hash already exists, it is not added again.
    Ensures that created_at is set if not already present.

    Args:
        ontology: The ontology to add.
        skip_vector_index: If True, do not call the vector store (caller
            already materialized embeddings, e.g. during ToolBox.initialize).

    Raises:
        RuntimeError: If vector reindex would run while an event loop is
            already active. Use :meth:`aadd_ontology` from async code.
    """
    self._ensure_sync_reindex_allowed(skip_vector_index=skip_vector_index)
    if not self._prepare_ontology_for_catalog(ontology):
        return
    if not skip_vector_index:
        self._reindex_ontology_sync(ontology)

aget_catalog_headers() async

Read ontology header metadata for every stored version.

Deliberately not cached. Headers are what terminal-version selection runs on, so caching them would let this process miss another worker's writes to a shared store -- the one thing the graph cache cannot go wrong about, and the one thing this would.

Returns:

Type Description
list[OntologyHeader]

list[OntologyHeader]: One header per stored ontology version.

Source code in ontocast/tool/ontology_manager.py
async def aget_catalog_headers(self) -> list[OntologyHeader]:
    """Read ontology header metadata for every stored version.

    Deliberately **not** cached. Headers are what terminal-version selection
    runs on, so caching them would let this process miss another worker's
    writes to a shared store -- the one thing the graph cache cannot go wrong
    about, and the one thing this would.

    Returns:
        list[OntologyHeader]: One header per stored ontology version.
    """
    return await self._require_triple_store().afetch_ontology_catalog()

aget_merged_graph(ontologies) async

Return the prefix-bound union of ontologies, cached by version set.

The induced-subgraph builder reads this union without mutating it, so one merge can be shared by every content unit that selects the same ontology versions -- which is the common case inside a document.

Parameters:

Name Type Description Default
ontologies Sequence[Ontology]

Ontology versions to merge.

required

Returns:

Name Type Description
tuple RDFGraph

(merged_graph, prefix_map). The graph **must not be mutated

dict[str, str]

by callers**; it is shared.

Source code in ontocast/tool/ontology_manager.py
async def aget_merged_graph(
    self, ontologies: Sequence[Ontology]
) -> tuple[RDFGraph, dict[str, str]]:
    """Return the prefix-bound union of ``ontologies``, cached by version set.

    The induced-subgraph builder reads this union without mutating it, so one
    merge can be shared by every content unit that selects the same ontology
    versions -- which is the common case inside a document.

    Args:
        ontologies: Ontology versions to merge.

    Returns:
        tuple: ``(merged_graph, prefix_map)``. The graph **must not be mutated
        by callers**; it is shared.
    """
    from .sparql import merge_ontology_graphs

    key = frozenset(onto.versioned_iri for onto in ontologies)
    cached = self._merged_cache.get(key)
    if cached is not None:
        self._merged_cache_hits += 1
        self._merged_cache.move_to_end(key)
        return cached

    self._merged_cache_misses += 1
    merged = await asyncio.to_thread(merge_ontology_graphs, list(ontologies))
    self._merged_cache[key] = merged
    while len(self._merged_cache) > _MERGED_CACHE_MAX_ENTRIES:
        self._merged_cache.popitem(last=False)
    return merged

aget_ontologies_by_iri(iris) async

Return terminal ontologies for iris, fetching only cache misses.

Terminal selection always runs against freshly read headers; only the graph bytes come from cache, keyed by the content-addressed versioned_iri.

Parameters:

Name Type Description Default
iris Sequence[str]

Ontology IRIs to resolve. Empty means "no restriction", matching :meth:~ontocast.tool.triple_manager.core.TripleStoreManager.afetch_ontologies_by_iri.

required

Returns:

Type Description
list[Ontology]

list[Ontology]: Terminal ontologies with graphs. Callers must treat

list[Ontology]

these as shared read-only references.

Source code in ontocast/tool/ontology_manager.py
async def aget_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
    """Return terminal ontologies for ``iris``, fetching only cache misses.

    Terminal selection always runs against freshly read headers; only the
    graph bytes come from cache, keyed by the content-addressed
    ``versioned_iri``.

    Args:
        iris: Ontology IRIs to resolve. Empty means "no restriction", matching
            :meth:`~ontocast.tool.triple_manager.core.TripleStoreManager.afetch_ontologies_by_iri`.

    Returns:
        list[Ontology]: Terminal ontologies with graphs. Callers must treat
        these as shared read-only references.
    """
    store = self._require_triple_store()
    headers = dedupe_terminal_ontologies(await self.aget_catalog_headers())
    if iris:
        wanted = set(iris)
        headers = [header for header in headers if header.iri in wanted]

    resolved: list[Ontology] = []
    missing_iris: list[str] = []
    graph_uri_by_iri: dict[str, str] = {}
    for header in headers:
        cached = self._graph_cache.get(header.graph_uri)
        if cached is not None:
            self._graph_cache_hits += 1
            self._graph_cache.move_to_end(header.graph_uri)
            resolved.append(cached)
        else:
            self._graph_cache_misses += 1
            missing_iris.append(header.iri)
            graph_uri_by_iri[header.iri] = header.graph_uri

    if missing_iris:
        fetched = await store.afetch_ontologies_by_iri(missing_iris)
        for ontology in fetched:
            self._cache_graph(ontology, graph_uri_by_iri.get(ontology.iri))
        resolved.extend(fetched)
    return resolved

aget_patch_context(query, top_k=None, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None) async

Async variant of :meth:get_patch_context.

Source code in ontocast/tool/ontology_manager.py
async def aget_patch_context(
    self,
    query: str,
    top_k: int | None = None,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> RDFGraph | None:
    """Async variant of :meth:`get_patch_context`."""
    graph, _ = await self.aget_patch_context_with_sources(
        query=query,
        top_k=top_k,
        subgraph_depth=subgraph_depth,
        max_total_triples=max_total_triples,
        estimated_triples_per_query=estimated_triples_per_query,
    )
    return graph

aget_patch_context_with_sources(query, top_k=None, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None) async

Async variant of :meth:get_patch_context_with_sources.

Source code in ontocast/tool/ontology_manager.py
async def aget_patch_context_with_sources(
    self,
    query: str,
    top_k: int | None = None,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> tuple[RDFGraph | None, list[str]]:
    """Async variant of :meth:`get_patch_context_with_sources`."""
    results = await self.aget_patch_contexts_with_sources(
        queries=[query],
        top_k=top_k,
        subgraph_depth=subgraph_depth,
        max_total_triples=max_total_triples,
        estimated_triples_per_query=estimated_triples_per_query,
    )
    if not results:
        return None, []
    return results[0]

aget_patch_contexts_with_sources(queries, top_k=None, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None) async

Async patch retrieval (vector + induced subgraph) for many queries.

With a patch retriever, returns a one-element list: a single induced graph for the union of hits over queries, plus contributing ontology IRIs.

Source code in ontocast/tool/ontology_manager.py
async def aget_patch_contexts_with_sources(
    self,
    queries: list[str],
    top_k: int | None = None,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> list[tuple[RDFGraph | None, list[str]]]:
    """Async patch retrieval (vector + induced subgraph) for many queries.

    With a patch retriever, returns a one-element list: a single induced graph for
    the union of hits over ``queries``, plus contributing ontology IRIs.
    """
    if not queries:
        return []
    if self._patch_retriever is not None:
        graph, sources = await self._patch_retriever.aretrieve_ensemble(
            queries=queries,
            top_k=self._effective_patch_top_k(top_k),
            subgraph_depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
        )
        return [self._normalize_patch_graph(graph, sources)]

    return self._fallback_patch_results(queries)

author_prefix_for_namespace(namespace)

Return the catalog-registered author prefix for a namespace, if any.

Source code in ontocast/tool/ontology_manager.py
def author_prefix_for_namespace(self, namespace: str) -> str | None:
    """Return the catalog-registered author prefix for a namespace, if any."""
    direct = self._namespace_to_author_prefix.get(namespace)
    if direct is not None:
        return direct
    stripped = namespace.rstrip("/#")
    for key, value in self._namespace_to_author_prefix.items():
        if key.rstrip("/#") == stripped:
            return value
    return None

catalog_cache_stats()

Cache hit/miss counters, for tests and retrieval diagnostics.

Source code in ontocast/tool/ontology_manager.py
def catalog_cache_stats(self) -> dict[str, int]:
    """Cache hit/miss counters, for tests and retrieval diagnostics."""
    return {
        "catalog_graph_cache_hits": self._graph_cache_hits,
        "catalog_graph_cache_misses": self._graph_cache_misses,
        "catalog_merge_cache_hits": self._merged_cache_hits,
        "catalog_merge_cache_misses": self._merged_cache_misses,
    }

get_freshest_terminal_ontology(ontology_id=None)

Get the freshest terminal ontology by ontology_id, alias, or IRI.

Parameters:

Name Type Description Default
ontology_id str | None

Optional ontology_id / alias / IRI to filter by.

None

Returns:

Name Type Description
Ontology Ontology | None

The freshest terminal ontology, or None if no terminal ontologies exist.

Source code in ontocast/tool/ontology_manager.py
def get_freshest_terminal_ontology(
    self, ontology_id: str | None = None
) -> Ontology | None:
    """Get the freshest terminal ontology by ontology_id, alias, or IRI.

    Args:
        ontology_id: Optional ontology_id / alias / IRI to filter by.

    Returns:
        Ontology: The freshest terminal ontology, or None if no terminal
            ontologies exist.
    """
    if ontology_id:
        iri = self.resolve_ontology_ref(ontology_id)
        if iri is None:
            return None
        return self.get_freshest_terminal_ontology_by_iri(iri)
    return self.get_freshest_terminal_ontology_by_iri(None)

get_freshest_terminal_ontology_by_iri(iri=None)

Get the freshest terminal ontology based on created_at timestamp.

Returns the terminal ontology with the most recent created_at timestamp. If multiple terminal ontologies exist, returns the one that was most recently created. If no created_at is set, falls back to the first terminal ontology.

Parameters:

Name Type Description Default
iri str | None

Optional IRI to filter by. If None, searches across all ontologies.

None

Returns:

Name Type Description
Ontology Ontology | None

The freshest terminal ontology, or None if no terminal ontologies exist.

Source code in ontocast/tool/ontology_manager.py
def get_freshest_terminal_ontology_by_iri(
    self, iri: str | None = None
) -> Ontology | None:
    """Get the freshest terminal ontology based on created_at timestamp.

    Returns the terminal ontology with the most recent `created_at` timestamp.
    If multiple terminal ontologies exist, returns the one that was most recently
    created. If no created_at is set, falls back to the first terminal ontology.

    Args:
        iri: Optional IRI to filter by. If None, searches across
            all ontologies.

    Returns:
        Ontology: The freshest terminal ontology, or None if no terminal
            ontologies exist.
    """
    terminals = self.get_terminal_ontologies_by_iri(iri)

    if not terminals:
        return None

    # Filter out ontologies without created_at and sort by created_at
    with_timestamp = [o for o in terminals if o.created_at is not None]
    without_timestamp = [o for o in terminals if o.created_at is None]

    if with_timestamp:
        # Sort by created_at descending (most recent first)
        freshest = max(
            with_timestamp,
            key=lambda o: cast(datetime, o.created_at),
        )
        return freshest
    elif without_timestamp:
        # Fallback to first terminal if no timestamps available
        return without_timestamp[0]

    return None

get_lineage_graph(ontology_id)

Get the lineage graph for a specific ontology_id, alias, or IRI.

Parameters:

Name Type Description Default
ontology_id str

The ontology_id / alias / IRI to get the lineage graph for.

required

Returns:

Type Description

networkx.DiGraph: The lineage graph for the ontology, or None if not found.

Source code in ontocast/tool/ontology_manager.py
def get_lineage_graph(self, ontology_id: str):
    """Get the lineage graph for a specific ontology_id, alias, or IRI.

    Args:
        ontology_id: The ontology_id / alias / IRI to get the lineage graph for.

    Returns:
        networkx.DiGraph: The lineage graph for the ontology, or None if not found.
    """
    iri = self.resolve_ontology_ref(ontology_id)
    if iri is None:
        return None
    return self.get_lineage_graph_by_iri(iri)

get_lineage_graph_by_iri(iri)

Get the lineage graph for a specific IRI.

Parameters:

Name Type Description Default
iri str

The IRI to get the lineage graph for.

required

Returns:

Type Description

networkx.DiGraph: The lineage graph for the ontology, or None if not found.

Source code in ontocast/tool/ontology_manager.py
def get_lineage_graph_by_iri(self, iri: str):
    """Get the lineage graph for a specific IRI.

    Args:
        iri: The IRI to get the lineage graph for.

    Returns:
        networkx.DiGraph: The lineage graph for the ontology, or None if not found.
    """
    if iri not in self.ontology_versions:
        return None

    return Ontology.build_lineage_graph(self.ontology_versions[iri])

get_ontology(ontology_id=None, ontology_iri=None, hash=None)

Get an ontology by its IRI, ontology_id/alias, or hash.

If hash is provided, returns the specific version. Otherwise, returns a terminal (most recent) version if multiple versions exist. IRI is preferred over ontology_id for lookup.

Parameters:

Name Type Description Default
ontology_id str | None

Short name, author prefix, or IRI (optional).

None
ontology_iri str | None

The IRI of the ontology to retrieve (preferred).

None
hash str | None

The hash of a specific version to retrieve (optional).

None

Returns:

Name Type Description
Ontology Ontology

The matching ontology if found, NULL_ONTOLOGY otherwise.

Source code in ontocast/tool/ontology_manager.py
def get_ontology(
    self,
    ontology_id: str | None = None,
    ontology_iri: str | None = None,
    hash: str | None = None,
) -> Ontology:
    """Get an ontology by its IRI, ontology_id/alias, or hash.

    If hash is provided, returns the specific version. Otherwise, returns
    a terminal (most recent) version if multiple versions exist.
    IRI is preferred over ontology_id for lookup.

    Args:
        ontology_id: Short name, author prefix, or IRI (optional).
        ontology_iri: The IRI of the ontology to retrieve (preferred).
        hash: The hash of a specific version to retrieve (optional).

    Returns:
        Ontology: The matching ontology if found, NULL_ONTOLOGY otherwise.
    """
    # If hash is provided, search by hash first
    if hash:
        for versions in self.ontology_versions.values():
            for o in versions:
                if o.hash == hash:
                    return o

    resolved_iri: str | None = None
    if ontology_iri is not None:
        resolved_iri = self.resolve_ontology_ref(ontology_iri)
    if resolved_iri is None and ontology_id is not None:
        resolved_iri = self.resolve_ontology_ref(ontology_id)

    if resolved_iri is not None and resolved_iri in self.ontology_versions:
        versions = self.ontology_versions[resolved_iri]
        if hash:
            for o in versions:
                if o.hash == hash:
                    return o
        else:
            terminals = self.get_terminal_ontologies_by_iri(resolved_iri)
            if terminals:
                return terminals[0]
            if versions:
                return versions[0]

        if (
            ontology_iri
            and ontology_id
            and self.resolve_ontology_ref(ontology_id) not in (None, resolved_iri)
        ):
            logger.warning(
                "Ontology id '%s' resolves differently from IRI '%s'",
                ontology_id,
                ontology_iri,
            )

    return NULL_ONTOLOGY

get_ontology_iris()

Get a list of all ontology IRIs.

Returns:

Type Description
list[str]

list[str]: List of ontology IRIs.

Source code in ontocast/tool/ontology_manager.py
def get_ontology_iris(self) -> list[str]:
    """Get a list of all ontology IRIs.

    Returns:
        list[str]: List of ontology IRIs.
    """
    return list(self.ontology_versions.keys())

get_ontology_names()

Return unique catalog ontology_id values currently tracked.

Returns:

Type Description
list[str]

list[str]: Sorted unique ontology short names.

Source code in ontocast/tool/ontology_manager.py
def get_ontology_names(self) -> list[str]:
    """Return unique catalog ``ontology_id`` values currently tracked.

    Returns:
        list[str]: Sorted unique ontology short names.
    """
    names = set()
    for versions in self.ontology_versions.values():
        for o in versions:
            if o.ontology_id:
                names.add(o.ontology_id)
    return sorted(list(names))

get_ontology_versions(ontology_id)

Get all versions of an ontology by ontology_id, alias, or IRI.

Parameters:

Name Type Description Default
ontology_id str

The ontology_id / alias / IRI to retrieve versions for.

required

Returns:

Type Description
list[Ontology]

list[Ontology]: List of all versions of the ontology.

Source code in ontocast/tool/ontology_manager.py
def get_ontology_versions(self, ontology_id: str) -> list[Ontology]:
    """Get all versions of an ontology by ontology_id, alias, or IRI.

    Args:
        ontology_id: The ontology_id / alias / IRI to retrieve versions for.

    Returns:
        list[Ontology]: List of all versions of the ontology.
    """
    iri = self.resolve_ontology_ref(ontology_id)
    if iri is None:
        return []
    return self.get_ontology_versions_by_iri(iri)

get_ontology_versions_by_iri(iri)

Get all versions of an ontology by IRI.

Parameters:

Name Type Description Default
iri str

The IRI to retrieve versions for.

required

Returns:

Type Description
list[Ontology]

list[Ontology]: List of all versions of the ontology.

Source code in ontocast/tool/ontology_manager.py
def get_ontology_versions_by_iri(self, iri: str) -> list[Ontology]:
    """Get all versions of an ontology by IRI.

    Args:
        iri: The IRI to retrieve versions for.

    Returns:
        list[Ontology]: List of all versions of the ontology.
    """
    return self.ontology_versions.get(iri, [])

get_patch_context(query, top_k=None, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None)

Retrieve multi-ontology patch context for a query.

Falls back to the freshest available ontology graph if vector retrieval is not configured or yields no atoms.

Source code in ontocast/tool/ontology_manager.py
def get_patch_context(
    self,
    query: str,
    top_k: int | None = None,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> RDFGraph | None:
    """Retrieve multi-ontology patch context for a query.

    Falls back to the freshest available ontology graph if vector retrieval
    is not configured or yields no atoms.
    """
    graph, _ = self.get_patch_context_with_sources(
        query=query,
        top_k=top_k,
        subgraph_depth=subgraph_depth,
        max_total_triples=max_total_triples,
        estimated_triples_per_query=estimated_triples_per_query,
    )
    return graph

get_patch_context_with_sources(query, top_k=None, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None)

Retrieve patch context and contributing ontology IRIs.

Source code in ontocast/tool/ontology_manager.py
def get_patch_context_with_sources(
    self,
    query: str,
    top_k: int | None = None,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> tuple[RDFGraph | None, list[str]]:
    """Retrieve patch context and contributing ontology IRIs."""
    results = self.get_patch_contexts_with_sources(
        queries=[query],
        top_k=top_k,
        subgraph_depth=subgraph_depth,
        max_total_triples=max_total_triples,
        estimated_triples_per_query=estimated_triples_per_query,
    )
    if not results:
        return None, []
    return results[0]

get_patch_contexts_with_sources(queries, top_k=None, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None)

Retrieve patch contexts for many queries in a batched pass.

With a patch retriever, the list has length 1 (ensemble graph + sources). Without it, length matches queries (fallback ontology per query).

Raises:

Type Description
RuntimeError

If called while an event loop is running. Use :meth:aget_patch_contexts_with_sources from async code.

Source code in ontocast/tool/ontology_manager.py
def get_patch_contexts_with_sources(
    self,
    queries: list[str],
    top_k: int | None = None,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> list[tuple[RDFGraph | None, list[str]]]:
    """Retrieve patch contexts for many queries in a batched pass.

    With a patch retriever, the list has length 1 (ensemble graph + sources).
    Without it, length matches ``queries`` (fallback ontology per query).

    Raises:
        RuntimeError: If called while an event loop is running. Use
            :meth:`aget_patch_contexts_with_sources` from async code.
    """
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(
            self.aget_patch_contexts_with_sources(
                queries=queries,
                top_k=top_k,
                subgraph_depth=subgraph_depth,
                max_total_triples=max_total_triples,
                estimated_triples_per_query=estimated_triples_per_query,
            )
        )
    raise RuntimeError(
        "get_patch_contexts_with_sources() cannot be called from async code; "
        "use await aget_patch_contexts_with_sources()"
    )

get_terminal_ontologies(ontology_id=None)

Get terminal (leaf) ontologies by ontology_id or alias.

Parameters:

Name Type Description Default
ontology_id str | None

Optional ontology_id / alias / IRI to filter by.

None

Returns:

Type Description
list[Ontology]

list[Ontology]: List of terminal ontologies.

Source code in ontocast/tool/ontology_manager.py
def get_terminal_ontologies(self, ontology_id: str | None = None) -> list[Ontology]:
    """Get terminal (leaf) ontologies by ontology_id or alias.

    Args:
        ontology_id: Optional ontology_id / alias / IRI to filter by.

    Returns:
        list[Ontology]: List of terminal ontologies.
    """
    if ontology_id:
        iri = self.resolve_ontology_ref(ontology_id)
        if iri is None:
            return []
        return self.get_terminal_ontologies_by_iri(iri)
    return self.get_terminal_ontologies_by_iri(None)

get_terminal_ontologies_by_iri(iri=None)

Get terminal (leaf) ontologies in the version graph.

Terminal ontologies are those that are not parents of any other ontology in the version tree. If iri is provided, returns terminals for that ontology only; otherwise returns terminals for all ontologies.

Parameters:

Name Type Description Default
iri str | None

Optional IRI to filter by.

None

Returns:

Type Description
list[Ontology]

list[Ontology]: List of terminal ontologies.

Source code in ontocast/tool/ontology_manager.py
def get_terminal_ontologies_by_iri(self, iri: str | None = None) -> list[Ontology]:
    """Get terminal (leaf) ontologies in the version graph.

    Terminal ontologies are those that are not parents of any other ontology
    in the version tree. If iri is provided, returns terminals for
    that ontology only; otherwise returns terminals for all ontologies.

    Args:
        iri: Optional IRI to filter by.

    Returns:
        list[Ontology]: List of terminal ontologies.
    """
    if iri:
        if iri not in self.ontology_versions:
            return []
        ontologies = self.ontology_versions[iri]
    else:
        ontologies = [
            o for versions in self.ontology_versions.values() for o in versions
        ]

    if not ontologies:
        return []

    # Build a set of all parent hashes
    all_parent_hashes = set()
    for o in ontologies:
        all_parent_hashes.update(o.parent_hashes)

    # Terminal nodes are those whose hash is not in any parent_hashes
    terminal_hashes = {o.hash for o in ontologies} - all_parent_hashes

    return [o for o in ontologies if o.hash in terminal_hashes]

register_triple_store(manager)

Register the triple store this catalog reads through on a cache miss.

Source code in ontocast/tool/ontology_manager.py
def register_triple_store(self, manager: TripleStoreManager | None) -> None:
    """Register the triple store this catalog reads through on a cache miss."""
    self._triple_store_manager = manager

register_vector_store(retriever)

Register a patch retriever for vector context lookups.

Source code in ontocast/tool/ontology_manager.py
def register_vector_store(self, retriever: "OntologyPatchRetriever") -> None:
    """Register a patch retriever for vector context lookups."""
    self._patch_retriever = retriever

remove_ontology_by_iri(iri)

Drop all tracked versions for an ontology IRI and clear caches.

Source code in ontocast/tool/ontology_manager.py
def remove_ontology_by_iri(self, iri: str) -> None:
    """Drop all tracked versions for an ontology IRI and clear caches."""
    # Evict under the key entries were *inserted* with. Popping
    # ``versioned_iri`` here -- as this did once -- silently missed every
    # entry whenever the recomputed hash differed from the stored graph URI,
    # leaving a removed ontology still resolvable from cache.
    for graph_uri in self._graph_uris_by_iri.pop(iri, set()):
        self._graph_cache.pop(graph_uri, None)
    for ontology in self.ontology_versions.get(iri, []):
        self._graph_cache.pop(ontology.versioned_iri, None)
    stale_merges = [
        key
        for key in self._merged_cache
        # An ontology with no hash falls back to the bare IRI as its
        # versioned IRI, so match that exactly as well as the `#hash` form.
        if any(
            versioned == iri or versioned.startswith(f"{iri}#") for versioned in key
        )
    ]
    for key in stale_merges:
        del self._merged_cache[key]
    self.ontology_versions.pop(iri, None)
    self._cached_ontologies.pop(iri, None)
    self._iri_to_ontology_id.pop(iri, None)
    # Drop all aliases pointing at this IRI.
    stale = [alias for alias, bound in self._alias_to_iri.items() if bound == iri]
    for alias in stale:
        del self._alias_to_iri[alias]
    # Drop author-prefix entries whose IRI matches (by scanning versions was already removed).
    # Namespace map is best-effort; rebuild from remaining ontologies.
    self._namespace_to_author_prefix = {}
    for versions in self.ontology_versions.values():
        if not versions:
            continue
        onto = versions[-1]
        if onto.prefix and onto.namespace:
            self._namespace_to_author_prefix[str(onto.namespace)] = onto.prefix

reset_catalog()

Drop every tracked ontology, identity binding, and cached graph.

Called when the active tenant/project changes: the catalog, the alias collision ledger, and the graph caches are all partition-scoped, and carrying them across a switch leaks one tenant's ontologies into another's requests.

Source code in ontocast/tool/ontology_manager.py
def reset_catalog(self) -> None:
    """Drop every tracked ontology, identity binding, and cached graph.

    Called when the active tenant/project changes: the catalog, the alias
    collision ledger, and the graph caches are all partition-scoped, and
    carrying them across a switch leaks one tenant's ontologies into another's
    requests.
    """
    self.ontology_versions.clear()
    self._cached_ontologies.clear()
    self._iri_to_ontology_id.clear()
    self._alias_to_iri.clear()
    self._namespace_to_author_prefix.clear()
    self._graph_cache.clear()
    self._graph_uris_by_iri.clear()
    self._merged_cache.clear()

resolve_ontology_ref(ref)

Resolve an absolute IRI or registered alias to a catalog ontology IRI.

Source code in ontocast/tool/ontology_manager.py
def resolve_ontology_ref(self, ref: str) -> str | None:
    """Resolve an absolute IRI or registered alias to a catalog ontology IRI."""
    if not ref or not str(ref).strip():
        return None
    cleaned = str(ref).strip()
    if cleaned in self.ontology_versions:
        return cleaned
    normalized = normalize_ontology_iri(cleaned)
    if normalized in self.ontology_versions:
        return normalized
    for key in (cleaned.lower(), normalized.lower()):
        iri = self._alias_to_iri.get(key)
        if iri is not None:
            return iri
    return None

update_ontology(ontology_id, ontology_addendum)

Update an existing ontology with additional triples.

Note: This method is deprecated. Use add_ontology() with a new version that has the current hash in parent_hashes instead.

Parameters:

Name Type Description Default
ontology_id str

The short name of the ontology to update.

required
ontology_addendum RDFGraph

The RDF graph containing additional triples to add.

required
Source code in ontocast/tool/ontology_manager.py
def update_ontology(self, ontology_id: str, ontology_addendum: RDFGraph):
    """Update an existing ontology with additional triples.

    Note: This method is deprecated. Use add_ontology() with a new version
    that has the current hash in parent_hashes instead.

    Args:
        ontology_id: The short name of the ontology to update.
        ontology_addendum: The RDF graph containing additional triples to add.
    """
    logger.warning(
        "update_ontology() is deprecated. Use add_ontology() with version tracking instead."
    )
    terminals = self.get_terminal_ontologies(ontology_id)
    if terminals:
        terminals[0] += ontology_addendum
        # Update cache for the IRI (though this method is deprecated)
        iri = terminals[0].iri
        freshest = self.get_freshest_terminal_ontology_by_iri(iri)
        if freshest and freshest.hash:
            self._cached_ontologies[iri] = freshest.hash

validate_identity_uniqueness(ontology)

Validate catalog IRI and alias uniqueness across the manager.

Same IRI may not change its primary ontology_id. The same ontology_id alias may not point at two different IRIs. Author prefix may differ from ontology_id (both register as aliases of the same IRI); a prefix collision across IRIs does not block ingest — the colliding prefix alias is simply skipped at registration and the ontology stays addressable by IRI and ontology_id.

Source code in ontocast/tool/ontology_manager.py
def validate_identity_uniqueness(self, ontology: Ontology) -> None:
    """Validate catalog IRI and alias uniqueness across the manager.

    Same IRI may not change its primary ``ontology_id``. The same
    ``ontology_id`` alias may not point at two different IRIs. Author
    ``prefix`` may differ from ``ontology_id`` (both register as aliases of
    the same IRI); a *prefix* collision across IRIs does not block ingest —
    the colliding prefix alias is simply skipped at registration and the
    ontology stays addressable by IRI and ``ontology_id``.
    """
    iri = (ontology.iri or "").strip()
    if not iri:
        raise ValueError("Ontology IRI is missing")
    if iri == NULL_ONTOLOGY.iri:
        raise ValueError("Null ontology IRI cannot be registered")

    primary = self._primary_ontology_id(ontology)

    existing_primary = self._iri_to_ontology_id.get(iri)
    if existing_primary is not None and existing_primary != primary:
        raise ValueError(
            "Ontology identity conflict: IRI "
            f"'{iri}' is already bound to identity '{existing_primary}', "
            f"received '{primary}'"
        )

    for alias, kind in self._collect_aliases(ontology):
        existing_iri = self._alias_to_iri.get(alias)
        if existing_iri is None or existing_iri == iri:
            continue
        if kind == "prefix":
            # Convenience alias only; degrades to IRI-only addressing.
            continue
        raise ValueError(
            "Ontology identity conflict: identity "
            f"'{alias}' is already bound to IRI '{existing_iri}', "
            f"received '{iri}'"
        )

OntologyPatchRetriever

Bases: Tool

Combines vector retrieval into one composite ontology graph.

Source code in ontocast/tool/vector_store/patch_retriever.py
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
class OntologyPatchRetriever(Tool):
    """Combines vector retrieval into one composite ontology graph."""

    vector_store: VectorStoreManager = Field(exclude=True)
    sparql_tool: Any | None = Field(default=None, exclude=True)
    # Typed ``Any`` for the same reason as ``sparql_tool``: OntologyManager holds a
    # back-reference to this class, so a concrete annotation would be a cycle.
    ontology_manager: Any | None = Field(default=None, exclude=True)
    patch: PatchRetrievalConfig = Field(
        default_factory=PatchRetrievalConfig,
        exclude=True,
    )
    _last_retrieval_metrics: dict[str, Any] = PrivateAttr(default_factory=dict)
    _surface_index: CatalogSurfaceIndex | None = PrivateAttr(default=None)
    # Whole-module graphs for the small-module closure, keyed by ontology IRI.
    # The catalog is stable for the life of a run, and every content unit hits
    # the same handful of modules — refetching per unit multiplies catalog
    # reads by the unit count for no new information. ``None`` caches a miss.
    _small_module_cache: dict[str, Ontology | None] = PrivateAttr(default_factory=dict)
    # Tenancy the cache was filled under. The retriever outlives a tenancy
    # switch, and serving one tenant's modules to another would be a leak.
    _small_module_cache_scope: str = PrivateAttr(default="")

    @property
    def last_retrieval_metrics(self) -> dict[str, Any]:
        return self._last_retrieval_metrics

    def _match_query_unit_signals(self, trigger_source: str) -> dict[str, str]:
        """Match number-adjacent unit tokens against catalog surface forms.

        Additive, outside the semantic atom budget (precedent: the lexical
        trigger lane). Returns ``{entity_iri: ontology_iri}``; empty when the
        lane is disabled or nothing matches.
        """
        if not self.vector_store.store_config.query_unit_signals_enabled:
            return {}
        manager = self.ontology_manager
        if manager is None or not trigger_source:
            return {}
        tokens = number_adjacent_tokens(trigger_source)
        if not tokens:
            return {}
        if self._surface_index is None:
            # Symbol/notation predicates come from configuration rather than
            # being compiled into query_signals; built lazily because the
            # store config is not available at PrivateAttr default time.
            self._surface_index = CatalogSurfaceIndex(
                symbol_predicates=[
                    URIRef(iri)
                    for iri in (
                        self.vector_store.store_config.induced_subgraph_symbol_predicates
                    )
                ]
            )
        matched = self._surface_index.match(tokens, manager.ontologies)
        if matched:
            logger.info(
                "Query unit signals matched %d entity(ies) from tokens %s",
                len(matched),
                sorted(tokens),
            )
        return matched

    @staticmethod
    def _schema_axiom_graph(
        merged_context: tuple[RDFGraph, dict[str, str]] | None,
        catalog: list[Ontology] | None,
    ) -> RDFGraph | None:
        """Pick the graph to read ``rdfs:domain``/``rdfs:range`` axioms from.

        Whichever of the two expansion paths materialized the ontologies wins;
        neither being available means the induced-subgraph call is fetching on
        its own and there is nothing local to close over.
        """
        if merged_context is not None:
            return merged_context[0]
        if catalog:
            combined = RDFGraph()
            for ontology in catalog:
                combined += ontology.graph
            return combined
        return None

    async def _apply_small_module_closure(
        self, graph: RDFGraph, hit_ontology_iris: list[str]
    ) -> None:
        """Merge whole small modules into the snapshot (header-stripped).

        A vocabulary small enough to fit entirely (e.g. a qualified-quantity
        module of ~20 terms) is included wholesale once any of its atoms is
        admitted: partial inclusion of a tiny module is what pushes the
        renderer to improvise near-miss property names.
        """
        closure_max = self.patch.small_module_closure_max_triples
        if closure_max <= 0:
            return
        modules = await self._asmall_module_candidates(hit_ontology_iris)
        closed: list[str] = []
        for onto_iri, ontology in modules:
            if len(ontology.graph) > closure_max:
                continue
            module_graph = Ontology.strip_ontology_header_triples(ontology.graph.copy())
            _drop_module_contribution(graph, module_graph)
            graph += module_graph
            for prefix, namespace_uri in ontology.graph.namespaces():
                graph.bind(prefix, namespace_uri)
            closed.append(onto_iri)
        if closed:
            self._last_retrieval_metrics["module_closure_iris"] = closed

    async def _asmall_module_candidates(
        self, hit_ontology_iris: list[str]
    ) -> list[tuple[str, Ontology]]:
        """Resolve hit ontologies to full graphs, manager first, store second.

        The in-memory manager is empty in every deployment that keeps its
        catalog in a triple store and fetches per query — which is the normal
        server configuration, and where this closure silently did nothing.
        """
        store_config = getattr(self.vector_store, "store_config", None)
        scope = str(getattr(store_config, "ontology_table", "") or "")
        if scope != self._small_module_cache_scope:
            self._small_module_cache.clear()
            self._small_module_cache_scope = scope

        wanted = sorted(set(hit_ontology_iris))
        resolved: list[tuple[str, Ontology]] = []
        missing: list[str] = []
        manager = self.ontology_manager
        for onto_iri in wanted:
            if onto_iri in self._small_module_cache:
                cached = self._small_module_cache[onto_iri]
                if cached is not None:
                    resolved.append((onto_iri, cached))
                continue
            ontology = (
                manager.get_freshest_terminal_ontology_by_iri(onto_iri)
                if manager is not None
                else None
            )
            if ontology is None or ontology.is_null():
                missing.append(onto_iri)
            else:
                self._small_module_cache[onto_iri] = ontology
                resolved.append((onto_iri, ontology))

        store = self.sparql_tool.triple_store_manager if self.sparql_tool else None
        if missing and store is not None:
            try:
                fetched = await store.afetch_ontologies_by_iri(missing)
            except Exception as exc:
                # Do NOT cache on this path. A None entry is a permanent
                # negative (see the miss-caching note above), so memoizing a
                # transient store error would silently strip the small-module
                # closure from every later unit in the process.
                logger.warning(
                    "Small-module closure catalog fetch failed (not cached, "
                    "will retry on the next unit): %s",
                    exc,
                )
                return sorted(resolved, key=lambda item: item[0])
            by_iri = {o.iri: o for o in fetched if o.iri and not o.is_null()}
            for onto_iri in missing:
                found = by_iri.get(onto_iri)
                self._small_module_cache[onto_iri] = found
                if found is not None:
                    resolved.append((onto_iri, found))
        return sorted(resolved, key=lambda item: item[0])

    async def _acandidate_context(
        self,
        *,
        entity_uris: list[str],
        ontology_iris: list[str],
        ontology_version_filters: dict[str, set[str]] | None,
        ontology_hash_filters: dict[str, set[str]] | None,
        depth: int,
    ) -> tuple[RDFGraph, dict[str, str]]:
        """Build the working graph from a CONSTRUCT instead of merging catalogs.

        Version and hash filters are applied to the *headers*, so the CONSTRUCT is
        restricted to exactly the named graphs the merge path would have selected.

        Prefix bindings cannot come from a CONSTRUCT, so they are rebuilt from the
        catalog's author-prefix table; standard vocabulary prefixes are bound
        downstream by :func:`_bind_common_vocab_prefixes` as on the merge path.

        Returns:
            tuple: ``(candidate_graph, prefix_map)``.
        """
        manager = self.ontology_manager
        assert manager is not None and self.sparql_tool is not None
        store = self.sparql_tool.triple_store_manager
        headers = select_relevant_ontologies(
            dedupe_terminal_ontologies(await manager.aget_catalog_headers()),
            ontology_iris,
            ontology_version_filters,
            ontology_hash_filters,
        )
        if not headers:
            return RDFGraph(), {}

        graph_irefs = _sparql_irefs([header.graph_uri for header in headers])
        seed_irefs = _sparql_irefs(entity_uris)
        if not graph_irefs or not seed_irefs:
            return RDFGraph(), {}

        candidate = RDFGraph()
        for chunk in _chunked(seed_irefs, _MAX_VALUES_TERMS):
            partial = await store.aconstruct(
                build_candidate_subgraph_query(chunk, graph_irefs, depth=depth)
            )
            candidate += partial

        prefix_map: dict[str, str] = {}
        for header in headers:
            namespace = str(header.namespace)
            prefix = manager.author_prefix_for_namespace(namespace)
            if prefix:
                prefix_map[prefix] = namespace
        prefix_map = filter_overbroad_namespace_map(prefix_map)
        for prefix, namespace in prefix_map.items():
            candidate.bind(prefix, Namespace(namespace))
        # Mirror the merge path: author @prefix names persisted as sh:declare
        # triples (pulled by the candidate CONSTRUCT's header branch) win over
        # stem-derived recovery, exactly as ontology_from_named_graph binds them
        # for merged catalog graphs.
        declared = candidate.bind_declared_prefixes()
        known_before_declared = set(prefix_map.values())
        for namespace, prefix in declared.items():
            if namespace not in known_before_declared:
                prefix_map[prefix] = namespace
        # Graphs served from a triple store carry no author @prefix bindings, so
        # stem-derived prefixes fill any remaining gap (see
        # ontology_from_named_graph). Recover the same implicit stems here so
        # both context paths advertise identical namespaces.
        candidate.bind_implicit_namespaces()
        known_namespaces = set(prefix_map.values())
        for prefix, namespace_uri in candidate.namespaces():
            ns = str(namespace_uri)
            if (
                not prefix
                or ns in known_namespaces
                or ns in RDFLIB_DEFAULT_NAMESPACE_URIS
            ):
                continue
            prefix_map[prefix] = ns
        return candidate, prefix_map

    async def _aresolve_merged_context(
        self,
        *,
        entity_uris: list[str],
        ontology_iris: list[str],
        catalog: list[Ontology] | None,
        ontology_version_filters: dict[str, set[str]] | None,
        ontology_hash_filters: dict[str, set[str]] | None,
        depth: int,
        candidate_pushdown: bool,
    ) -> tuple[RDFGraph, dict[str, str]] | None:
        """Resolve the merged ontology context through the catalog, or ``None``.

        Returning ``None`` leaves the induced-subgraph call on its own fetch path,
        which is what happens when no catalog is registered or a read fails.
        ``catalog`` being set means the reference-expansion fallback already
        materialized everything, so there is nothing left to save here.

        Args:
            ontology_iris: Ontology IRIs surviving reference expansion.
            catalog: Ontologies already materialized by the fallback path, if any.
            ontology_version_filters: Allowed versions per ontology IRI.
            ontology_hash_filters: Allowed hashes per ontology IRI.

        Returns:
            tuple | None: ``(merged_graph, prefix_map)``, or ``None`` to fall back.
        """
        manager = self.ontology_manager
        if manager is None or catalog is not None:
            return None
        store = self.sparql_tool.triple_store_manager if self.sparql_tool else None
        use_pushdown = (
            candidate_pushdown
            and store is not None
            and store.supports_sparql_construct()
        )
        try:
            if use_pushdown:
                merged = await self._acandidate_context(
                    entity_uris=entity_uris,
                    ontology_iris=ontology_iris,
                    ontology_version_filters=ontology_version_filters,
                    ontology_hash_filters=ontology_hash_filters,
                    depth=depth,
                )
                mode = "sparql_candidate"
            else:
                selected = select_relevant_ontologies(
                    await manager.aget_ontologies_by_iri(ontology_iris),
                    ontology_iris,
                    ontology_version_filters,
                    ontology_hash_filters,
                )
                merged = await manager.aget_merged_graph(selected)
                mode = "merged_catalog"
        except Exception as exc:
            logger.warning(
                "Catalog context via OntologyManager failed (%s); "
                "falling back to a direct triple-store read",
                exc,
            )
            return None
        self._last_retrieval_metrics.update(manager.catalog_cache_stats())
        self._last_retrieval_metrics["catalog_context_mode"] = mode
        self._last_retrieval_metrics["catalog_context_triples"] = len(merged[0])
        return merged

    def _effective_top_k(self, top_k: int | None) -> int:
        if top_k is not None:
            return top_k
        return self.vector_store.store_config.top_k

    def _resolve_subgraph_budget(
        self,
        subgraph_depth: int | None,
        max_total_triples: int | None,
        estimated_triples_per_query: int | None,
    ) -> tuple[int, int, int]:
        """Fill unset induced-subgraph budget arguments from configuration."""
        sc = self.vector_store.store_config
        return (
            sc.induced_subgraph_depth if subgraph_depth is None else subgraph_depth,
            (
                sc.induced_subgraph_max_total_triples
                if max_total_triples is None
                else max_total_triples
            ),
            (
                sc.induced_subgraph_estimated_triples_per_query
                if estimated_triples_per_query is None
                else estimated_triples_per_query
            ),
        )

    def retrieve(
        self,
        query: str,
        top_k: int | None = None,
        expand_sparql: bool = True,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
    ) -> tuple[RDFGraph, list[str]]:
        """Retrieve top-k hits for one query and optional induced subgraph; returns source ontology IRIs."""
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            return asyncio.run(
                self.aretrieve(
                    query=query,
                    top_k=top_k,
                    expand_sparql=expand_sparql,
                    subgraph_depth=subgraph_depth,
                    max_total_triples=max_total_triples,
                    estimated_triples_per_query=estimated_triples_per_query,
                )
            )
        raise RuntimeError(
            "retrieve() cannot be called from async code; use await aretrieve()"
        )

    def retrieve_ensemble(
        self,
        queries: list[str],
        top_k: int | None = None,
        expand_sparql: bool = True,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
        trigger_text: str | None = None,
    ) -> tuple[RDFGraph, list[str]]:
        """Sync: one induced graph and source IRIs for the union of vector hits over ``queries``."""
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            return asyncio.run(
                self.aretrieve_ensemble(
                    queries=queries,
                    top_k=top_k,
                    expand_sparql=expand_sparql,
                    subgraph_depth=subgraph_depth,
                    max_total_triples=max_total_triples,
                    estimated_triples_per_query=estimated_triples_per_query,
                    trigger_text=trigger_text,
                )
            )
        raise RuntimeError(
            "retrieve_ensemble() is not allowed inside async code; use aretrieve_ensemble()"
        )

    async def aretrieve(
        self,
        query: str,
        top_k: int | None = None,
        expand_sparql: bool = True,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
        trigger_text: str | None = None,
    ) -> tuple[RDFGraph, list[str]]:
        """Async single-query variant of :meth:`aretrieve_ensemble`."""
        return await self.aretrieve_ensemble(
            queries=[query],
            top_k=top_k,
            expand_sparql=expand_sparql,
            subgraph_depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
            trigger_text=trigger_text,
        )

    async def aretrieve_ensemble(
        self,
        queries: list[str],
        top_k: int | None = None,
        expand_sparql: bool = True,
        subgraph_depth: int | None = None,
        max_total_triples: int | None = None,
        estimated_triples_per_query: int | None = None,
        trigger_text: str | None = None,
    ) -> tuple[RDFGraph, list[str]]:
        """Vector search over all ``queries`` once, score-filter, dedupe, single subgraph expansion.

        ``subgraph_depth`` / ``max_total_triples`` / ``estimated_triples_per_query``
        default to the configured values (``ONTOLOGY_PATCH_INDUCED_SUBGRAPH_*``).
        They previously carried literal defaults of 1 / 300 / 24, which
        contradicted the config defaults of 2 / 1200 / 24: the pipeline passed
        config explicitly and was unaffected, but any other caller of this
        public API silently got a 4x smaller snapshot than the deployment was
        configured for.
        """
        self._last_retrieval_metrics = {}
        subgraph_depth, max_total_triples, estimated_triples_per_query = (
            self._resolve_subgraph_budget(
                subgraph_depth, max_total_triples, estimated_triples_per_query
            )
        )
        trigger_source = (trigger_text or "").strip()
        if not queries and not trigger_source:
            return RDFGraph(), []

        eff_top_k = self._effective_top_k(top_k)
        hits_by_query: list[OntologySearchHitsByChannel] = []
        if queries:
            hits_by_query = await self.vector_store.asearch_patch_hits_many(
                queries=queries,
                top_k=eff_top_k,
            )
        sc = self.vector_store.store_config
        pc = self.patch
        eff_max_atoms = pc.effective_max_atoms(len(queries))
        merged = _filter_and_merge_patch_hits(
            hits_by_query,
            store_config=sc,
            patch_config=pc,
            per_query_core_score_ratio=pc.per_query_core_score_ratio,
            per_query_neighborhood_score_ratio=pc.per_query_neighborhood_score_ratio,
            per_query_bm25_score_ratio=pc.per_query_bm25_score_ratio,
            min_core_query_best_score=pc.min_core_query_best_score,
            min_neighborhood_query_best_score=pc.min_neighborhood_query_best_score,
            min_bm25_query_best_score=pc.min_bm25_query_best_score,
            min_merged_max_score=pc.min_merged_max_score,
            max_atoms_total=0,
        )
        atoms_after_dedupe = len(merged)
        merged = [atom for atom in merged if not _is_ontology_declaration_atom(atom)]

        if merged and pc.merged_score_ratio > 0.0:
            merged_top = float(merged[0].score or 0.0)
            merged_floor = merged_top * pc.merged_score_ratio
            merged = [
                atom for atom in merged if float(atom.score or 0.0) >= merged_floor
            ]

        ranked_before_cut = list(merged)

        if merged and pc.mmr_lambda < 1.0:
            merged = _normalize_relevance_scores(merged)
            vectors = await self.vector_store.afetch_vectors(
                [atom.atom_id for atom in merged]
            )
            core_w, neigh_w = normalized_core_neighborhood_weights(sc)
            merged = _mmr_rerank(
                merged,
                vectors,
                mmr_lambda=pc.mmr_lambda,
                max_atoms=eff_max_atoms,
                core_weight=core_w,
                neighborhood_weight=neigh_w,
            )
        elif pc.cross_query_merge_mode in (
            CrossQueryMergeMode.MAX_SCORE,
            CrossQueryMergeMode.SUM_SCORE,
        ):
            merged = _select_atoms_round_robin_by_ontology(
                merged,
                per_ontology_seed_quota=pc.per_ontology_seed_quota,
                max_atoms=eff_max_atoms,
                per_ontology_atom_floor=pc.per_ontology_atom_floor,
                per_role_atom_floor=pc.per_role_atom_floor,
            )
        elif eff_max_atoms > 0:
            merged = merged[:eff_max_atoms]

        trigger_source = trigger_source or " ".join(queries)
        trigger_atoms = await asyncio.to_thread(
            self.vector_store.match_lexical_triggers, trigger_source
        )
        merged, trigger_promoted, trigger_appended = _merge_lexical_trigger_atoms(
            merged, trigger_atoms, fusion=sc.lexical_trigger_fusion
        )
        # After the trigger merge: an exact-case trigger hit is positive
        # evidence and exempts the atom; what remains penalizable is the
        # case-folded BM25/dense residue.
        merged, symbol_case_penalized = _demote_case_mismatched_symbol_atoms(
            merged,
            trigger_source,
            policy=sc.symbol_case_mismatch_policy,
            demote_factor=sc.symbol_case_mismatch_demote_factor,
        )

        if not merged:
            self._last_retrieval_metrics = {
                "query_count": len(queries),
                "top_k": eff_top_k,
                "effective_max_atoms": eff_max_atoms,
                "atoms_after_dedupe": atoms_after_dedupe,
                "atoms_final": 0,
                "seed_iris": [],
                "lexical_trigger_hits": len(trigger_atoms),
                "lexical_trigger_atom_ids": [a.atom_id for a in trigger_atoms],
                "lexical_trigger_promoted": trigger_promoted,
                "lexical_trigger_appended": trigger_appended,
                "symbol_case_penalized": symbol_case_penalized,
            }
            if pc.dump_ontology_ranks:
                self._last_retrieval_metrics["ontology_rank_diagnostics"] = (
                    build_ontology_rank_diagnostics(
                        hits_by_query, ranked_before_cut, []
                    )
                )
            return RDFGraph(), []

        source_iris = _source_iris_from_atoms(merged)
        seeds_by_ontology: dict[str, int] = defaultdict(int)
        for atom in merged:
            if atom.ontology_iri:
                seeds_by_ontology[atom.ontology_iri] += 1

        self._last_retrieval_metrics = {
            "query_count": len(queries),
            "top_k": eff_top_k,
            "effective_max_atoms": eff_max_atoms,
            "merge_mode": pc.cross_query_merge_mode.value,
            "atoms_after_dedupe": atoms_after_dedupe,
            "atoms_final": len(merged),
            "seed_iris": [atom.iri for atom in merged if atom.iri],
            "source_ontology_iris": source_iris,
            "seeds_by_ontology": dict(seeds_by_ontology),
            "lexical_trigger_hits": len(trigger_atoms),
            "lexical_trigger_atom_ids": [a.atom_id for a in trigger_atoms],
            "lexical_trigger_iris": [a.iri for a in trigger_atoms if a.iri],
            "lexical_trigger_promoted": trigger_promoted,
            "lexical_trigger_appended": trigger_appended,
            "symbol_case_penalized": symbol_case_penalized,
        }
        if pc.dump_ontology_ranks:
            self._last_retrieval_metrics["ontology_rank_diagnostics"] = (
                build_ontology_rank_diagnostics(
                    hits_by_query, ranked_before_cut, merged
                )
            )

        if not expand_sparql or self.sparql_tool is None:
            return RDFGraph(), source_iris

        entity_uris, entity_relevance, entity_roles = _ranked_entity_weights(merged)
        signal_entities = self._match_query_unit_signals(trigger_source)
        for signal_iri, signal_onto_iri in sorted(signal_entities.items()):
            if signal_iri in entity_relevance:
                continue
            entity_uris.append(signal_iri)
            entity_relevance[signal_iri] = sc.lexical_trigger_score
            entity_roles[signal_iri] = "resource"
        if signal_entities:
            self._last_retrieval_metrics["query_signal_iris"] = sorted(
                signal_entities.keys()
            )
        hit_ontology_iris = sorted(
            {atom.ontology_iri for atom in merged if atom.ontology_iri}
            | set(signal_entities.values())
        )
        ontology_version_filters: dict[str, set[str]] = {}
        ontology_hash_filters: dict[str, set[str]] = {}
        for atom in merged:
            if atom.ontology_iri and atom.ontology_version:
                ontology_version_filters.setdefault(atom.ontology_iri, set()).add(
                    str(atom.ontology_version)
                )
            if atom.ontology_iri and atom.ontology_hash:
                ontology_hash_filters.setdefault(atom.ontology_iri, set()).add(
                    atom.ontology_hash
                )

        ontology_iris = hit_ontology_iris
        catalog: list[Ontology] | None = None
        triple_store_manager = self.sparql_tool.triple_store_manager
        if triple_store_manager is not None:
            ontology_iris, catalog, expansion_metrics = await _aexpand_ontology_iris(
                triple_store_manager, entity_uris, hit_ontology_iris
            )
            expanded = sorted(set(ontology_iris) - set(hit_ontology_iris))
            if expanded:
                self._last_retrieval_metrics["expanded_ontology_iris"] = expanded
            self._last_retrieval_metrics.update(expansion_metrics)

        merged_context = await self._aresolve_merged_context(
            entity_uris=entity_uris,
            ontology_iris=ontology_iris,
            catalog=catalog,
            ontology_version_filters=ontology_version_filters or None,
            ontology_hash_filters=ontology_hash_filters or None,
            depth=subgraph_depth,
            candidate_pushdown=sc.induced_subgraph_candidate_pushdown,
        )

        schema_graph = self._schema_axiom_graph(merged_context, catalog)
        if schema_graph is not None:
            closure = _schema_closure_entities(
                schema_graph,
                entity_uris,
                max_entities=pc.schema_closure_max_entities,
                ancestor_depth=pc.schema_closure_ancestor_depth,
                seed_relevance=entity_relevance,
            )
            if closure:
                closure_score = _closure_floor_score(entity_relevance)
                for closure_iri, closure_role in closure.items():
                    entity_uris.append(closure_iri)
                    entity_relevance[closure_iri] = closure_score
                    entity_roles[closure_iri] = closure_role
                self._last_retrieval_metrics["schema_closure_iris"] = sorted(closure)

        hub_seed_count = sc.induced_subgraph_hub_seed_count
        ancestor_depth = sc.induced_subgraph_ancestor_closure_depth
        entity_groups: dict[str, str] = {
            atom.iri: atom.ontology_iri
            for atom in merged
            if atom.iri and atom.ontology_iri
        }
        for signal_iri, signal_onto_iri in signal_entities.items():
            entity_groups.setdefault(signal_iri, signal_onto_iri)
        symbol_predicates = tuple(
            URIRef(iri) for iri in sc.induced_subgraph_symbol_predicates
        )

        graph = await self.sparql_tool.aget_induced_subgraph(
            ontologies=catalog,
            merged=merged_context,
            entity_uris=entity_uris,
            entity_relevance=entity_relevance,
            entity_roles=entity_roles,
            ontology_iris=ontology_iris,
            depth=subgraph_depth,
            max_total_triples=max_total_triples,
            estimated_triples_per_query=estimated_triples_per_query,
            ontology_version_filters=ontology_version_filters or None,
            ontology_hash_filters=ontology_hash_filters or None,
            hub_seed_count=hub_seed_count,
            ancestor_closure_depth=ancestor_depth,
            type_promotion_score_factor=(
                sc.induced_subgraph_type_promotion_score_factor
            ),
            seed_order=sc.induced_subgraph_seed_order.value,
            entity_groups=entity_groups,
            extra_description_predicates=symbol_predicates,
        )
        await self._apply_small_module_closure(graph, hit_ontology_iris)

        self._last_retrieval_metrics["snapshot_triple_count"] = len(graph)
        self._last_retrieval_metrics["ontology_iris_for_expansion"] = ontology_iris
        self._last_retrieval_metrics.update(self.sparql_tool.last_finalize_metrics)

        _bind_common_vocab_prefixes(graph)
        return graph, source_iris

aretrieve(query, top_k=None, expand_sparql=True, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None, trigger_text=None) async

Async single-query variant of :meth:aretrieve_ensemble.

Source code in ontocast/tool/vector_store/patch_retriever.py
async def aretrieve(
    self,
    query: str,
    top_k: int | None = None,
    expand_sparql: bool = True,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
    trigger_text: str | None = None,
) -> tuple[RDFGraph, list[str]]:
    """Async single-query variant of :meth:`aretrieve_ensemble`."""
    return await self.aretrieve_ensemble(
        queries=[query],
        top_k=top_k,
        expand_sparql=expand_sparql,
        subgraph_depth=subgraph_depth,
        max_total_triples=max_total_triples,
        estimated_triples_per_query=estimated_triples_per_query,
        trigger_text=trigger_text,
    )

aretrieve_ensemble(queries, top_k=None, expand_sparql=True, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None, trigger_text=None) async

Vector search over all queries once, score-filter, dedupe, single subgraph expansion.

subgraph_depth / max_total_triples / estimated_triples_per_query default to the configured values (ONTOLOGY_PATCH_INDUCED_SUBGRAPH_*). They previously carried literal defaults of 1 / 300 / 24, which contradicted the config defaults of 2 / 1200 / 24: the pipeline passed config explicitly and was unaffected, but any other caller of this public API silently got a 4x smaller snapshot than the deployment was configured for.

Source code in ontocast/tool/vector_store/patch_retriever.py
async def aretrieve_ensemble(
    self,
    queries: list[str],
    top_k: int | None = None,
    expand_sparql: bool = True,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
    trigger_text: str | None = None,
) -> tuple[RDFGraph, list[str]]:
    """Vector search over all ``queries`` once, score-filter, dedupe, single subgraph expansion.

    ``subgraph_depth`` / ``max_total_triples`` / ``estimated_triples_per_query``
    default to the configured values (``ONTOLOGY_PATCH_INDUCED_SUBGRAPH_*``).
    They previously carried literal defaults of 1 / 300 / 24, which
    contradicted the config defaults of 2 / 1200 / 24: the pipeline passed
    config explicitly and was unaffected, but any other caller of this
    public API silently got a 4x smaller snapshot than the deployment was
    configured for.
    """
    self._last_retrieval_metrics = {}
    subgraph_depth, max_total_triples, estimated_triples_per_query = (
        self._resolve_subgraph_budget(
            subgraph_depth, max_total_triples, estimated_triples_per_query
        )
    )
    trigger_source = (trigger_text or "").strip()
    if not queries and not trigger_source:
        return RDFGraph(), []

    eff_top_k = self._effective_top_k(top_k)
    hits_by_query: list[OntologySearchHitsByChannel] = []
    if queries:
        hits_by_query = await self.vector_store.asearch_patch_hits_many(
            queries=queries,
            top_k=eff_top_k,
        )
    sc = self.vector_store.store_config
    pc = self.patch
    eff_max_atoms = pc.effective_max_atoms(len(queries))
    merged = _filter_and_merge_patch_hits(
        hits_by_query,
        store_config=sc,
        patch_config=pc,
        per_query_core_score_ratio=pc.per_query_core_score_ratio,
        per_query_neighborhood_score_ratio=pc.per_query_neighborhood_score_ratio,
        per_query_bm25_score_ratio=pc.per_query_bm25_score_ratio,
        min_core_query_best_score=pc.min_core_query_best_score,
        min_neighborhood_query_best_score=pc.min_neighborhood_query_best_score,
        min_bm25_query_best_score=pc.min_bm25_query_best_score,
        min_merged_max_score=pc.min_merged_max_score,
        max_atoms_total=0,
    )
    atoms_after_dedupe = len(merged)
    merged = [atom for atom in merged if not _is_ontology_declaration_atom(atom)]

    if merged and pc.merged_score_ratio > 0.0:
        merged_top = float(merged[0].score or 0.0)
        merged_floor = merged_top * pc.merged_score_ratio
        merged = [
            atom for atom in merged if float(atom.score or 0.0) >= merged_floor
        ]

    ranked_before_cut = list(merged)

    if merged and pc.mmr_lambda < 1.0:
        merged = _normalize_relevance_scores(merged)
        vectors = await self.vector_store.afetch_vectors(
            [atom.atom_id for atom in merged]
        )
        core_w, neigh_w = normalized_core_neighborhood_weights(sc)
        merged = _mmr_rerank(
            merged,
            vectors,
            mmr_lambda=pc.mmr_lambda,
            max_atoms=eff_max_atoms,
            core_weight=core_w,
            neighborhood_weight=neigh_w,
        )
    elif pc.cross_query_merge_mode in (
        CrossQueryMergeMode.MAX_SCORE,
        CrossQueryMergeMode.SUM_SCORE,
    ):
        merged = _select_atoms_round_robin_by_ontology(
            merged,
            per_ontology_seed_quota=pc.per_ontology_seed_quota,
            max_atoms=eff_max_atoms,
            per_ontology_atom_floor=pc.per_ontology_atom_floor,
            per_role_atom_floor=pc.per_role_atom_floor,
        )
    elif eff_max_atoms > 0:
        merged = merged[:eff_max_atoms]

    trigger_source = trigger_source or " ".join(queries)
    trigger_atoms = await asyncio.to_thread(
        self.vector_store.match_lexical_triggers, trigger_source
    )
    merged, trigger_promoted, trigger_appended = _merge_lexical_trigger_atoms(
        merged, trigger_atoms, fusion=sc.lexical_trigger_fusion
    )
    # After the trigger merge: an exact-case trigger hit is positive
    # evidence and exempts the atom; what remains penalizable is the
    # case-folded BM25/dense residue.
    merged, symbol_case_penalized = _demote_case_mismatched_symbol_atoms(
        merged,
        trigger_source,
        policy=sc.symbol_case_mismatch_policy,
        demote_factor=sc.symbol_case_mismatch_demote_factor,
    )

    if not merged:
        self._last_retrieval_metrics = {
            "query_count": len(queries),
            "top_k": eff_top_k,
            "effective_max_atoms": eff_max_atoms,
            "atoms_after_dedupe": atoms_after_dedupe,
            "atoms_final": 0,
            "seed_iris": [],
            "lexical_trigger_hits": len(trigger_atoms),
            "lexical_trigger_atom_ids": [a.atom_id for a in trigger_atoms],
            "lexical_trigger_promoted": trigger_promoted,
            "lexical_trigger_appended": trigger_appended,
            "symbol_case_penalized": symbol_case_penalized,
        }
        if pc.dump_ontology_ranks:
            self._last_retrieval_metrics["ontology_rank_diagnostics"] = (
                build_ontology_rank_diagnostics(
                    hits_by_query, ranked_before_cut, []
                )
            )
        return RDFGraph(), []

    source_iris = _source_iris_from_atoms(merged)
    seeds_by_ontology: dict[str, int] = defaultdict(int)
    for atom in merged:
        if atom.ontology_iri:
            seeds_by_ontology[atom.ontology_iri] += 1

    self._last_retrieval_metrics = {
        "query_count": len(queries),
        "top_k": eff_top_k,
        "effective_max_atoms": eff_max_atoms,
        "merge_mode": pc.cross_query_merge_mode.value,
        "atoms_after_dedupe": atoms_after_dedupe,
        "atoms_final": len(merged),
        "seed_iris": [atom.iri for atom in merged if atom.iri],
        "source_ontology_iris": source_iris,
        "seeds_by_ontology": dict(seeds_by_ontology),
        "lexical_trigger_hits": len(trigger_atoms),
        "lexical_trigger_atom_ids": [a.atom_id for a in trigger_atoms],
        "lexical_trigger_iris": [a.iri for a in trigger_atoms if a.iri],
        "lexical_trigger_promoted": trigger_promoted,
        "lexical_trigger_appended": trigger_appended,
        "symbol_case_penalized": symbol_case_penalized,
    }
    if pc.dump_ontology_ranks:
        self._last_retrieval_metrics["ontology_rank_diagnostics"] = (
            build_ontology_rank_diagnostics(
                hits_by_query, ranked_before_cut, merged
            )
        )

    if not expand_sparql or self.sparql_tool is None:
        return RDFGraph(), source_iris

    entity_uris, entity_relevance, entity_roles = _ranked_entity_weights(merged)
    signal_entities = self._match_query_unit_signals(trigger_source)
    for signal_iri, signal_onto_iri in sorted(signal_entities.items()):
        if signal_iri in entity_relevance:
            continue
        entity_uris.append(signal_iri)
        entity_relevance[signal_iri] = sc.lexical_trigger_score
        entity_roles[signal_iri] = "resource"
    if signal_entities:
        self._last_retrieval_metrics["query_signal_iris"] = sorted(
            signal_entities.keys()
        )
    hit_ontology_iris = sorted(
        {atom.ontology_iri for atom in merged if atom.ontology_iri}
        | set(signal_entities.values())
    )
    ontology_version_filters: dict[str, set[str]] = {}
    ontology_hash_filters: dict[str, set[str]] = {}
    for atom in merged:
        if atom.ontology_iri and atom.ontology_version:
            ontology_version_filters.setdefault(atom.ontology_iri, set()).add(
                str(atom.ontology_version)
            )
        if atom.ontology_iri and atom.ontology_hash:
            ontology_hash_filters.setdefault(atom.ontology_iri, set()).add(
                atom.ontology_hash
            )

    ontology_iris = hit_ontology_iris
    catalog: list[Ontology] | None = None
    triple_store_manager = self.sparql_tool.triple_store_manager
    if triple_store_manager is not None:
        ontology_iris, catalog, expansion_metrics = await _aexpand_ontology_iris(
            triple_store_manager, entity_uris, hit_ontology_iris
        )
        expanded = sorted(set(ontology_iris) - set(hit_ontology_iris))
        if expanded:
            self._last_retrieval_metrics["expanded_ontology_iris"] = expanded
        self._last_retrieval_metrics.update(expansion_metrics)

    merged_context = await self._aresolve_merged_context(
        entity_uris=entity_uris,
        ontology_iris=ontology_iris,
        catalog=catalog,
        ontology_version_filters=ontology_version_filters or None,
        ontology_hash_filters=ontology_hash_filters or None,
        depth=subgraph_depth,
        candidate_pushdown=sc.induced_subgraph_candidate_pushdown,
    )

    schema_graph = self._schema_axiom_graph(merged_context, catalog)
    if schema_graph is not None:
        closure = _schema_closure_entities(
            schema_graph,
            entity_uris,
            max_entities=pc.schema_closure_max_entities,
            ancestor_depth=pc.schema_closure_ancestor_depth,
            seed_relevance=entity_relevance,
        )
        if closure:
            closure_score = _closure_floor_score(entity_relevance)
            for closure_iri, closure_role in closure.items():
                entity_uris.append(closure_iri)
                entity_relevance[closure_iri] = closure_score
                entity_roles[closure_iri] = closure_role
            self._last_retrieval_metrics["schema_closure_iris"] = sorted(closure)

    hub_seed_count = sc.induced_subgraph_hub_seed_count
    ancestor_depth = sc.induced_subgraph_ancestor_closure_depth
    entity_groups: dict[str, str] = {
        atom.iri: atom.ontology_iri
        for atom in merged
        if atom.iri and atom.ontology_iri
    }
    for signal_iri, signal_onto_iri in signal_entities.items():
        entity_groups.setdefault(signal_iri, signal_onto_iri)
    symbol_predicates = tuple(
        URIRef(iri) for iri in sc.induced_subgraph_symbol_predicates
    )

    graph = await self.sparql_tool.aget_induced_subgraph(
        ontologies=catalog,
        merged=merged_context,
        entity_uris=entity_uris,
        entity_relevance=entity_relevance,
        entity_roles=entity_roles,
        ontology_iris=ontology_iris,
        depth=subgraph_depth,
        max_total_triples=max_total_triples,
        estimated_triples_per_query=estimated_triples_per_query,
        ontology_version_filters=ontology_version_filters or None,
        ontology_hash_filters=ontology_hash_filters or None,
        hub_seed_count=hub_seed_count,
        ancestor_closure_depth=ancestor_depth,
        type_promotion_score_factor=(
            sc.induced_subgraph_type_promotion_score_factor
        ),
        seed_order=sc.induced_subgraph_seed_order.value,
        entity_groups=entity_groups,
        extra_description_predicates=symbol_predicates,
    )
    await self._apply_small_module_closure(graph, hit_ontology_iris)

    self._last_retrieval_metrics["snapshot_triple_count"] = len(graph)
    self._last_retrieval_metrics["ontology_iris_for_expansion"] = ontology_iris
    self._last_retrieval_metrics.update(self.sparql_tool.last_finalize_metrics)

    _bind_common_vocab_prefixes(graph)
    return graph, source_iris

retrieve(query, top_k=None, expand_sparql=True, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None)

Retrieve top-k hits for one query and optional induced subgraph; returns source ontology IRIs.

Source code in ontocast/tool/vector_store/patch_retriever.py
def retrieve(
    self,
    query: str,
    top_k: int | None = None,
    expand_sparql: bool = True,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
) -> tuple[RDFGraph, list[str]]:
    """Retrieve top-k hits for one query and optional induced subgraph; returns source ontology IRIs."""
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(
            self.aretrieve(
                query=query,
                top_k=top_k,
                expand_sparql=expand_sparql,
                subgraph_depth=subgraph_depth,
                max_total_triples=max_total_triples,
                estimated_triples_per_query=estimated_triples_per_query,
            )
        )
    raise RuntimeError(
        "retrieve() cannot be called from async code; use await aretrieve()"
    )

retrieve_ensemble(queries, top_k=None, expand_sparql=True, subgraph_depth=None, max_total_triples=None, estimated_triples_per_query=None, trigger_text=None)

Source code in ontocast/tool/vector_store/patch_retriever.py
def retrieve_ensemble(
    self,
    queries: list[str],
    top_k: int | None = None,
    expand_sparql: bool = True,
    subgraph_depth: int | None = None,
    max_total_triples: int | None = None,
    estimated_triples_per_query: int | None = None,
    trigger_text: str | None = None,
) -> tuple[RDFGraph, list[str]]:
    """Sync: one induced graph and source IRIs for the union of vector hits over ``queries``."""
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(
            self.aretrieve_ensemble(
                queries=queries,
                top_k=top_k,
                expand_sparql=expand_sparql,
                subgraph_depth=subgraph_depth,
                max_total_triples=max_total_triples,
                estimated_triples_per_query=estimated_triples_per_query,
                trigger_text=trigger_text,
            )
        )
    raise RuntimeError(
        "retrieve_ensemble() is not allowed inside async code; use aretrieve_ensemble()"
    )

SearchHit

Bases: BaseModel

Single web-search hit used as optional grounding context.

Source code in ontocast/tool/atomic.py
class SearchHit(BaseModel):
    """Single web-search hit used as optional grounding context."""

    title: str
    url: str
    snippet: str

Tool

Bases: BasePydanticModel

Base class for all OntoCast tools.

This class serves as the foundation for all tools in the OntoCast system. It provides common functionality and interface that all tools must implement. Tools should inherit from this class and implement their specific functionality.

Source code in ontocast/tool/onto.py
class Tool(BasePydanticModel):
    """Base class for all OntoCast tools.

    This class serves as the foundation for all tools in the OntoCast system.
    It provides common functionality and interface that all tools must implement.
    Tools should inherit from this class and implement their specific functionality.

    Attributes:
        Inherits all attributes from BasePydanticModel.
    """

    def __init__(self, **kwargs):
        """Initialize the tool.

        Args:
            **kwargs: Keyword arguments passed to the parent class.
        """
        super().__init__(**kwargs)

__init__(**kwargs)

Initialize the tool.

Parameters:

Name Type Description Default
**kwargs

Keyword arguments passed to the parent class.

{}
Source code in ontocast/tool/onto.py
def __init__(self, **kwargs):
    """Initialize the tool.

    Args:
        **kwargs: Keyword arguments passed to the parent class.
    """
    super().__init__(**kwargs)

TripleStoreManager

Bases: Tool

Base class for managing RDF triple stores.

This class defines the interface for triple store management operations, including fetching and storing ontologies and their graphs. All concrete triple store implementations should inherit from this class.

This is an abstract base class that must be implemented by specific triple store backends (e.g., Fuseki, In-Memory).

Source code in ontocast/tool/triple_manager/core.py
class TripleStoreManager(Tool):
    """Base class for managing RDF triple stores.

    This class defines the interface for triple store management operations,
    including fetching and storing ontologies and their graphs. All concrete
    triple store implementations should inherit from this class.

    This is an abstract base class that must be implemented by specific
    triple store backends (e.g., Fuseki, In-Memory).
    """

    def __init__(self, **kwargs):
        """Initialize the triple store manager.

        Args:
            **kwargs: Additional keyword arguments passed to the parent class.
        """
        super().__init__(**kwargs)

    @abc.abstractmethod
    def fetch_ontologies(self) -> list[Ontology]:
        """Fetch all available ontologies from the triple store.

        This method should retrieve all ontologies stored in the triple store
        and return them as Ontology objects with their associated RDF graphs.

        Returns:
            list[Ontology]: List of available ontologies with their graphs.
        """
        return []

    async def afetch_ontologies(self) -> list[Ontology]:
        """Async fetch helper for backends without native async I/O."""
        return await asyncio.to_thread(self.fetch_ontologies)

    @abc.abstractmethod
    def serialize_graph(self, graph: Graph, **kwargs) -> bool:
        """Store an RDF graph in the triple store."""
        pass

    async def aserialize_graph(self, graph: Graph, **kwargs) -> bool:
        """Async serialize helper for backends without native async I/O."""
        return await asyncio.to_thread(self.serialize_graph, graph, **kwargs)

    @abc.abstractmethod
    def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        """Store an Ontology or RDFGraph in the triple store."""
        pass

    async def aserialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
        """Async serialize helper for backends without native async I/O."""
        return await asyncio.to_thread(self.serialize, o, **kwargs)

    async def async_init(self) -> None:
        """Backend warmup (e.g. ensure datasets exist). No-op by default."""

    async def update_tenancy(
        self,
        tenant: str,
        project: str,
        *,
        sep: str = TENANCY_SEP,
    ) -> None:
        """Switch the active tenant/project partition when supported."""
        if not self.supports_tenancy_partition():
            raise NotImplementedError(
                f"{type(self).__name__} does not isolate data by tenant/project"
            )
        raise NotImplementedError(
            f"{type(self).__name__} must implement update_tenancy()"
        )

    async def drop_named_graph(
        self, graph_uri: str, *, use_ontologies_dataset: bool = True
    ) -> None:
        """Drop a single named graph."""
        raise NotImplementedError(
            f"{type(self).__name__} does not support drop_named_graph()"
        )

    async def drop_all_ontology_graphs_for_iri(self, ontology_iri: str) -> None:
        """Remove named graphs for ``ontology_iri`` (base and versioned)."""
        raise NotImplementedError(
            f"{type(self).__name__} does not support drop_all_ontology_graphs_for_iri()"
        )

    @classmethod
    def _provenance_source_nodes(cls, graph: Graph) -> set:
        """Return chunk/source nodes whose triples are provenance scaffolding."""
        derived_from = set(graph.objects(None, PROV.wasDerivedFrom))
        entity_nodes = set(graph.subjects(RDF.type, PROV.Entity))
        text_chunk_nodes = set(graph.subjects(RDF.type, SCHEMA.Text))
        chunk_metadata_nodes = entity_nodes & text_chunk_nodes
        return derived_from | chunk_metadata_nodes

    @classmethod
    def strip_provenance(cls, graph: Graph) -> RDFGraph:
        """Return a graph without reification/provenance scaffolding triples."""
        clean = RDFGraph()
        for prefix, namespace in graph.namespaces():
            clean.bind(prefix, namespace)

        reifier_nodes = set(graph.subjects(RDF_REIFIES, None))
        source_nodes = cls._provenance_source_nodes(graph)

        for subject, predicate, object_ in graph:
            if predicate in {RDF_REIFIES, PROV.wasDerivedFrom}:
                continue
            if subject in reifier_nodes:
                continue
            if subject in source_nodes:
                continue
            clean.add((subject, predicate, object_))

        return clean

    @abc.abstractmethod
    async def clean(self) -> None:
        """Clean/flush data managed by this store (backend-specific scope).

        Warning: This operation is irreversible and will delete data.

        Raises:
            NotImplementedError: If the triple store doesn't support cleaning.
        """
        raise NotImplementedError("clean() method must be implemented by subclasses")

    def supports_tenancy_partition(self) -> bool:
        """True if this backend isolates facts/ontologies by :func:`tenant_project_*` names."""
        return False

    async def close(self) -> None:
        """Release any connection held by this backend.

        Default is a no-op for in-process backends.
        """
        return None

    def last_catalog_was_complete(self) -> bool:
        """True when the most recent full catalog fetch returned every graph.

        Consulted before destructive reconciliation (vector-store orphan
        pruning): a backend that fetched only part of its catalog reports False
        so callers treat the result as non-authoritative rather than concluding
        that the missing ontologies were deleted. Backends that cannot fetch
        partially always report True.
        """
        return True

    def supports_sparql_select(self) -> bool:
        """True when :meth:`aselect` reaches a real SPARQL engine.

        Callers branch on this to choose targeted queries over materializing the
        whole catalog. Backends returning ``False`` still answer every catalog
        method correctly, just by fetching more than they need.
        """
        return False

    async def aselect(
        self, query: str, *, use_ontologies_dataset: bool = True
    ) -> list[dict[str, str]]:
        """Run a SPARQL SELECT against the active partition.

        Rows map variable name to the term's **lexical value** only; term kind and
        datatype are not preserved, so constrain kinds in the query itself
        (``FILTER(isIRI(?x))``). Unbound variables are absent from the row dict.

        Implementations must raise rather than return an empty list on failure --
        an empty result set is indistinguishable from "nothing matched", which
        would silently disable callers that treat no-rows as a valid answer.

        Args:
            query: A SPARQL SELECT query.
            use_ontologies_dataset: Query the ontologies partition rather than facts.

        Returns:
            list[dict[str, str]]: One dict per solution.

        Raises:
            NotImplementedError: If the backend has no SPARQL engine.
        """
        raise NotImplementedError(f"{type(self).__name__} does not support aselect()")

    def supports_sparql_construct(self) -> bool:
        """True when :meth:`aconstruct` reaches a real SPARQL engine.

        Separate from :meth:`supports_sparql_select` because a backend can answer
        row queries without being able to return triples: the Fuseki SELECT path
        speaks ``application/sparql-results+json`` only.
        """
        return False

    async def aconstruct(
        self, query: str, *, use_ontologies_dataset: bool = True
    ) -> RDFGraph:
        """Run a SPARQL CONSTRUCT against the active partition.

        Unlike :meth:`aselect`, the result carries real RDF terms, so blank nodes
        and datatypes survive. Prefix bindings do **not** -- they are serialization
        metadata rather than triples, and must be re-sourced by the caller.

        Implementations must raise rather than return an empty graph on failure,
        for the same reason :meth:`aselect` must raise: an empty result is
        indistinguishable from "nothing matched".

        Args:
            query: A SPARQL CONSTRUCT (or DESCRIBE) query.
            use_ontologies_dataset: Query the ontologies partition rather than facts.

        Returns:
            RDFGraph: The constructed triples, without prefix bindings.

        Raises:
            NotImplementedError: If the backend has no SPARQL engine.
        """
        raise NotImplementedError(
            f"{type(self).__name__} does not support aconstruct()"
        )

    async def afetch_ontology_catalog(self) -> list[OntologyHeader]:
        """Fetch per-named-graph ontology header metadata.

        Headers carry the lineage fields terminal-version selection needs without
        the graphs themselves. The default implementation materializes the catalog
        and derives headers from it; SPARQL-capable backends should override with a
        single SELECT.

        Note the default returns one header per *terminal* ontology (whatever
        :meth:`afetch_ontologies` returns), while a native implementation returns
        one per *stored version*. Callers that re-run terminal selection over the
        result are correct either way; that is why they should.

        Returns:
            list[OntologyHeader]: Header metadata for stored ontologies.
        """
        return [
            OntologyHeader.from_ontology(onto)
            for onto in await self.afetch_ontologies()
        ]

    async def afetch_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
        """Fetch terminal ontologies restricted to ``iris``.

        Args:
            iris: Ontology IRIs to fetch. Empty means "no restriction", matching
                how :meth:`ontocast.tool.sparql.SPARQLTool._build_induced_subgraph`
                treats an empty ontology filter.

        Returns:
            list[Ontology]: The requested ontologies, with graphs.
        """
        if not iris:
            return await self.afetch_ontologies()
        wanted = set(iris)
        return [onto for onto in await self.afetch_ontologies() if onto.iri in wanted]

    async def clean_tenancy(self, tenant: str, project: str) -> None:
        """Remove all triples for datasets derived from ``tenant`` / ``project``.

        Backends without per-tenant partitions raise :class:`NotImplementedError`.
        """
        raise NotImplementedError(
            f"{type(self).__name__} does not isolate data by tenant/project"
        )

__init__(**kwargs)

Initialize the triple store manager.

Parameters:

Name Type Description Default
**kwargs

Additional keyword arguments passed to the parent class.

{}
Source code in ontocast/tool/triple_manager/core.py
def __init__(self, **kwargs):
    """Initialize the triple store manager.

    Args:
        **kwargs: Additional keyword arguments passed to the parent class.
    """
    super().__init__(**kwargs)

aconstruct(query, *, use_ontologies_dataset=True) async

Run a SPARQL CONSTRUCT against the active partition.

Unlike :meth:aselect, the result carries real RDF terms, so blank nodes and datatypes survive. Prefix bindings do not -- they are serialization metadata rather than triples, and must be re-sourced by the caller.

Implementations must raise rather than return an empty graph on failure, for the same reason :meth:aselect must raise: an empty result is indistinguishable from "nothing matched".

Parameters:

Name Type Description Default
query str

A SPARQL CONSTRUCT (or DESCRIBE) query.

required
use_ontologies_dataset bool

Query the ontologies partition rather than facts.

True

Returns:

Name Type Description
RDFGraph RDFGraph

The constructed triples, without prefix bindings.

Raises:

Type Description
NotImplementedError

If the backend has no SPARQL engine.

Source code in ontocast/tool/triple_manager/core.py
async def aconstruct(
    self, query: str, *, use_ontologies_dataset: bool = True
) -> RDFGraph:
    """Run a SPARQL CONSTRUCT against the active partition.

    Unlike :meth:`aselect`, the result carries real RDF terms, so blank nodes
    and datatypes survive. Prefix bindings do **not** -- they are serialization
    metadata rather than triples, and must be re-sourced by the caller.

    Implementations must raise rather than return an empty graph on failure,
    for the same reason :meth:`aselect` must raise: an empty result is
    indistinguishable from "nothing matched".

    Args:
        query: A SPARQL CONSTRUCT (or DESCRIBE) query.
        use_ontologies_dataset: Query the ontologies partition rather than facts.

    Returns:
        RDFGraph: The constructed triples, without prefix bindings.

    Raises:
        NotImplementedError: If the backend has no SPARQL engine.
    """
    raise NotImplementedError(
        f"{type(self).__name__} does not support aconstruct()"
    )

afetch_ontologies() async

Async fetch helper for backends without native async I/O.

Source code in ontocast/tool/triple_manager/core.py
async def afetch_ontologies(self) -> list[Ontology]:
    """Async fetch helper for backends without native async I/O."""
    return await asyncio.to_thread(self.fetch_ontologies)

afetch_ontologies_by_iri(iris) async

Fetch terminal ontologies restricted to iris.

Parameters:

Name Type Description Default
iris Sequence[str]

Ontology IRIs to fetch. Empty means "no restriction", matching how :meth:ontocast.tool.sparql.SPARQLTool._build_induced_subgraph treats an empty ontology filter.

required

Returns:

Type Description
list[Ontology]

list[Ontology]: The requested ontologies, with graphs.

Source code in ontocast/tool/triple_manager/core.py
async def afetch_ontologies_by_iri(self, iris: Sequence[str]) -> list[Ontology]:
    """Fetch terminal ontologies restricted to ``iris``.

    Args:
        iris: Ontology IRIs to fetch. Empty means "no restriction", matching
            how :meth:`ontocast.tool.sparql.SPARQLTool._build_induced_subgraph`
            treats an empty ontology filter.

    Returns:
        list[Ontology]: The requested ontologies, with graphs.
    """
    if not iris:
        return await self.afetch_ontologies()
    wanted = set(iris)
    return [onto for onto in await self.afetch_ontologies() if onto.iri in wanted]

afetch_ontology_catalog() async

Fetch per-named-graph ontology header metadata.

Headers carry the lineage fields terminal-version selection needs without the graphs themselves. The default implementation materializes the catalog and derives headers from it; SPARQL-capable backends should override with a single SELECT.

Note the default returns one header per terminal ontology (whatever :meth:afetch_ontologies returns), while a native implementation returns one per stored version. Callers that re-run terminal selection over the result are correct either way; that is why they should.

Returns:

Type Description
list[OntologyHeader]

list[OntologyHeader]: Header metadata for stored ontologies.

Source code in ontocast/tool/triple_manager/core.py
async def afetch_ontology_catalog(self) -> list[OntologyHeader]:
    """Fetch per-named-graph ontology header metadata.

    Headers carry the lineage fields terminal-version selection needs without
    the graphs themselves. The default implementation materializes the catalog
    and derives headers from it; SPARQL-capable backends should override with a
    single SELECT.

    Note the default returns one header per *terminal* ontology (whatever
    :meth:`afetch_ontologies` returns), while a native implementation returns
    one per *stored version*. Callers that re-run terminal selection over the
    result are correct either way; that is why they should.

    Returns:
        list[OntologyHeader]: Header metadata for stored ontologies.
    """
    return [
        OntologyHeader.from_ontology(onto)
        for onto in await self.afetch_ontologies()
    ]

aselect(query, *, use_ontologies_dataset=True) async

Run a SPARQL SELECT against the active partition.

Rows map variable name to the term's lexical value only; term kind and datatype are not preserved, so constrain kinds in the query itself (FILTER(isIRI(?x))). Unbound variables are absent from the row dict.

Implementations must raise rather than return an empty list on failure -- an empty result set is indistinguishable from "nothing matched", which would silently disable callers that treat no-rows as a valid answer.

Parameters:

Name Type Description Default
query str

A SPARQL SELECT query.

required
use_ontologies_dataset bool

Query the ontologies partition rather than facts.

True

Returns:

Type Description
list[dict[str, str]]

list[dict[str, str]]: One dict per solution.

Raises:

Type Description
NotImplementedError

If the backend has no SPARQL engine.

Source code in ontocast/tool/triple_manager/core.py
async def aselect(
    self, query: str, *, use_ontologies_dataset: bool = True
) -> list[dict[str, str]]:
    """Run a SPARQL SELECT against the active partition.

    Rows map variable name to the term's **lexical value** only; term kind and
    datatype are not preserved, so constrain kinds in the query itself
    (``FILTER(isIRI(?x))``). Unbound variables are absent from the row dict.

    Implementations must raise rather than return an empty list on failure --
    an empty result set is indistinguishable from "nothing matched", which
    would silently disable callers that treat no-rows as a valid answer.

    Args:
        query: A SPARQL SELECT query.
        use_ontologies_dataset: Query the ontologies partition rather than facts.

    Returns:
        list[dict[str, str]]: One dict per solution.

    Raises:
        NotImplementedError: If the backend has no SPARQL engine.
    """
    raise NotImplementedError(f"{type(self).__name__} does not support aselect()")

aserialize(o, **kwargs) async

Async serialize helper for backends without native async I/O.

Source code in ontocast/tool/triple_manager/core.py
async def aserialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
    """Async serialize helper for backends without native async I/O."""
    return await asyncio.to_thread(self.serialize, o, **kwargs)

aserialize_graph(graph, **kwargs) async

Async serialize helper for backends without native async I/O.

Source code in ontocast/tool/triple_manager/core.py
async def aserialize_graph(self, graph: Graph, **kwargs) -> bool:
    """Async serialize helper for backends without native async I/O."""
    return await asyncio.to_thread(self.serialize_graph, graph, **kwargs)

async_init() async

Backend warmup (e.g. ensure datasets exist). No-op by default.

Source code in ontocast/tool/triple_manager/core.py
async def async_init(self) -> None:
    """Backend warmup (e.g. ensure datasets exist). No-op by default."""

clean() abstractmethod async

Clean/flush data managed by this store (backend-specific scope).

Warning: This operation is irreversible and will delete data.

Raises:

Type Description
NotImplementedError

If the triple store doesn't support cleaning.

Source code in ontocast/tool/triple_manager/core.py
@abc.abstractmethod
async def clean(self) -> None:
    """Clean/flush data managed by this store (backend-specific scope).

    Warning: This operation is irreversible and will delete data.

    Raises:
        NotImplementedError: If the triple store doesn't support cleaning.
    """
    raise NotImplementedError("clean() method must be implemented by subclasses")

clean_tenancy(tenant, project) async

Remove all triples for datasets derived from tenant / project.

Backends without per-tenant partitions raise :class:NotImplementedError.

Source code in ontocast/tool/triple_manager/core.py
async def clean_tenancy(self, tenant: str, project: str) -> None:
    """Remove all triples for datasets derived from ``tenant`` / ``project``.

    Backends without per-tenant partitions raise :class:`NotImplementedError`.
    """
    raise NotImplementedError(
        f"{type(self).__name__} does not isolate data by tenant/project"
    )

close() async

Release any connection held by this backend.

Default is a no-op for in-process backends.

Source code in ontocast/tool/triple_manager/core.py
async def close(self) -> None:
    """Release any connection held by this backend.

    Default is a no-op for in-process backends.
    """
    return None

drop_all_ontology_graphs_for_iri(ontology_iri) async

Remove named graphs for ontology_iri (base and versioned).

Source code in ontocast/tool/triple_manager/core.py
async def drop_all_ontology_graphs_for_iri(self, ontology_iri: str) -> None:
    """Remove named graphs for ``ontology_iri`` (base and versioned)."""
    raise NotImplementedError(
        f"{type(self).__name__} does not support drop_all_ontology_graphs_for_iri()"
    )

drop_named_graph(graph_uri, *, use_ontologies_dataset=True) async

Drop a single named graph.

Source code in ontocast/tool/triple_manager/core.py
async def drop_named_graph(
    self, graph_uri: str, *, use_ontologies_dataset: bool = True
) -> None:
    """Drop a single named graph."""
    raise NotImplementedError(
        f"{type(self).__name__} does not support drop_named_graph()"
    )

fetch_ontologies() abstractmethod

Fetch all available ontologies from the triple store.

This method should retrieve all ontologies stored in the triple store and return them as Ontology objects with their associated RDF graphs.

Returns:

Type Description
list[Ontology]

list[Ontology]: List of available ontologies with their graphs.

Source code in ontocast/tool/triple_manager/core.py
@abc.abstractmethod
def fetch_ontologies(self) -> list[Ontology]:
    """Fetch all available ontologies from the triple store.

    This method should retrieve all ontologies stored in the triple store
    and return them as Ontology objects with their associated RDF graphs.

    Returns:
        list[Ontology]: List of available ontologies with their graphs.
    """
    return []

last_catalog_was_complete()

True when the most recent full catalog fetch returned every graph.

Consulted before destructive reconciliation (vector-store orphan pruning): a backend that fetched only part of its catalog reports False so callers treat the result as non-authoritative rather than concluding that the missing ontologies were deleted. Backends that cannot fetch partially always report True.

Source code in ontocast/tool/triple_manager/core.py
def last_catalog_was_complete(self) -> bool:
    """True when the most recent full catalog fetch returned every graph.

    Consulted before destructive reconciliation (vector-store orphan
    pruning): a backend that fetched only part of its catalog reports False
    so callers treat the result as non-authoritative rather than concluding
    that the missing ontologies were deleted. Backends that cannot fetch
    partially always report True.
    """
    return True

serialize(o, **kwargs) abstractmethod

Store an Ontology or RDFGraph in the triple store.

Source code in ontocast/tool/triple_manager/core.py
@abc.abstractmethod
def serialize(self, o: Ontology | RDFGraph, **kwargs) -> bool:
    """Store an Ontology or RDFGraph in the triple store."""
    pass

serialize_graph(graph, **kwargs) abstractmethod

Store an RDF graph in the triple store.

Source code in ontocast/tool/triple_manager/core.py
@abc.abstractmethod
def serialize_graph(self, graph: Graph, **kwargs) -> bool:
    """Store an RDF graph in the triple store."""
    pass

strip_provenance(graph) classmethod

Return a graph without reification/provenance scaffolding triples.

Source code in ontocast/tool/triple_manager/core.py
@classmethod
def strip_provenance(cls, graph: Graph) -> RDFGraph:
    """Return a graph without reification/provenance scaffolding triples."""
    clean = RDFGraph()
    for prefix, namespace in graph.namespaces():
        clean.bind(prefix, namespace)

    reifier_nodes = set(graph.subjects(RDF_REIFIES, None))
    source_nodes = cls._provenance_source_nodes(graph)

    for subject, predicate, object_ in graph:
        if predicate in {RDF_REIFIES, PROV.wasDerivedFrom}:
            continue
        if subject in reifier_nodes:
            continue
        if subject in source_nodes:
            continue
        clean.add((subject, predicate, object_))

    return clean

supports_sparql_construct()

True when :meth:aconstruct reaches a real SPARQL engine.

Separate from :meth:supports_sparql_select because a backend can answer row queries without being able to return triples: the Fuseki SELECT path speaks application/sparql-results+json only.

Source code in ontocast/tool/triple_manager/core.py
def supports_sparql_construct(self) -> bool:
    """True when :meth:`aconstruct` reaches a real SPARQL engine.

    Separate from :meth:`supports_sparql_select` because a backend can answer
    row queries without being able to return triples: the Fuseki SELECT path
    speaks ``application/sparql-results+json`` only.
    """
    return False

supports_sparql_select()

True when :meth:aselect reaches a real SPARQL engine.

Callers branch on this to choose targeted queries over materializing the whole catalog. Backends returning False still answer every catalog method correctly, just by fetching more than they need.

Source code in ontocast/tool/triple_manager/core.py
def supports_sparql_select(self) -> bool:
    """True when :meth:`aselect` reaches a real SPARQL engine.

    Callers branch on this to choose targeted queries over materializing the
    whole catalog. Backends returning ``False`` still answer every catalog
    method correctly, just by fetching more than they need.
    """
    return False

supports_tenancy_partition()

True if this backend isolates facts/ontologies by :func:tenant_project_* names.

Source code in ontocast/tool/triple_manager/core.py
def supports_tenancy_partition(self) -> bool:
    """True if this backend isolates facts/ontologies by :func:`tenant_project_*` names."""
    return False

update_tenancy(tenant, project, *, sep=TENANCY_SEP) async

Switch the active tenant/project partition when supported.

Source code in ontocast/tool/triple_manager/core.py
async def update_tenancy(
    self,
    tenant: str,
    project: str,
    *,
    sep: str = TENANCY_SEP,
) -> None:
    """Switch the active tenant/project partition when supported."""
    if not self.supports_tenancy_partition():
        raise NotImplementedError(
            f"{type(self).__name__} does not isolate data by tenant/project"
        )
    raise NotImplementedError(
        f"{type(self).__name__} must implement update_tenancy()"
    )

VectorStoreManager

Bases: Tool

Abstract interface for vector store implementations.

Source code in ontocast/tool/vector_store/core.py
class VectorStoreManager(Tool):
    """Abstract interface for vector store implementations."""

    store_config: VectorStoreConfig = Field(default_factory=VectorStoreConfig)
    embedding: EmbeddingTool | None = Field(default=None, exclude=True)
    sparse_embedding: FastembedBm25SparseTool | None = Field(default=None, exclude=True)

    @abc.abstractmethod
    async def initialize(self) -> None:
        """Prepare schema/collections in the backing vector store."""

    @abc.abstractmethod
    def index_ontology(self, ontology: Ontology) -> int:
        """Index an ontology and return number of indexed atoms."""

    @abc.abstractmethod
    def search_patches(
        self,
        query: str,
        top_k: int | None = None,
        filter_iri: str | None = None,
        filter_version: str | None = None,
        filter_hash: str | None = None,
    ) -> list[GraphAtom]:
        """Search ontology patches by query text (``top_k`` None → store default)."""

    @abc.abstractmethod
    def search_patch_hits(
        self,
        query: str,
        top_k: int | None = None,
        filter_iri: str | None = None,
        filter_version: str | None = None,
        filter_hash: str | None = None,
    ) -> list[OntologySearchHit]:
        """Search ontology atoms and return rank-fused scored hit objects."""

    @abc.abstractmethod
    def search_patch_hits_many(
        self,
        queries: list[str],
        top_k: int | None = None,
        filter_iri: str | None = None,
        filter_version: str | None = None,
        filter_hash: str | None = None,
    ) -> list[OntologySearchHitsByChannel]:
        """Search ontology atoms for many queries with split-channel outputs."""

    @abc.abstractmethod
    async def asearch_patch_hits_many(
        self,
        queries: list[str],
        top_k: int | None = None,
        filter_iri: str | None = None,
        filter_version: str | None = None,
        filter_hash: str | None = None,
    ) -> list[OntologySearchHitsByChannel]:
        """Async variant of :meth:`search_patch_hits_many`."""

    @abc.abstractmethod
    def fetch_vectors(
        self,
        atom_ids: list[str],
    ) -> dict[str, tuple[list[float], list[float]]]:
        """Batch-fetch dense core/neighborhood vectors for MMR."""

    async def afetch_vectors(
        self,
        atom_ids: list[str],
    ) -> dict[str, tuple[list[float], list[float]]]:
        """Async wrapper around :meth:`fetch_vectors`."""
        return await asyncio.to_thread(self.fetch_vectors, atom_ids)

    def fetch_atoms_by_ids(self, atom_ids: list[str]) -> list[GraphAtom]:
        """Batch-fetch atom payloads by ``atom_id`` (for lexical-trigger injection)."""
        raise NotImplementedError(
            f"{type(self).__name__} does not support fetch_atoms_by_ids"
        )

    def match_lexical_triggers(
        self, text: str, *, max_atoms: int | None = None
    ) -> list[GraphAtom]:
        """Match raw text against the lexical-trigger index and return atoms."""
        raise NotImplementedError(
            f"{type(self).__name__} does not support lexical trigger matching"
        )

    @abc.abstractmethod
    def delete_ontology(
        self,
        iri: str,
        version: str | None = None,
        ontology_hash: str | None = None,
    ) -> None:
        """Delete all indexed atoms for a specific ontology IRI."""

    def reindex_ontology(self, ontology: Ontology) -> int:
        """Replace all atoms for a given ontology and return indexed count."""
        self.delete_ontology(ontology.iri)
        return self.index_ontology(ontology)

    def list_indexed_ontology_iris(self) -> set[str]:
        """Return distinct ``ontology_iri`` values present in the ontology store."""
        raise NotImplementedError(
            f"{type(self).__name__} does not support listing indexed ontology IRIs"
        )

    def prune_orphan_ontology_iris(self, keep_iris: set[str]) -> list[str]:
        """Delete indexed atoms whose ``ontology_iri`` is not in ``keep_iris``.

        An empty ``keep_iris`` is refused rather than treated as "everything is
        an orphan". Pruning exists to follow IRI renames, and no rename makes
        every ontology disappear at once -- an empty catalog means the source of
        truth could not be read, and deleting the whole index on that basis is
        unrecoverable. Callers that genuinely want an empty store should call
        :meth:`wipe_store`.

        Returns the orphan IRIs that were deleted (sorted); empty when the
        prune was refused.
        """
        indexed = self.list_indexed_ontology_iris()
        if not keep_iris:
            if indexed:
                logger.warning(
                    "Refusing to prune %d indexed ontology IRI(s) against an empty "
                    "catalog -- this usually means the triple store could not be "
                    "read. Use wipe_store() to clear the index deliberately.",
                    len(indexed),
                )
            return []
        orphans = sorted(indexed - keep_iris)
        for iri in orphans:
            self.delete_ontology(iri)
        return orphans

    def close(self) -> None:
        """Release any backend connection held by this store.

        Default is a no-op: backends that open no long-lived handle (LanceDB
        connects per call) have nothing to release.
        """
        return None

    async def wipe_store(self) -> None:
        """Drop the currently configured ontology/facts collections or tables.

        Call :meth:`initialize` afterwards to recreate empty schema.
        """
        raise NotImplementedError(
            f"{type(self).__name__} does not support wiping the current store"
        )

    def apply_tenancy(
        self,
        tenant: str,
        project: str,
        *,
        sep: str = TENANCY_SEP,
    ) -> None:
        """Switch the active tenant/project partition when supported."""
        if not self.supports_tenancy_partition():
            raise NotImplementedError(
                f"{type(self).__name__} does not isolate data by tenant/project"
            )
        raise NotImplementedError(f"{type(self).__name__} must implement apply_tenancy")

    def supports_tenancy_partition(self) -> bool:
        """True if tenancy hooks isolate data by tenant/project."""
        return False

    async def clean_tenancy(self, tenant: str, project: str) -> None:
        """Drop or empty vector collections derived from ``tenant`` / ``project``."""
        raise NotImplementedError(
            f"{type(self).__name__} does not isolate vectors by tenant/project"
        )

afetch_vectors(atom_ids) async

Async wrapper around :meth:fetch_vectors.

Source code in ontocast/tool/vector_store/core.py
async def afetch_vectors(
    self,
    atom_ids: list[str],
) -> dict[str, tuple[list[float], list[float]]]:
    """Async wrapper around :meth:`fetch_vectors`."""
    return await asyncio.to_thread(self.fetch_vectors, atom_ids)

apply_tenancy(tenant, project, *, sep=TENANCY_SEP)

Switch the active tenant/project partition when supported.

Source code in ontocast/tool/vector_store/core.py
def apply_tenancy(
    self,
    tenant: str,
    project: str,
    *,
    sep: str = TENANCY_SEP,
) -> None:
    """Switch the active tenant/project partition when supported."""
    if not self.supports_tenancy_partition():
        raise NotImplementedError(
            f"{type(self).__name__} does not isolate data by tenant/project"
        )
    raise NotImplementedError(f"{type(self).__name__} must implement apply_tenancy")

asearch_patch_hits_many(queries, top_k=None, filter_iri=None, filter_version=None, filter_hash=None) abstractmethod async

Async variant of :meth:search_patch_hits_many.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
async def asearch_patch_hits_many(
    self,
    queries: list[str],
    top_k: int | None = None,
    filter_iri: str | None = None,
    filter_version: str | None = None,
    filter_hash: str | None = None,
) -> list[OntologySearchHitsByChannel]:
    """Async variant of :meth:`search_patch_hits_many`."""

clean_tenancy(tenant, project) async

Drop or empty vector collections derived from tenant / project.

Source code in ontocast/tool/vector_store/core.py
async def clean_tenancy(self, tenant: str, project: str) -> None:
    """Drop or empty vector collections derived from ``tenant`` / ``project``."""
    raise NotImplementedError(
        f"{type(self).__name__} does not isolate vectors by tenant/project"
    )

close()

Release any backend connection held by this store.

Default is a no-op: backends that open no long-lived handle (LanceDB connects per call) have nothing to release.

Source code in ontocast/tool/vector_store/core.py
def close(self) -> None:
    """Release any backend connection held by this store.

    Default is a no-op: backends that open no long-lived handle (LanceDB
    connects per call) have nothing to release.
    """
    return None

delete_ontology(iri, version=None, ontology_hash=None) abstractmethod

Delete all indexed atoms for a specific ontology IRI.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
def delete_ontology(
    self,
    iri: str,
    version: str | None = None,
    ontology_hash: str | None = None,
) -> None:
    """Delete all indexed atoms for a specific ontology IRI."""

fetch_atoms_by_ids(atom_ids)

Batch-fetch atom payloads by atom_id (for lexical-trigger injection).

Source code in ontocast/tool/vector_store/core.py
def fetch_atoms_by_ids(self, atom_ids: list[str]) -> list[GraphAtom]:
    """Batch-fetch atom payloads by ``atom_id`` (for lexical-trigger injection)."""
    raise NotImplementedError(
        f"{type(self).__name__} does not support fetch_atoms_by_ids"
    )

fetch_vectors(atom_ids) abstractmethod

Batch-fetch dense core/neighborhood vectors for MMR.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
def fetch_vectors(
    self,
    atom_ids: list[str],
) -> dict[str, tuple[list[float], list[float]]]:
    """Batch-fetch dense core/neighborhood vectors for MMR."""

index_ontology(ontology) abstractmethod

Index an ontology and return number of indexed atoms.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
def index_ontology(self, ontology: Ontology) -> int:
    """Index an ontology and return number of indexed atoms."""

initialize() abstractmethod async

Prepare schema/collections in the backing vector store.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
async def initialize(self) -> None:
    """Prepare schema/collections in the backing vector store."""

list_indexed_ontology_iris()

Return distinct ontology_iri values present in the ontology store.

Source code in ontocast/tool/vector_store/core.py
def list_indexed_ontology_iris(self) -> set[str]:
    """Return distinct ``ontology_iri`` values present in the ontology store."""
    raise NotImplementedError(
        f"{type(self).__name__} does not support listing indexed ontology IRIs"
    )

match_lexical_triggers(text, *, max_atoms=None)

Match raw text against the lexical-trigger index and return atoms.

Source code in ontocast/tool/vector_store/core.py
def match_lexical_triggers(
    self, text: str, *, max_atoms: int | None = None
) -> list[GraphAtom]:
    """Match raw text against the lexical-trigger index and return atoms."""
    raise NotImplementedError(
        f"{type(self).__name__} does not support lexical trigger matching"
    )

prune_orphan_ontology_iris(keep_iris)

Delete indexed atoms whose ontology_iri is not in keep_iris.

An empty keep_iris is refused rather than treated as "everything is an orphan". Pruning exists to follow IRI renames, and no rename makes every ontology disappear at once -- an empty catalog means the source of truth could not be read, and deleting the whole index on that basis is unrecoverable. Callers that genuinely want an empty store should call :meth:wipe_store.

Returns the orphan IRIs that were deleted (sorted); empty when the prune was refused.

Source code in ontocast/tool/vector_store/core.py
def prune_orphan_ontology_iris(self, keep_iris: set[str]) -> list[str]:
    """Delete indexed atoms whose ``ontology_iri`` is not in ``keep_iris``.

    An empty ``keep_iris`` is refused rather than treated as "everything is
    an orphan". Pruning exists to follow IRI renames, and no rename makes
    every ontology disappear at once -- an empty catalog means the source of
    truth could not be read, and deleting the whole index on that basis is
    unrecoverable. Callers that genuinely want an empty store should call
    :meth:`wipe_store`.

    Returns the orphan IRIs that were deleted (sorted); empty when the
    prune was refused.
    """
    indexed = self.list_indexed_ontology_iris()
    if not keep_iris:
        if indexed:
            logger.warning(
                "Refusing to prune %d indexed ontology IRI(s) against an empty "
                "catalog -- this usually means the triple store could not be "
                "read. Use wipe_store() to clear the index deliberately.",
                len(indexed),
            )
        return []
    orphans = sorted(indexed - keep_iris)
    for iri in orphans:
        self.delete_ontology(iri)
    return orphans

reindex_ontology(ontology)

Replace all atoms for a given ontology and return indexed count.

Source code in ontocast/tool/vector_store/core.py
def reindex_ontology(self, ontology: Ontology) -> int:
    """Replace all atoms for a given ontology and return indexed count."""
    self.delete_ontology(ontology.iri)
    return self.index_ontology(ontology)

search_patch_hits(query, top_k=None, filter_iri=None, filter_version=None, filter_hash=None) abstractmethod

Search ontology atoms and return rank-fused scored hit objects.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
def search_patch_hits(
    self,
    query: str,
    top_k: int | None = None,
    filter_iri: str | None = None,
    filter_version: str | None = None,
    filter_hash: str | None = None,
) -> list[OntologySearchHit]:
    """Search ontology atoms and return rank-fused scored hit objects."""

search_patch_hits_many(queries, top_k=None, filter_iri=None, filter_version=None, filter_hash=None) abstractmethod

Search ontology atoms for many queries with split-channel outputs.

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
def search_patch_hits_many(
    self,
    queries: list[str],
    top_k: int | None = None,
    filter_iri: str | None = None,
    filter_version: str | None = None,
    filter_hash: str | None = None,
) -> list[OntologySearchHitsByChannel]:
    """Search ontology atoms for many queries with split-channel outputs."""

search_patches(query, top_k=None, filter_iri=None, filter_version=None, filter_hash=None) abstractmethod

Search ontology patches by query text (top_k None → store default).

Source code in ontocast/tool/vector_store/core.py
@abc.abstractmethod
def search_patches(
    self,
    query: str,
    top_k: int | None = None,
    filter_iri: str | None = None,
    filter_version: str | None = None,
    filter_hash: str | None = None,
) -> list[GraphAtom]:
    """Search ontology patches by query text (``top_k`` None → store default)."""

supports_tenancy_partition()

True if tenancy hooks isolate data by tenant/project.

Source code in ontocast/tool/vector_store/core.py
def supports_tenancy_partition(self) -> bool:
    """True if tenancy hooks isolate data by tenant/project."""
    return False

wipe_store() async

Drop the currently configured ontology/facts collections or tables.

Call :meth:initialize afterwards to recreate empty schema.

Source code in ontocast/tool/vector_store/core.py
async def wipe_store(self) -> None:
    """Drop the currently configured ontology/facts collections or tables.

    Call :meth:`initialize` afterwards to recreate empty schema.
    """
    raise NotImplementedError(
        f"{type(self).__name__} does not support wiping the current store"
    )

__getattr__(name)

Resolve the optional-backend managers on first access.

Source code in ontocast/tool/__init__.py
def __getattr__(name: str) -> Any:
    """Resolve the optional-backend managers on first access."""
    target = _LAZY_EXPORTS.get(name)
    if target is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    import importlib

    module_name, attribute = target
    value = getattr(importlib.import_module(module_name), attribute)
    globals()[name] = value
    return value