"""
CogniFlow Ontology Manager Module
"""

from pathlib import Path
from typing import Any, Dict, List, Optional, Union, Iterable
import json
import logging
import os
import functools
from importlib import resources
import duckdb
from rdflib import Graph
from rdflib import BNode, Literal, URIRef
from rdflib.namespace import OWL, RDF, RDFS
from pyshacl import validate as shacl_validate

from .semantics_paths import (
    resolve_semantics_db_path,
    resolve_semantics_db_paths,
    resolve_semantics_dir,
)
from .pipeline_states_store import PipelineStateStore
from .package_discovery import ingest_installed_step_packages
from .quad_store import QuadStore
from .datatypes_mapping import normalize_parameter_type, normalize_port_literal_datatype

logger = logging.getLogger(__name__)

DEFAULT_PIPELINE_STATE = "sleep"


# MARK: Ontology Manager
class OntologyManager:
    """
    CogniFlow Ontology Manager Class Constructor.
    Manages loading and querying of the CogniFlow ontology stored in DuckDB.
    """

    def __init__(self, *, load_resources: bool = True):
        """
        Loads the CogniFlow ontology and discovered step package files, validates JSON-LD
        against SHACL shapes, and persists into cf-ontology.duckdb.

        :return: OntologyManager instance.

            Fields:
            - graph: Lazily built RDFLib Graph from JSON-LD stored in DuckDB (read-only view).
            - shapes: Lazily built RDFLib Graph from shapes JSON-LD stored in DuckDB.
            - ontology_db_path: Filesystem path to cf-ontology.duckdb.

            Methods:
            - get_graph_jsonld: Return combined ontology JSON-LD from DuckDB.
            - save_graph_jsonld: Persist ontology JSON-LD into DuckDB.
            - get_processing_steps: Retrieve processing steps as JSON-LD nodes.
            - get_step_packages: Retrieve StepPackage nodes as JSON-LD.
            - get_steps_for_package: Retrieve ProcessingSteps belonging to a StepPackage.
            - resolve_step_implementations: Resolve implementation bindings for a step.
            - get_step_info: Retrieve a specific processing step as JSON-LD.
            - get_pipelines: Retrieve pipelines as JSON-LD nodes.
        """
        self._graph_cache: Optional[Graph] = None
        self._shapes_cache: Optional[Graph] = None
        self._shapes_jsonld: Optional[Dict[str, Any]] = None
        self._quads_context_cache: Optional[Dict[str, Any]] = None
        self._jsonld_documents: List[Dict[str, Any]] = []
        self._semantics_dir = resolve_semantics_dir()
        self._db_paths = resolve_semantics_db_paths(self._semantics_dir)
        self._ontology_db_path = self._db_paths.ontology
        self._ontology_store = None
        self._steps_store = None
        self._pipelines_store = None
        self._pipeline_states_store = None
        self._pipeline_state_store = None
        self._ontology_store = QuadStore(
            db_path=self._db_paths.ontology,
            semantics_dir=self._semantics_dir,
        )
        self._steps_store = QuadStore(
            db_path=self._db_paths.steps,
            semantics_dir=self._semantics_dir,
        )
        self._pipelines_store = QuadStore(
            db_path=self._db_paths.pipelines,
            semantics_dir=self._semantics_dir,
        )
        self._pipeline_states_store = QuadStore(
            db_path=self._db_paths.pipeline_states,
            semantics_dir=self._semantics_dir,
        )
        self._pipeline_state_store = PipelineStateStore(
            db_path=self._db_paths.pipeline_states,
            semantics_dir=self._semantics_dir,
        )
        self._ontology_store.init_schema(create_pipeline_tables=False)
        self._steps_store.init_schema(create_pipeline_tables=False)
        self._pipelines_store.init_schema(create_pipeline_tables=True)
        self._pipeline_states_store.init_schema(create_pipeline_tables=False)
        self._pipeline_state_store.init_schema()
        self._sync_pipeline_state_rows()
        self._load_packaged_resources(load_resources=load_resources)
        self._ingest_installed_step_packages()

    # ------------------------------------------------------------------ #
    # MARK: Public Methods
    # ------------------------------------------------------------------ #

    # MARK: get_graph_jsonld
    def get_graph_jsonld(self) -> Dict[str, Any]:
        """
        Return a combined JSON-LD object for the ontology (data + shapes).

        Reads JSON-LD documents from cf-ontology.duckdb when available.
        """
        if self._ontology_store is not None:
            try:
                documents: List[Dict[str, Any]] = []

                def _base_graph_ids(store):
                    if store is None:
                        return []
                    return [
                        entry.graph_id
                        for entry in store.list_graphs()
                        if entry.is_active and entry.graph_kind != "pipeline_rev"
                    ]

                for store in (
                    self._ontology_store,
                    self._steps_store,
                    self._pipelines_store,
                ):
                    graph_ids = _base_graph_ids(store)
                    if graph_ids:
                        documents.append(store.export_jsonld(graph_ids, flatten=True))

                if self._pipelines_store is not None:
                    pipeline_graph_ids = (
                        self._pipelines_store.list_current_pipeline_graph_ids()
                    )
                    if pipeline_graph_ids:
                        documents.append(
                            self._pipelines_store.export_jsonld(
                                pipeline_graph_ids,
                                flatten=True,
                            )
                        )

                if documents:
                    return self._combine_jsonld_documents(documents)
            except Exception as exc:  # pylint: disable=broad-exception-caught
                logger.error("Failed to export JSON-LD from QuadStore: %s", exc)
        documents = self._load_jsonld_documents_from_db()
        if not documents:
            documents = [doc["document"] for doc in self._jsonld_documents]
        return self._combine_jsonld_documents(documents)

    def get_graph_dataset_jsonld(self) -> Dict[str, Any]:
        """
        Export the active semantics as dataset JSON-LD (named graphs preserved).

        The export includes only current pipeline revisions (not all historical revisions).
        """
        if self._ontology_store is None:
            return self.get_graph_jsonld()

        documents: List[Dict[str, Any]] = []

        def _base_graph_ids(store):
            if store is None:
                return []
            return [
                entry.graph_id
                for entry in store.list_graphs()
                if entry.is_active and entry.graph_kind != "pipeline_rev"
            ]

        for store in (self._ontology_store, self._steps_store, self._pipelines_store):
            graph_ids = _base_graph_ids(store)
            if graph_ids:
                documents.append(store.export_jsonld(graph_ids, flatten=False))

        if self._pipelines_store is not None:
            pipeline_graph_ids = self._pipelines_store.list_current_pipeline_graph_ids()
            if pipeline_graph_ids:
                documents.append(
                    self._pipelines_store.export_jsonld(
                        pipeline_graph_ids,
                        flatten=False,
                    )
                )

        if not documents:
            return {}
        return self._combine_jsonld_documents(documents)

    # MARK: save_graph_jsonld
    def save_graph_jsonld(self, jsonld: Dict[str, Any]) -> None:
        """
        Persist a JSON-LD ontology graph to cf-ontology.duckdb and refresh the data graph.
        """
        self._record_jsonld_document("combined", jsonld, "definitions", "custom", None)
        self._graph_cache = None

    def refresh_installed_step_packages(self, *, force: bool = False) -> List[str]:
        """
        Re-ingest installed step packages and refresh caches.

        Missing installed packages are marked inactive (`is_active = FALSE`) by the
        package discovery ingest routine.
        """
        if os.environ.get("CF_ENABLE_STEP_PACKAGES", "0") != "1":
            return []
        changed = ingest_installed_step_packages(
            semantics_dir=self._semantics_dir,
            force=force,
        )
        self._graph_cache = None
        self._quads_context_cache = None
        return changed

    # MARK: get_processing_steps
    def get_processing_steps(self) -> List[Dict[str, Any]]:
        """
        Get all processing steps as raw JSON-LD dicts.
        """
        step_types = {
            "cf:ProcessingStep",
            "https://cogniflow.org/ns#ProcessingStep",
            "http://cogniflow.org/ns#ProcessingStep",
        }
        steps: List[Dict[str, Any]] = []
        for node in self._iter_jsonld_nodes():
            if self._has_type(node, step_types):
                steps.append(node)
        return steps

    # MARK: get_step_packages
    def get_step_packages(self) -> List[Dict[str, Any]]:
        """Get StepPackage nodes as raw JSON-LD dicts."""
        pkg_types = {
            "cf:StepPackage",
            "https://cogniflow.org/ns#StepPackage",
            "http://cogniflow.org/ns#StepPackage",
        }
        packages: Dict[str, Dict[str, Any]] = {}
        for node in self._iter_jsonld_nodes():
            if not self._has_type(node, pkg_types):
                continue
            node_id = node.get("@id")
            if isinstance(node_id, str):
                packages.setdefault(node_id, node)
        if packages:
            return list(packages.values())
        # Fallback: return referenced package ids for legacy graphs without StepPackage nodes.
        package_ids: set[str] = set()
        for node in self._iter_jsonld_nodes():
            belongs_value = (
                node.get("cf:belongsToStepPackage")
                or node.get("https://cogniflow.org/ns#belongsToStepPackage")
                or node.get("http://cogniflow.org/ns#belongsToStepPackage")
            )
            for pkg in self._extract_id_values(belongs_value):
                package_ids.add(pkg)
        return [{"@id": pkg} for pkg in sorted(package_ids)]

    # MARK: get_steps_for_package
    def get_steps_for_package(self, package_ref: str) -> List[Dict[str, Any]]:
        """
        Get ProcessingStep nodes that belong to a given StepPackage.

        Args:
            package_ref: StepPackage @id, or cf:packageId, optionally with "@version".
        """
        ref = str(package_ref).strip()
        if not ref:
            return []

        pkg_nodes = self._iter_step_package_nodes()
        pkg_ids: set[str] = set()
        ref_id, ref_version = (ref.split("@", 1) + [None])[:2]

        for node in pkg_nodes:
            node_id = node.get("@id")
            if isinstance(node_id, str) and node_id == ref:
                pkg_ids.add(node_id)
                continue
            pkg_id = self._extract_text_value(
                node.get("cf:packageId")
                or node.get("https://cogniflow.org/ns#packageId")
                or node.get("http://cogniflow.org/ns#packageId")
            )
            pkg_version = self._extract_text_value(
                node.get("cf:packageVersion")
                or node.get("https://cogniflow.org/ns#packageVersion")
                or node.get("http://cogniflow.org/ns#packageVersion")
            )
            if pkg_id and pkg_id == ref_id:
                if ref_version is None or (pkg_version and pkg_version == ref_version):
                    if isinstance(node_id, str):
                        pkg_ids.add(node_id)

        # Fallback: allow filtering by raw package id if no StepPackage nodes match.
        if not pkg_ids and ref:
            pkg_ids.add(ref)

        steps: List[Dict[str, Any]] = []
        step_types = {
            "cf:ProcessingStep",
            "https://cogniflow.org/ns#ProcessingStep",
            "http://cogniflow.org/ns#ProcessingStep",
        }
        for node in self._iter_jsonld_nodes():
            if not self._has_type(node, step_types):
                continue
            belongs_value = (
                node.get("cf:belongsToStepPackage")
                or node.get("https://cogniflow.org/ns#belongsToStepPackage")
                or node.get("http://cogniflow.org/ns#belongsToStepPackage")
            )
            if any(pkg in pkg_ids for pkg in self._extract_id_values(belongs_value)):
                steps.append(node)
        return steps

    # MARK: resolve_step_implementations
    def resolve_step_implementations(
        self, step_name: str, resolve_paths: bool = True
    ) -> List[Dict[str, Any]]:
        """
        Resolve implementation bindings for a ProcessingStep.

        Returns a list of bindings with Python entrypoints and/or C++ plugin artifacts.
        """
        step_node = self.get_step_info(step_name)
        if not step_node:
            return []

        nodes = self._iter_jsonld_nodes()
        id_index: Dict[str, Dict[str, Any]] = {}
        for node in nodes:
            node_id = node.get("@id")
            if isinstance(node_id, str):
                id_index[node_id] = node

        impl_ids = self._extract_id_values(
            step_node.get("cf:hasImplementation")
            or step_node.get("https://cogniflow.org/ns#hasImplementation")
            or step_node.get("http://cogniflow.org/ns#hasImplementation")
        )
        if not impl_ids:
            return []

        package_ids = self._extract_id_values(
            step_node.get("cf:belongsToStepPackage")
            or step_node.get("https://cogniflow.org/ns#belongsToStepPackage")
            or step_node.get("http://cogniflow.org/ns#belongsToStepPackage")
        )
        package_root = self._resolve_package_root(package_ids)

        bindings: List[Dict[str, Any]] = []
        for impl_id in impl_ids:
            impl_node = id_index.get(impl_id)
            if not impl_node:
                continue

            entrypoint = self._extract_text_value(
                impl_node.get("cf:implementationEntrypoint")
                or impl_node.get("https://cogniflow.org/ns#implementationEntrypoint")
                or impl_node.get("http://cogniflow.org/ns#implementationEntrypoint")
            )
            artifact_ids = self._extract_id_values(
                impl_node.get("cf:implementationArtifact")
                or impl_node.get("https://cogniflow.org/ns#implementationArtifact")
                or impl_node.get("http://cogniflow.org/ns#implementationArtifact")
            )
            artifact_node = None
            if artifact_ids:
                artifact_node = id_index.get(artifact_ids[0])

            artifact_info: Optional[Dict[str, Any]] = None
            if artifact_node:
                artifact_paths = self._extract_text_values(
                    artifact_node.get("cf:artifactPath")
                    or artifact_node.get("https://cogniflow.org/ns#artifactPath")
                    or artifact_node.get("http://cogniflow.org/ns#artifactPath")
                )
                resolved_paths: Optional[List[str]] = None
                if resolve_paths and package_root and artifact_paths:
                    resolved_paths = []
                    for rel in artifact_paths:
                        path = Path(rel)
                        if not path.is_absolute():
                            path = (package_root / path).resolve()
                        resolved_paths.append(str(path))

                artifact_info = {
                    "artifact_id": artifact_node.get("@id"),
                    "artifact_paths": artifact_paths,
                    "artifact_paths_resolved": resolved_paths,
                    "plugin_name": self._extract_text_value(
                        artifact_node.get("cf:pluginName")
                        or artifact_node.get("https://cogniflow.org/ns#pluginName")
                        or artifact_node.get("http://cogniflow.org/ns#pluginName")
                        or artifact_node.get("schema:name")
                        or artifact_node.get("https://schema.org/name")
                    ),
                    "plugin_version": self._extract_text_value(
                        artifact_node.get("cf:pluginVersion")
                        or artifact_node.get("https://cogniflow.org/ns#pluginVersion")
                        or artifact_node.get("http://cogniflow.org/ns#pluginVersion")
                        or artifact_node.get("schema:version")
                        or artifact_node.get("https://schema.org/version")
                    ),
                    "plugin_api_version": self._extract_text_value(
                        artifact_node.get("cf:pluginApiVersion")
                        or artifact_node.get(
                            "https://cogniflow.org/ns#pluginApiVersion"
                        )
                        or artifact_node.get("http://cogniflow.org/ns#pluginApiVersion")
                    ),
                    "plugin_data_abi": self._extract_text_value(
                        artifact_node.get("cf:pluginDataAbi")
                        or artifact_node.get("https://cogniflow.org/ns#pluginDataAbi")
                        or artifact_node.get("http://cogniflow.org/ns#pluginDataAbi")
                    ),
                    "plugin_list_symbol": self._extract_text_value(
                        artifact_node.get("cf:pluginListSymbol")
                        or artifact_node.get(
                            "https://cogniflow.org/ns#pluginListSymbol"
                        )
                        or artifact_node.get("http://cogniflow.org/ns#pluginListSymbol")
                    ),
                    "plugin_resolve_symbol": self._extract_text_value(
                        artifact_node.get("cf:pluginResolveSymbol")
                        or artifact_node.get(
                            "https://cogniflow.org/ns#pluginResolveSymbol"
                        )
                        or artifact_node.get(
                            "http://cogniflow.org/ns#pluginResolveSymbol"
                        )
                    ),
                }

            bindings.append(
                {
                    "step_id": step_node.get("@id"),
                    "implementation_id": impl_id,
                    "entrypoint": entrypoint,
                    "artifact": artifact_info,
                    "operation_name": self._extract_text_value(
                        impl_node.get("cf:operationName")
                        or impl_node.get("https://cogniflow.org/ns#operationName")
                        or impl_node.get("http://cogniflow.org/ns#operationName")
                    ),
                    "operation_contract": impl_node.get("cf:operationContract")
                    or impl_node.get("https://cogniflow.org/ns#operationContract")
                    or impl_node.get("http://cogniflow.org/ns#operationContract"),
                }
            )

        return bindings

    # MARK: get_step_info
    def get_step_info(self, step_name: str) -> Optional[Dict[str, Any]]:
        """
        Get a processing step as a raw JSON-LD dict.
        """
        for node in self._iter_jsonld_nodes():
            node_id = node.get("@id")
            if not isinstance(node_id, str):
                continue
            if self._matches_jsonld_id(step_name, node_id):
                return node
        return None

    # MARK: get_pipelines
    def get_pipelines(self) -> List[Dict[str, Any]]:
        """Get all pipeline nodes as raw JSON-LD dicts."""
        pipelines: List[Dict[str, Any]] = []
        for node in self._iter_jsonld_nodes():
            if self._has_type(node, {"cf:ProcessingPipeline"}):
                pipelines.append(node)
        return pipelines

    # MARK: validate_ontology
    def validate_ontology(
        self,
        shapes_path: Optional[Path] = None,
        inference: str = "rdfs",
    ) -> Dict[str, Any]:
        """
        Validate the ontology graph against SHACL shapes.
        """
        if shapes_path:
            if not shapes_path.is_file():
                raise FileNotFoundError(f"Shapes file not found: {shapes_path}")
            shapes_graph = Graph().parse(shapes_path, format="json-ld")
        else:
            shapes_graph = self.shapes

        inf = None if inference == "none" else inference
        conforms, report_graph, report_text = shacl_validate(
            self.graph,
            shacl_graph=shapes_graph,
            inference=inf,
            abort_on_first=False,
            advanced=True,
        )
        return {
            "conforms": bool(conforms),
            "report_graph": report_graph,
            "report_text": report_text,
        }

    def commit_pipeline(
        self,
        pipeline_id: str,
        jsonld: Union[Dict[str, Any], str, Path],
        user: Optional[str] = None,
        message: Optional[str] = None,
    ) -> int:
        """Commit a new pipeline revision (append-only) and return the new rev."""
        if self._pipelines_store is None:
            raise RuntimeError(
                "Pipeline versioning requires CF_SEMANTICS_BACKEND=quads"
            )
        rev = self._pipelines_store.commit_pipeline_revision(
            pipeline_id,
            jsonld,
            created_by=user,
            message=message,
        )
        self._ensure_pipeline_state_row(pipeline_id, int(rev))
        return rev

    def get_pipeline_revision(
        self,
        pipeline_id: str,
        rev: Optional[int] = None,
        *,
        flatten: bool = True,
    ) -> Dict[str, Any]:
        """
        Load a specific pipeline revision as JSON-LD.

        If `rev` is None, returns the current revision.
        """
        if self._pipelines_store is None:
            raise RuntimeError(
                "Pipeline versioning requires CF_SEMANTICS_BACKEND=quads"
            )
        if rev is None:
            graph_id = self._pipelines_store.get_pipeline_current_graph_id(pipeline_id)
            return self._pipelines_store.export_jsonld([graph_id], flatten=flatten)
        return self._pipelines_store.load_pipeline_revision_jsonld(
            pipeline_id, int(rev), flatten=flatten
        )

    def list_pipeline_revisions(self, pipeline_id: str) -> List[Dict[str, Any]]:
        """List revision metadata for a pipeline."""
        if self._pipelines_store is None:
            raise RuntimeError(
                "Pipeline versioning requires CF_SEMANTICS_BACKEND=quads"
            )
        return self._pipelines_store.list_pipeline_revisions(pipeline_id)

    # MARK: ontology_db_path
    @property
    def ontology_db_path(self) -> Path:
        """Filesystem path to the ontology DuckDB file."""
        return self._ontology_db_path

    @property
    def steps_db_path(self) -> Path:
        """Filesystem path to the steps DuckDB file."""
        return self._db_paths.steps

    @property
    def pipelines_db_path(self) -> Path:
        """Filesystem path to the pipelines DuckDB file."""
        return self._db_paths.pipelines

    @property
    def pipeline_states_db_path(self) -> Path:
        """Filesystem path to the pipeline states DuckDB file."""
        return self._db_paths.pipeline_states

    # MARK: graph
    @property
    def graph(self) -> Graph:
        """Lazily built RDFLib graph for the ontology data."""
        if self._graph_cache is None:
            doc = self.get_graph_jsonld()
            graph = Graph()
            graph.parse(data=json.dumps(doc), format="json-ld")
            self._graph_cache = graph
        return self._graph_cache

    # MARK: shapes
    @property
    def shapes(self) -> Graph:
        """Lazily built RDFLib graph for the SHACL shapes."""
        if self._shapes_cache is None:
            shapes_doc = self._get_shapes_jsonld()
            graph = Graph()
            if shapes_doc:
                graph.parse(data=json.dumps(shapes_doc), format="json-ld")
            self._shapes_cache = graph
        return self._shapes_cache

    # ------------------------------------------------------------------ #
    # MARK: Internal Helpers
    # ------------------------------------------------------------------ #

    # MARK: _resolve_ontology_db_path
    @staticmethod
    def _resolve_ontology_db_path() -> Path:
        return resolve_semantics_db_path(resolve_semantics_dir(), kind="ontology")

    def _ingest_installed_step_packages(self) -> None:
        if os.environ.get("CF_ENABLE_STEP_PACKAGES", "0") != "1":
            return
        try:
            self.refresh_installed_step_packages()
        except Exception as exc:  # pylint: disable=broad-exception-caught
            logger.warning("Step package ingestion failed: %s", exc)

    def _ensure_pipeline_state_row(
        self,
        pipeline_id: str,
        current_rev: int,
        *,
        desired_state: str = DEFAULT_PIPELINE_STATE,
    ) -> None:
        if self._pipeline_state_store is None:
            return
        con = duckdb.connect(str(self._db_paths.pipeline_states), read_only=False)
        try:
            con.execute(
                """
                INSERT INTO pipeline_state (
                  pipeline_id,
                  current_rev,
                  desired_state,
                  concurrency_limit,
                  priority,
                  created_at,
                  updated_at
                )
                VALUES (?, ?, ?, 1, 0, current_timestamp, current_timestamp)
                ON CONFLICT (pipeline_id)
                DO UPDATE SET
                  current_rev = EXCLUDED.current_rev,
                  updated_at = now()
                """,
                [pipeline_id, int(current_rev), str(desired_state)],
            )
        finally:
            con.close()

    def _sync_pipeline_state_rows(self) -> None:
        if self._pipeline_state_store is None:
            return
        if not self._db_paths.pipelines.exists():
            return
        con = duckdb.connect(str(self._db_paths.pipelines), read_only=True)
        try:
            rows = con.execute(
                "SELECT pipeline_id, current_rev FROM pipelines"
            ).fetchall()
        except Exception:  # pylint: disable=broad-exception-caught
            rows = []
        finally:
            con.close()
        if not rows:
            return
        for pipeline_id, current_rev in rows:
            self._ensure_pipeline_state_row(
                str(pipeline_id),
                int(current_rev or 0),
            )

    def _select_store(self, graph_type: str):
        gtype = str(graph_type or "").lower()
        if gtype in {"pipeline_state", "pipeline_states"}:
            return self._pipeline_states_store
        if gtype == "pipeline":
            return self._pipelines_store
        if gtype == "step":
            return self._steps_store
        return self._ontology_store

    # MARK: _iter_jsonld_nodes
    def _iter_jsonld_nodes(self) -> List[Dict[str, Any]]:
        combined = self.get_graph_jsonld()
        graph = combined.get("@graph")
        if isinstance(graph, list):
            return [node for node in graph if isinstance(node, dict)]
        if isinstance(combined, dict):
            return [combined]
        return []

    def _iter_step_package_nodes(self) -> List[Dict[str, Any]]:
        pkg_types = {
            "cf:StepPackage",
            "https://cogniflow.org/ns#StepPackage",
            "http://cogniflow.org/ns#StepPackage",
        }
        packages: Dict[str, Dict[str, Any]] = {}
        for node in self._iter_jsonld_nodes():
            if not self._has_type(node, pkg_types):
                continue
            node_id = node.get("@id")
            if isinstance(node_id, str):
                packages.setdefault(node_id, node)
        return list(packages.values())

    # MARK: _has_type
    @staticmethod
    def _has_type(node: Dict[str, Any], candidates: set[str]) -> bool:
        raw = node.get("@type")
        types: List[str] = []
        if isinstance(raw, str):
            types.append(raw)
        elif isinstance(raw, list):
            for entry in raw:
                if isinstance(entry, str):
                    types.append(entry)
                elif isinstance(entry, dict):
                    entry_id = entry.get("@id")
                    if isinstance(entry_id, str):
                        types.append(entry_id)
        elif isinstance(raw, dict):
            entry_id = raw.get("@id")
            if isinstance(entry_id, str):
                types.append(entry_id)
        return any(t in candidates for t in types)

    # MARK: _extract_id_values
    @staticmethod
    def _extract_id_values(value: Any) -> List[str]:
        values: List[str] = []
        if isinstance(value, str):
            values.append(value)
        elif isinstance(value, dict):
            entry_id = value.get("@id")
            if isinstance(entry_id, str):
                values.append(entry_id)
        elif isinstance(value, list):
            for item in value:
                values.extend(OntologyManager._extract_id_values(item))
        return values

    @staticmethod
    def _extract_text_values(value: Any) -> List[str]:
        values: List[str] = []
        if isinstance(value, str):
            values.append(value)
        elif isinstance(value, dict):
            entry_value = value.get("@value")
            if isinstance(entry_value, str):
                values.append(entry_value)
            entry_id = value.get("@id")
            if isinstance(entry_id, str):
                values.append(entry_id)
        elif isinstance(value, list):
            for item in value:
                values.extend(OntologyManager._extract_text_values(item))
        return values

    @staticmethod
    def _extract_text_value(value: Any) -> Optional[str]:
        values = OntologyManager._extract_text_values(value)
        return values[0] if values else None

    def _resolve_package_root(self, package_ids: List[str]) -> Optional[Path]:
        if not package_ids or self._steps_store is None:
            return None

        package_nodes = {
            node.get("@id"): node
            for node in self._iter_step_package_nodes()
            if isinstance(node.get("@id"), str)
        }

        def _lookup(
            package_name: Optional[str], package_version: Optional[str]
        ) -> Optional[Path]:
            if self._steps_store is None:
                return None
            for entry in self._steps_store.list_graphs():
                if not entry.is_active:
                    continue
                if entry.graph_kind not in {"package", "package_step"}:
                    continue
                name = entry.package_name or entry.package
                version = entry.package_version
                if package_name and name != package_name:
                    continue
                if package_version and version and version != package_version:
                    continue
                if entry.path:
                    return Path(entry.path).resolve().parent
            return None

        for pkg_id in package_ids:
            node = package_nodes.get(pkg_id)
            if node:
                pkg_name = self._extract_text_value(
                    node.get("cf:packageId")
                    or node.get("https://cogniflow.org/ns#packageId")
                    or node.get("http://cogniflow.org/ns#packageId")
                )
                pkg_version = self._extract_text_value(
                    node.get("cf:packageVersion")
                    or node.get("https://cogniflow.org/ns#packageVersion")
                    or node.get("http://cogniflow.org/ns#packageVersion")
                )
                root = _lookup(pkg_name, pkg_version)
                if root:
                    return root

        # Fallback: try by raw package id.
        return _lookup(package_ids[0], None)

    # MARK: _extract_step_ids
    def _extract_step_ids(self, doc: Dict[str, Any]) -> List[str]:
        step_ids: List[str] = []
        graph = doc.get("@graph")
        nodes = graph if isinstance(graph, list) else [doc]
        for node in nodes:
            if not isinstance(node, dict):
                continue
            if not self._has_type(node, {"cf:ProcessingStep"}):
                continue
            node_id = node.get("@id")
            if isinstance(node_id, str):
                step_ids.append(node_id)
        return step_ids

    # MARK: _extract_step_subgraph
    def _extract_step_subgraph(
        self, doc: Dict[str, Any], step_id: str
    ) -> Optional[Dict[str, Any]]:
        graph = doc.get("@graph")
        nodes = graph if isinstance(graph, list) else [doc]
        id_index: Dict[str, Dict[str, Any]] = {}
        for node in nodes:
            if not isinstance(node, dict):
                continue
            node_id = node.get("@id")
            if isinstance(node_id, str):
                id_index[node_id] = node

        if step_id not in id_index:
            return None

        def _collect_ids(value: Any, collected: set[str]) -> None:
            if isinstance(value, dict):
                ref_id = value.get("@id")
                if isinstance(ref_id, str):
                    collected.add(ref_id)
                for v in value.values():
                    _collect_ids(v, collected)
            elif isinstance(value, list):
                for item in value:
                    _collect_ids(item, collected)

        visited: set[str] = set()
        queue: List[str] = [step_id]
        subgraph: List[Dict[str, Any]] = []

        while queue:
            current_id = queue.pop(0)
            if current_id in visited:
                continue
            visited.add(current_id)
            node = id_index.get(current_id)
            if not node:
                continue
            subgraph.append(node)
            referenced: set[str] = set()
            _collect_ids(node, referenced)
            for ref_id in referenced:
                if ref_id not in visited and ref_id in id_index:
                    queue.append(ref_id)

        return {"@context": doc.get("@context", {}), "@graph": subgraph}

    # MARK: _validate_jsonld
    def _validate_jsonld(
        self,
        doc: Dict[str, Any],
        graph_id: str,
        source_path: Optional[str],
        include_core: bool = False,
    ) -> bool:
        shapes_doc = self._get_shapes_jsonld()
        if not shapes_doc:
            raise RuntimeError(
                "SHACL shapes are missing; cannot validate ontology JSON-LD."
            )

        payload = doc
        if include_core:
            core_doc = self._get_core_jsonld()
            if core_doc:
                payload = self._combine_jsonld_documents([core_doc, doc])

        data_graph = Graph()
        data_graph.parse(data=json.dumps(payload), format="json-ld")
        conforms, _, report_text = shacl_validate(
            data_graph,
            shacl_graph=self.shapes,
            inference="rdfs",
            meta_shacl=False,
        )
        if not conforms:
            logger.error(
                "SHACL validation failed for %s (%s):\n%s",
                graph_id,
                source_path or "in-memory",
                report_text,
            )
        return bool(conforms)

    # MARK: _matches_jsonld_id
    @staticmethod
    def _matches_jsonld_id(target: str, node_id: str) -> bool:
        if target == node_id:
            return True
        return OntologyManager._local_id(target) == OntologyManager._local_id(node_id)

    # MARK: _local_id
    @staticmethod
    def _local_id(value: str) -> str:
        if "#" in value:
            return value.split("#", 1)[1]
        if "/" in value:
            return value.rsplit("/", 1)[-1]
        if ":" in value:
            return value.split(":", 1)[1]
        return value

    # MARK: _record_jsonld_document
    def _record_jsonld_document(
        self,
        graph_id: str,
        document: Dict[str, Any],
        graph_type: str,
        package: str,
        path: Optional[str],
    ) -> None:
        self._jsonld_documents = [
            doc for doc in self._jsonld_documents if doc.get("graph_id") != graph_id
        ]
        self._jsonld_documents.append(
            {
                "graph_id": graph_id,
                "graph_type": graph_type,
                "package": package,
                "document": document,
                "path": path,
            }
        )
        base_path = None
        if path:
            try:
                base_path = Path(path).resolve().parent
            except Exception:  # pylint: disable=broad-exception-caught
                base_path = None
        try:
            store = self._select_store(graph_type)
            if store is None:
                return
            store.ingest_jsonld_document(
                document,
                graph_id=graph_id,
                graph_type=graph_type,
                package=package,
                source_path=path,
                base_path=base_path,
            )
        except Exception as exc:  # pylint: disable=broad-exception-caught
            logger.error(
                "Failed to ingest JSON-LD into QuadStore (%s): %s", graph_id, exc
            )

    # MARK: _load_jsonld_text
    def _load_jsonld_text(
        self,
        jsonld_text: str,
        source: str,
        graph_id: str,
        graph_type: str = "custom",
        package: str = "custom",
        record: bool = True,
        split_steps: bool = False,
    ) -> Optional[Dict[str, Any]]:
        try:
            doc = json.loads(jsonld_text)
        except json.JSONDecodeError as exc:
            logger.error("Invalid JSON-LD in %s: %s", source, exc)
            return None

        if record:
            if split_steps:
                step_ids = self._extract_step_ids(doc)
                for step_id in step_ids:
                    subgraph = self._extract_step_subgraph(doc, step_id)
                    if subgraph:
                        if not self._validate_jsonld(
                            subgraph,
                            step_id,
                            source,
                            include_core=True,
                        ):
                            continue
                        self._record_jsonld_document(
                            step_id, subgraph, graph_type, package, source
                        )
            else:
                if not self._validate_jsonld(
                    doc,
                    graph_id,
                    source,
                    include_core=(graph_type == "step"),
                ):
                    return doc
                self._record_jsonld_document(graph_id, doc, graph_type, package, source)

        logger.info("Loaded ontology JSON-LD from %s", source)
        return doc

    # MARK: _load_rdf_path
    def _load_rdf_path(
        self,
        path: Path,
        graph_type: str,
        package: str,
    ) -> None:
        suffix = path.suffix.lower()
        if suffix not in {".jsonld", ".json"}:
            logger.warning("Skipping non-JSON-LD ontology file: %s", path)
            return
        graph_id = str(path.resolve())
        jsonld_text = path.read_text(encoding="utf-8")
        self._load_jsonld_text(
            jsonld_text,
            str(path),
            graph_id,
            graph_type=graph_type,
            package=package,
            split_steps=graph_type == "step",
        )

    # MARK: _load_jsonld_documents_from_db
    def _load_jsonld_documents_from_db(self) -> List[Dict[str, Any]]:
        db_path = self._ontology_db_path
        if not db_path.exists():
            return []

        con = duckdb.connect(str(db_path), read_only=True)
        try:
            rows = con.execute("SELECT jsonld FROM ontology_graph").fetchall()
        except Exception:  # pylint: disable=broad-exception-caught
            return []
        finally:
            con.close()

        documents: List[Dict[str, Any]] = []
        for (jsonld_value,) in rows:
            raw = jsonld_value
            if isinstance(raw, dict):
                documents.append(raw)
                continue
            try:
                documents.append(json.loads(raw))
            except Exception:  # pylint: disable=broad-exception-caught
                continue
        return documents

    def _load_jsonld_entries_from_db(self) -> List[Dict[str, Any]]:
        db_path = self._ontology_db_path
        if not db_path.exists():
            return []

        con = duckdb.connect(str(db_path), read_only=True)
        try:
            rows = con.execute(
                "SELECT graph_id, graph_type, package, path, jsonld FROM ontology_graph"
            ).fetchall()
        except Exception:  # pylint: disable=broad-exception-caught
            return []
        finally:
            con.close()

        entries: List[Dict[str, Any]] = []
        for graph_id, graph_type, package, path, jsonld_value in rows:
            raw = jsonld_value
            if not isinstance(raw, dict):
                try:
                    raw = json.loads(raw)
                except Exception:  # pylint: disable=broad-exception-caught
                    continue
            entries.append(
                {
                    "graph_id": graph_id,
                    "graph_type": graph_type,
                    "package": package,
                    "path": path,
                    "document": raw,
                }
            )
        return entries

    # MARK: _load_jsonld_by_id
    def _load_jsonld_by_id(self, graph_id: str) -> Optional[Dict[str, Any]]:
        db_path = self._ontology_db_path
        if not db_path.exists():
            return None

        con = duckdb.connect(str(db_path), read_only=True)
        try:
            row = con.execute(
                "SELECT jsonld FROM ontology_graph WHERE graph_id = ?",
                [graph_id],
            ).fetchone()
        except Exception:  # pylint: disable=broad-exception-caught
            return None
        finally:
            con.close()

        if not row:
            return None
        raw = row[0]
        if isinstance(raw, dict):
            return raw
        try:
            return json.loads(raw)
        except Exception:  # pylint: disable=broad-exception-caught
            return None

    def _get_core_jsonld(self) -> Optional[Dict[str, Any]]:
        doc = self._load_jsonld_by_id("core")
        if doc:
            return doc
        for entry in self._jsonld_documents:
            if entry.get("graph_id") == "core":
                return entry.get("document")
        return None

    # MARK: _get_shapes_jsonld
    def _get_shapes_jsonld(self) -> Optional[Dict[str, Any]]:
        if self._shapes_jsonld:
            return self._shapes_jsonld
        doc = self._load_jsonld_by_id("shapes")
        if doc:
            self._shapes_jsonld = doc
        return doc

    def _load_data_jsonld_documents(self) -> List[Dict[str, Any]]:
        if self._ontology_store is not None:
            try:
                documents: List[Dict[str, Any]] = []

                def _data_graph_ids(store):
                    if store is None:
                        return []
                    return [
                        entry.graph_id
                        for entry in store.list_graphs()
                        if entry.is_active
                        and entry.graph_type != "shapes"
                        and entry.graph_kind != "pipeline_rev"
                    ]

                for store in (
                    self._ontology_store,
                    self._steps_store,
                    self._pipelines_store,
                ):
                    graph_ids = _data_graph_ids(store)
                    if graph_ids:
                        documents.append(store.export_jsonld(graph_ids, flatten=True))

                if documents:
                    return documents
            except Exception as exc:  # pylint: disable=broad-exception-caught
                logger.error("Failed to load data graphs from QuadStore: %s", exc)
        entries = self._load_jsonld_entries_from_db()
        if not entries:
            entries = self._jsonld_documents
        return [
            entry["document"]
            for entry in entries
            if entry.get("graph_type") != "shapes"
        ]

    def validate_pipeline_jsonld(self, pipeline_doc: Dict[str, Any]) -> Dict[str, Any]:
        """
        Validate a pipeline JSON-LD document against SHACL shapes using the loaded ontology.
        """
        documents = self._load_data_jsonld_documents()
        combined = self._combine_jsonld_documents(documents + [pipeline_doc])
        data_graph = Graph()
        data_graph.parse(data=json.dumps(combined), format="json-ld")
        conforms, report_graph, report_text = shacl_validate(
            data_graph,
            shacl_graph=self.shapes,
            inference="rdfs",
            abort_on_first=False,
            advanced=True,
        )
        return {
            "conforms": bool(conforms),
            "report_graph": report_graph,
            "report_text": report_text,
        }

    # MARK: _combine_jsonld_documents
    @staticmethod
    def _combine_jsonld_documents(documents: List[Dict[str, Any]]) -> Dict[str, Any]:
        merged_context: Dict[str, Any] = {}
        combined_graph: List[Dict[str, Any]] = []
        seen_ids: set[str] = set()

        for doc in documents:
            context = doc.get("@context")
            if isinstance(context, dict):
                for key, value in context.items():
                    if key in merged_context and merged_context[key] != value:
                        logger.warning(
                            "JSON-LD context conflict for '%s': %s != %s",
                            key,
                            merged_context[key],
                            value,
                        )
                        continue
                    merged_context[key] = value
            elif isinstance(context, list):
                for item in context:
                    if not isinstance(item, dict):
                        continue
                    for key, value in item.items():
                        if key in merged_context and merged_context[key] != value:
                            logger.warning(
                                "JSON-LD context conflict for '%s': %s != %s",
                                key,
                                merged_context[key],
                                value,
                            )
                            continue
                        merged_context[key] = value

            graph = doc.get("@graph")
            doc_id = doc.get("@id")
            # Preserve named-graph envelopes exported as:
            # {"@id": "<graph-id>", "@graph": [ ...triples-as-nodes... ]}.
            # Without this, the @id is dropped and consumers see
            # urn:x-rdflib:default instead of the real graph id.
            if isinstance(doc_id, str) and isinstance(graph, list):
                has_nested_named_graph = any(
                    isinstance(node, dict) and "@graph" in node for node in graph
                )
                if not has_nested_named_graph:
                    if doc_id in seen_ids:
                        continue
                    seen_ids.add(doc_id)
                    combined_graph.append({"@id": doc_id, "@graph": graph})
                    continue

            if isinstance(graph, list):
                for node in graph:
                    if not isinstance(node, dict):
                        continue
                    node_id = node.get("@id")
                    if isinstance(node_id, str):
                        if node_id in seen_ids:
                            continue
                        seen_ids.add(node_id)
                    combined_graph.append(node)
            elif isinstance(doc, dict):
                node_id = doc.get("@id")
                if isinstance(node_id, str):
                    if node_id in seen_ids:
                        continue
                    seen_ids.add(node_id)
                combined_graph.append(doc)

        return {"@context": merged_context, "@graph": combined_graph}

    # MARK: _load_packaged_resources
    def _load_packaged_resources(self, *, load_resources: bool) -> bool:
        """
        Load packaged ontology resources into appropriate graphs.
        ontology/shapes/*.jsonld (SHACL shapes) -> self.shapes
        ontology/core/*.jsonld + ontology/vocab/*.jsonld (ontology data) -> self.graph
        """
        requested = bool(load_resources)
        if not requested:
            db_path = self._db_paths.ontology
            if not db_path.exists():
                requested = True
            else:
                try:
                    con = duckdb.connect(str(db_path), read_only=True)
                    try:
                        tables = {
                            row[0]
                            for row in con.execute("PRAGMA show_tables").fetchall()
                        }
                        if "graphs" not in tables:
                            requested = True
                        else:
                            count = con.execute(
                                "SELECT COUNT(*) FROM graphs"
                            ).fetchone()[0]
                            if int(count or 0) == 0:
                                requested = True
                    finally:
                        con.close()
                except Exception:  # pylint: disable=broad-exception-caught
                    requested = True
        if not requested:
            return False
        # Load SHACL shapes into shapes graph
        shapes_loaded = False
        shapes_path: Optional[str] = None
        try:
            shapes_dir = resources.files(__package__) / "ontology" / "shapes"
            if shapes_dir.is_dir():
                shapes_path = str(shapes_dir)
                shapes_docs: List[Dict[str, Any]] = []
                entries = sorted(
                    [e for e in shapes_dir.iterdir() if e.name.endswith(".jsonld")],
                    key=lambda e: e.name,
                )
                for entry in entries:
                    with resources.as_file(entry) as tmp_path:
                        doc = self._load_jsonld_text(
                            tmp_path.read_text(encoding="utf-8"),
                            str(tmp_path),
                            f"shapes/{entry.name}",
                            graph_type="shapes",
                            package="cogniflow",
                            record=False,
                        )
                        if doc:
                            shapes_docs.append(doc)
                if shapes_docs:
                    merged_shapes = self._combine_jsonld_documents(shapes_docs)
                    self._record_jsonld_document(
                        "shapes",
                        merged_shapes,
                        "shapes",
                        "cogniflow",
                        shapes_path,
                    )
                    self._shapes_jsonld = merged_shapes
                    shapes_loaded = True
        except Exception as exc:  # pylint: disable=broad-exception-caught
            logger.error("Error accessing packaged shapes JSON-LD: %s", exc)

        if not shapes_loaded:
            logger.warning("Packaged shapes JSON-LD missing under ontology/shapes/")

        # Load core ontology into data graph
        core_loaded = False
        data_docs: List[Dict[str, Any]] = []
        try:
            core_dir = resources.files(__package__) / "ontology" / "core"
            vocab_dir = resources.files(__package__) / "ontology" / "vocab"
            for data_dir in (core_dir, vocab_dir):
                if not data_dir.is_dir():
                    continue
                entries = sorted(
                    [e for e in data_dir.iterdir() if e.name.endswith(".jsonld")],
                    key=lambda e: e.name,
                )
                for entry in entries:
                    with resources.as_file(entry) as tmp_path:
                        doc = self._load_jsonld_text(
                            tmp_path.read_text(encoding="utf-8"),
                            str(tmp_path),
                            f"{data_dir.name}/{entry.name}",
                            record=False,
                        )
                        if doc:
                            data_docs.append(doc)
                        core_loaded = True
        except Exception as exc:  # pylint: disable=broad-exception-caught
            logger.error("Error accessing packaged ontology JSON-LD: %s", exc)

        if not core_loaded:
            logger.warning(
                "Packaged ontology JSON-LD missing under ontology/core/ or ontology/vocab/"
            )
        elif data_docs:
            merged = self._combine_jsonld_documents(data_docs)
            if shapes_loaded and not self._validate_jsonld(merged, "core", shapes_path):
                raise ValueError(
                    "Core ontology JSON-LD does not conform to SHACL shapes."
                )
            self._record_jsonld_document(
                "core",
                merged,
                "definitions",
                "cogniflow",
                str(core_dir),
            )
        return True

    # ------------------------------------------------------------------ #
    # MARK: Quads-backed query helpers
    # ------------------------------------------------------------------ #

    def _quads_available(self) -> bool:
        return self._ontology_store is not None and self._steps_store is not None

    def quads_available(self) -> bool:
        """Return whether quads-backed stores are initialized and available."""
        return self._quads_available()

    def list_active_graph_catalog(self) -> List[Dict[str, Any]]:
        """Return active graph catalog entries across ontology/steps/pipelines stores."""
        stores = [
            ("ontology", self._ontology_store),
            ("steps", self._steps_store),
            ("pipelines", self._pipelines_store),
        ]
        rows: List[Dict[str, Any]] = []
        for store_name, store in stores:
            if store is None:
                continue
            try:
                entries = store.list_graphs()
            except Exception:  # pylint: disable=broad-exception-caught
                continue
            for entry in entries:
                if not entry.is_active:
                    continue
                rows.append(
                    {
                        "store": store_name,
                        "graph_id": entry.graph_id,
                        "graph_type": entry.graph_type,
                        "package": entry.package,
                        "path": entry.path,
                        "graph_kind": entry.graph_kind,
                        "package_name": entry.package_name,
                        "package_version": entry.package_version,
                        "canonical_id": entry.canonical_id,
                        "compact_id": entry.compact_id,
                        "local_id": entry.local_id,
                        "aliases": list(entry.aliases),
                    }
                )
        return rows

    def query_quads(
        self,
        *,
        graph_id: Optional[str] = None,
        subject: Optional[str] = None,
        predicate: Optional[str] = None,
        obj: Optional[str] = None,
        limit: Optional[int] = None,
        include_steps: bool = True,
        include_pipelines: bool = True,
    ) -> List[Dict[str, Any]]:
        """Query raw quads across stores for diagnostics and node inspection."""
        stores: List[tuple[str, Any]] = [("ontology", self._ontology_store)]
        if include_steps:
            stores.append(("steps", self._steps_store))
        if include_pipelines:
            stores.append(("pipelines", self._pipelines_store))

        rows: List[Dict[str, Any]] = []
        seen: set[tuple[str, str, str, str, str]] = set()
        remaining = limit if isinstance(limit, int) and limit > 0 else None
        for store_name, store in stores:
            if store is None:
                continue
            if remaining is not None and remaining <= 0:
                break
            store_limit = remaining if remaining is not None else None
            try:
                store_rows = store.query_quads(
                    graph_id=graph_id,
                    subject=subject,
                    predicate=predicate,
                    obj=obj,
                    limit=store_limit,
                )
            except Exception:  # pylint: disable=broad-exception-caught
                continue
            for row in store_rows:
                key = (
                    str(row.get("graph_id") or ""),
                    str(row.get("subject") or ""),
                    str(row.get("predicate") or ""),
                    str(row.get("object") or ""),
                    str(row.get("object_kind") or ""),
                )
                if key in seen:
                    continue
                seen.add(key)
                payload = dict(row)
                payload["store"] = store_name
                rows.append(payload)
                if remaining is not None:
                    remaining -= 1
                    if remaining <= 0:
                        break
        return rows

    @staticmethod
    def _quads_connect(
        path: Path, *, read_only: bool = True
    ) -> duckdb.DuckDBPyConnection:
        return duckdb.connect(str(path), read_only=bool(read_only))

    @staticmethod
    def _rdf_iri(value: URIRef) -> str:
        return str(value)

    @staticmethod
    def _literal_from_row(
        value: str, datatype: Optional[str], lang: Optional[str]
    ) -> Literal:
        dt = URIRef(datatype) if datatype else None
        return Literal(value, lang=lang, datatype=dt)

    def _quads_fetchall(self, path: Path, sql: str, params: List[Any]) -> List[tuple]:
        if not path.exists():
            return []
        con = self._quads_connect(path, read_only=True)
        try:
            return con.execute(sql, params).fetchall()
        finally:
            con.close()

    def _quads_fetchone(
        self, path: Path, sql: str, params: List[Any]
    ) -> Optional[tuple]:
        if not path.exists():
            return None
        con = self._quads_connect(path, read_only=True)
        try:
            return con.execute(sql, params).fetchone()
        finally:
            con.close()

    def _quads_paths_for(self, *, include_steps: bool = True) -> List[Path]:
        paths = [self._db_paths.ontology]
        if include_steps:
            paths.append(self._db_paths.steps)
        return paths

    def _quads_context_map(self) -> Dict[str, Any]:
        if self._quads_context_cache is not None:
            return dict(self._quads_context_cache)
        context: Dict[str, Any] = {}

        def _merge_context(payload: Optional[Dict[str, Any]]) -> None:
            if not payload:
                return
            for key, value in payload.items():
                if key in context and context[key] != value:
                    logger.warning(
                        "JSON-LD context conflict for '%s': %s != %s",
                        key,
                        context[key],
                        value,
                    )
                    continue
                context[key] = value

        for path in self._quads_paths_for():
            if not path.exists():
                continue
            con = self._quads_connect(path, read_only=True)
            try:
                rows = con.execute(
                    "SELECT context FROM graphs WHERE is_active = TRUE"
                ).fetchall()
            except Exception:  # pylint: disable=broad-exception-caught
                rows = []
            finally:
                con.close()
            for (ctx_value,) in rows:
                if isinstance(ctx_value, dict):
                    _merge_context(ctx_value)
                    continue
                if ctx_value is None:
                    continue
                try:
                    parsed = json.loads(str(ctx_value))
                except Exception:  # pylint: disable=broad-exception-caught
                    continue
                if isinstance(parsed, dict):
                    _merge_context(parsed)
        self._quads_context_cache = dict(context)
        return context

    def get_quads_context(self) -> Dict[str, Any]:
        """Expose cached JSON-LD @context map for quads backend."""
        return self._quads_context_map()

    def get_namespace_graph(self) -> Graph:
        """Build a namespace-only graph from the current quads JSON-LD context."""
        graph = Graph()
        ctx = self._quads_context_map()
        for prefix, value in ctx.items():
            if isinstance(prefix, str) and isinstance(value, str):
                if value.startswith("http://") or value.startswith("https://"):
                    graph.bind(prefix, value)
        return graph

    def _resolve_curie_quads(self, term: str, context: Dict[str, Any]) -> str:
        raw = term.strip()
        if raw.startswith("http://") or raw.startswith("https://"):
            return raw
        if ":" in raw:
            prefix, local = raw.split(":", 1)
            prefix_value = context.get(prefix)
            if isinstance(prefix_value, str):
                return f"{prefix_value}{local}"
        if ":" not in raw:
            return f"https://cogniflow.org/ns#{raw}"
        return raw

    def list_classes_quads(self, *, include_external: bool = False) -> List[URIRef]:
        """List ontology classes from the quads store."""
        class_iris = set()
        sql = """
            SELECT DISTINCT s
            FROM rdf_quads
            WHERE p = ?
              AND o IN (?, ?)
              AND s_kind != 'bnode'
        """
        params = [
            self._rdf_iri(RDF.type),
            self._rdf_iri(OWL.Class),
            self._rdf_iri(RDFS.Class),
        ]
        for path in [self._db_paths.ontology]:
            for (s,) in self._quads_fetchall(path, sql, params):
                if not isinstance(s, str):
                    continue
                if not include_external:
                    if not (
                        s.startswith("https://cogniflow.org/ns#")
                        or s.startswith("http://cogniflow.org/ns#")
                    ):
                        continue
                class_iris.add(URIRef(s))
        return sorted(class_iris, key=str)

    def list_classes_with_labels_quads(
        self,
        *,
        include_external: bool = False,
    ) -> List[tuple[URIRef, Optional[Literal]]]:
        """List ontology classes with an optional preferred label."""
        path = self._db_paths.ontology
        if not path.exists():
            return []
        sql = """
            SELECT
              t.s,
              ANY_VALUE(l.o) AS label_value,
              ANY_VALUE(l.o_datatype) AS label_datatype,
              ANY_VALUE(l.o_lang) AS label_lang
            FROM rdf_quads t
            LEFT JOIN rdf_quads l
              ON l.s = t.s
             AND l.p = ?
             AND l.o_kind = 'literal'
            WHERE t.p = ?
              AND t.o IN (?, ?)
              AND t.s_kind != 'bnode'
            GROUP BY t.s
            ORDER BY t.s
        """
        params = [
            self._rdf_iri(RDFS.label),
            self._rdf_iri(RDF.type),
            self._rdf_iri(OWL.Class),
            self._rdf_iri(RDFS.Class),
        ]
        rows = self._quads_fetchall(path, sql, params)
        results: List[tuple[URIRef, Optional[Literal]]] = []
        for s, label_value, label_datatype, label_lang in rows:
            if not isinstance(s, str):
                continue
            if not include_external:
                if not (
                    s.startswith("https://cogniflow.org/ns#")
                    or s.startswith("http://cogniflow.org/ns#")
                ):
                    continue
            label = None
            if label_value is not None:
                label = self._literal_from_row(
                    str(label_value), label_datatype, label_lang
                )
            results.append((URIRef(s), label))
        return results

    def get_class_labels_quads(
        self,
        class_iris: List[URIRef],
    ) -> Dict[URIRef, Literal]:
        """Resolve labels for a list of class IRIs from quads."""
        if not class_iris:
            return {}
        labels: Dict[URIRef, Literal] = {}

        def _chunk(items: List[URIRef], size: int) -> Iterable[List[URIRef]]:
            for idx in range(0, len(items), size):
                yield items[idx : idx + size]

        predicate = self._rdf_iri(RDFS.label)
        for path in self._quads_paths_for():
            for batch in _chunk(class_iris, 500):
                placeholders = ", ".join(["?"] * len(batch))
                sql = f"""
                    SELECT s, o, o_datatype, o_lang
                    FROM rdf_quads
                    WHERE p = ?
                      AND o_kind = 'literal'
                      AND s IN ({placeholders})
                """
                params: List[Any] = [predicate] + [str(item) for item in batch]
                rows = self._quads_fetchall(path, sql, params)
                for s, o, o_datatype, o_lang in rows:
                    uri = URIRef(str(s))
                    if uri in labels:
                        continue
                    labels[uri] = self._literal_from_row(str(o), o_datatype, o_lang)
        return labels

    def _quads_get_literals(
        self,
        subject: str,
        predicate: str,
        *,
        paths: Optional[List[Path]] = None,
        limit: Optional[int] = None,
    ) -> List[Literal]:
        literals: List[Literal] = []
        sql = """
            SELECT o, o_datatype, o_lang
            FROM rdf_quads
            WHERE s = ? AND p = ? AND o_kind = 'literal'
        """
        params = [subject, predicate]
        for path in paths or self._quads_paths_for():
            rows = self._quads_fetchall(path, sql, params)
            for value, datatype, lang in rows:
                literals.append(self._literal_from_row(str(value), datatype, lang))
                if limit and len(literals) >= limit:
                    return literals
        return literals

    def _quads_get_objects(
        self,
        subject: str,
        predicate: str,
        *,
        paths: Optional[List[Path]] = None,
        include_bnodes: bool = False,
    ) -> List[URIRef | BNode]:
        objects: List[URIRef | BNode] = []
        sql = """
            SELECT o, o_kind
            FROM rdf_quads
            WHERE s = ? AND p = ?
        """
        params = [subject, predicate]
        for path in paths or self._quads_paths_for():
            rows = self._quads_fetchall(path, sql, params)
            for value, kind in rows:
                if kind == "literal":
                    continue
                if kind == "bnode":
                    if include_bnodes:
                        objects.append(BNode(str(value)))
                    continue
                objects.append(URIRef(str(value)))
        return objects

    def _quads_get_subjects(
        self,
        predicate: str,
        obj_value: str,
        *,
        paths: Optional[List[Path]] = None,
    ) -> List[URIRef]:
        subjects: List[URIRef] = []
        sql = """
            SELECT s, s_kind
            FROM rdf_quads
            WHERE p = ? AND o = ?
        """
        params = [predicate, obj_value]
        for path in paths or self._quads_paths_for():
            rows = self._quads_fetchall(path, sql, params)
            for value, kind in rows:
                if kind == "bnode":
                    continue
                subjects.append(URIRef(str(value)))
        return subjects

    def _quads_get_bnode_objects(
        self,
        subject: str,
        predicate: str,
        *,
        paths: Optional[List[Path]] = None,
    ) -> List[BNode]:
        bnodes: List[BNode] = []
        sql = """
            SELECT o, o_kind
            FROM rdf_quads
            WHERE s = ? AND p = ?
        """
        params = [subject, predicate]
        for path in paths or self._quads_paths_for():
            rows = self._quads_fetchall(path, sql, params)
            for value, kind in rows:
                if kind == "bnode":
                    bnodes.append(BNode(str(value)))
        return bnodes

    def _quads_get_list_members(
        self,
        list_node: BNode,
        *,
        paths: Optional[List[Path]] = None,
    ) -> List[URIRef]:
        members: List[URIRef] = []
        current = list_node

        def _lookup(
            path_list: List[Path], subj: BNode, pred: URIRef
        ) -> Optional[tuple]:
            for path in path_list:
                rows = self._quads_fetchall(
                    path,
                    "SELECT o, o_kind FROM rdf_quads WHERE s = ? AND p = ?",
                    [str(subj), self._rdf_iri(pred)],
                )
                if rows:
                    return rows[0]
            return None

        path_list = paths or self._quads_paths_for()
        while True:
            first_row = _lookup(path_list, current, RDF.first)
            rest_row = _lookup(path_list, current, RDF.rest)
            if first_row:
                o, kind = first_row
                if kind != "literal" and kind != "bnode":
                    members.append(URIRef(str(o)))
            if not rest_row:
                break
            rest_value, rest_kind = rest_row
            if rest_kind != "bnode":
                if str(rest_value) == self._rdf_iri(RDF.nil):
                    break
                break
            current = BNode(str(rest_value))
        return members

    def get_class_info_quads(
        self,
        class_id: str,
        *,
        include_inherited: bool = False,
    ) -> Optional[Dict[str, Any]]:
        """Return class metadata and related properties from quads."""
        ctx = self._quads_context_map()
        class_iri = self._resolve_curie_quads(class_id, ctx)
        class_uri = URIRef(class_iri)

        classes = set(self.list_classes_quads(include_external=True))
        if class_uri not in classes:
            return None

        label = self._quads_get_literals(class_iri, self._rdf_iri(RDFS.label), limit=1)
        description = self._quads_get_literals(
            class_iri,
            "https://cogniflow.org/ns#description",
            limit=1,
        )
        if not description:
            description = self._quads_get_literals(
                class_iri,
                "http://cogniflow.org/ns#description",
                limit=1,
            )
        if not description:
            description = self._quads_get_literals(
                class_iri,
                self._rdf_iri(RDFS.comment),
                limit=1,
            )

        superclasses = self._quads_get_objects(
            class_iri, self._rdf_iri(RDFS.subClassOf)
        )
        subclasses = self._quads_get_subjects(self._rdf_iri(RDFS.subClassOf), class_iri)

        union_nodes = self._quads_get_bnode_objects(
            class_iri,
            self._rdf_iri(OWL.unionOf),
        )
        union_members: List[URIRef] = []
        for node in union_nodes:
            union_members.extend(self._quads_get_list_members(node))

        def _collect_props(domain_iri: str) -> List[URIRef]:
            return self._quads_get_subjects(self._rdf_iri(RDFS.domain), domain_iri)

        direct_props = set(_collect_props(class_iri))
        inherited_props: set[URIRef] = set()
        if include_inherited:
            for parent in superclasses:
                inherited_props.update(_collect_props(str(parent)))
            inherited_props.difference_update(direct_props)

        def _prop_info(prop: URIRef) -> Dict[str, Any]:
            p_iri = str(prop)
            p_label = self._quads_get_literals(
                p_iri, self._rdf_iri(RDFS.label), limit=1
            )
            p_desc = self._quads_get_literals(
                p_iri,
                "https://cogniflow.org/ns#description",
                limit=1,
            )
            if not p_desc:
                p_desc = self._quads_get_literals(
                    p_iri,
                    "http://cogniflow.org/ns#description",
                    limit=1,
                )
            p_type = "Property"
            type_objects = self._quads_get_objects(p_iri, self._rdf_iri(RDF.type))
            if URIRef(self._rdf_iri(OWL.ObjectProperty)) in type_objects:
                p_type = "ObjectProperty"
            elif URIRef(self._rdf_iri(OWL.DatatypeProperty)) in type_objects:
                p_type = "DatatypeProperty"
            ranges = self._quads_get_objects(p_iri, self._rdf_iri(RDFS.range))
            return {
                "id": prop,
                "label": p_label[0] if p_label else None,
                "description": p_desc[0] if p_desc else None,
                "type": p_type,
                "ranges": ranges,
            }

        return {
            "id": class_uri,
            "label": label[0] if label else None,
            "description": description[0] if description else None,
            "superclasses": sorted(superclasses, key=str),
            "subclasses": sorted(subclasses, key=str),
            "union_members": sorted(union_members, key=str),
            "properties": [_prop_info(p) for p in sorted(direct_props, key=str)],
            "inherited_properties": [
                _prop_info(p) for p in sorted(inherited_props, key=str)
            ],
        }

    def list_processing_steps_quads(self) -> List[URIRef]:
        """List ProcessingStep instances from quads across ontology/steps stores."""
        steps: set[URIRef] = set()
        sql = """
            SELECT DISTINCT s
            FROM rdf_quads
            WHERE p = ? AND o IN (?, ?)
              AND s_kind != 'bnode'
        """
        params = [
            self._rdf_iri(RDF.type),
            "https://cogniflow.org/ns#ProcessingStep",
            "http://cogniflow.org/ns#ProcessingStep",
        ]
        for path in self._quads_paths_for(include_steps=True):
            for (s,) in self._quads_fetchall(path, sql, params):
                if isinstance(s, str):
                    steps.add(URIRef(s))
        return sorted(steps, key=str)

    def _is_processing_step_quads(self, step_iri: str) -> bool:
        """Return True when a subject is typed as cf:ProcessingStep in steps quads."""
        sql = """
            SELECT 1
            FROM rdf_quads
            WHERE s = ?
              AND p = ?
              AND o IN (?, ?)
            LIMIT 1
        """
        params = [
            step_iri,
            self._rdf_iri(RDF.type),
            "https://cogniflow.org/ns#ProcessingStep",
            "http://cogniflow.org/ns#ProcessingStep",
        ]
        rows = self._quads_fetchall(self._db_paths.steps, sql, params)
        return bool(rows)

    def list_processing_steps_basic_quads(self) -> List[Dict[str, Any]]:
        """Return step id + label/description without per-step queries."""
        path = self._db_paths.steps
        if not path.exists():
            return []
        sql = """
            WITH steps AS (
              SELECT DISTINCT s
              FROM rdf_quads
              WHERE p = ?
                AND o IN (?, ?)
                AND s_kind != 'bnode'
            )
            SELECT
              s.s AS step_id,
              ANY_VALUE(l.o) AS label_value,
              ANY_VALUE(l.o_datatype) AS label_datatype,
              ANY_VALUE(l.o_lang) AS label_lang,
              ANY_VALUE(d.o) AS desc_value,
              ANY_VALUE(d.o_datatype) AS desc_datatype,
              ANY_VALUE(d.o_lang) AS desc_lang
            FROM steps s
            LEFT JOIN rdf_quads l
              ON l.s = s.s
             AND l.p = ?
             AND l.o_kind = 'literal'
            LEFT JOIN rdf_quads d
              ON d.s = s.s
             AND d.p IN (?, ?)
             AND d.o_kind = 'literal'
            GROUP BY s.s
            ORDER BY s.s
        """
        params = [
            self._rdf_iri(RDF.type),
            "https://cogniflow.org/ns#ProcessingStep",
            "http://cogniflow.org/ns#ProcessingStep",
            self._rdf_iri(RDFS.label),
            "https://cogniflow.org/ns#description",
            "http://cogniflow.org/ns#description",
        ]
        rows = self._quads_fetchall(path, sql, params)
        results: List[Dict[str, Any]] = []
        for (
            step_id,
            label_value,
            label_datatype,
            label_lang,
            desc_value,
            desc_datatype,
            desc_lang,
        ) in rows:
            if not isinstance(step_id, str):
                continue
            label = (
                self._literal_from_row(str(label_value), label_datatype, label_lang)
                if label_value is not None
                else None
            )
            desc = (
                self._literal_from_row(str(desc_value), desc_datatype, desc_lang)
                if desc_value is not None
                else None
            )
            results.append(
                {
                    "id": URIRef(step_id),
                    "label": label,
                    "description": desc,
                }
            )
        return results

    def list_instances_quads(
        self,
        class_id: str,
        *,
        include_subclasses: bool = False,
    ) -> List[URIRef]:
        """List instances for a class (and optionally subclasses) from quads."""
        ctx = self._quads_context_map()
        class_iri = self._resolve_curie_quads(class_id, ctx)
        class_uri = URIRef(class_iri)
        classes = {class_uri}
        if include_subclasses:
            subclasses = self._quads_get_subjects(
                self._rdf_iri(RDFS.subClassOf), class_iri
            )
            classes.update(subclasses)
        instances: set[URIRef] = set()
        for cls in classes:
            instances.update(
                self._quads_get_subjects(self._rdf_iri(RDF.type), str(cls))
            )
        return sorted(instances, key=str)

    def get_instance_properties_quads(
        self,
        instance_id: str,
    ) -> Dict[URIRef, List[URIRef | BNode | Literal]]:
        """Return grouped predicate/value properties for an instance from quads."""
        ctx = self._quads_context_map()
        instance_iri = self._resolve_curie_quads(instance_id, ctx)
        props: Dict[URIRef, List[URIRef | BNode | Literal]] = {}
        sql = """
            SELECT p, o, o_kind, o_datatype, o_lang
            FROM rdf_quads
            WHERE s = ?
        """
        for path in self._quads_paths_for():
            rows = self._quads_fetchall(path, sql, [instance_iri])
            for pred, obj, kind, datatype, lang in rows:
                if str(pred) == self._rdf_iri(RDF.type):
                    continue
                pred_uri = URIRef(str(pred))
                if kind == "literal":
                    value = self._literal_from_row(str(obj), datatype, lang)
                elif kind == "bnode":
                    value = BNode(str(obj))
                else:
                    value = URIRef(str(obj))
                props.setdefault(pred_uri, []).append(value)
        return props

    def get_processing_step_info_quads(self, step_id: str) -> Optional[Dict[str, Any]]:
        """Return normalized processing-step metadata from the steps quads store."""
        ctx = self._quads_context_map()
        step_iri = self._resolve_curie_quads(step_id, ctx)
        step_uri = URIRef(step_iri)
        if not self._is_processing_step_quads(step_iri):
            return None

        ns_pairs: List[tuple[str, str]] = []
        for prefix, ns in ctx.items():
            if isinstance(prefix, str) and isinstance(ns, str):
                ns_pairs.append((prefix, ns))
        ns_pairs.sort(key=lambda item: len(item[1]), reverse=True)

        def _compact_uri(value: URIRef | str | None) -> Optional[str]:
            if value is None:
                return None
            text = str(value)
            for prefix, ns in ns_pairs:
                if text.startswith(ns):
                    local = text[len(ns):]
                    if local:
                        return f"{prefix}:{local}"
            return text

        def _local_id(value: URIRef | str | None) -> Optional[str]:
            if value is None:
                return None
            text = str(value)
            if "#" in text:
                return text.split("#", 1)[1]
            if "/" in text:
                return text.rsplit("/", 1)[-1]
            if ":" in text:
                return text.split(":", 1)[1]
            return text

        def _json_safe(value: Any) -> Any:
            if isinstance(value, Literal):
                native = value.toPython()
                if isinstance(native, str):
                    stripped = native.strip()
                    if stripped.startswith("{") or stripped.startswith("["):
                        try:
                            return json.loads(native)
                        except json.JSONDecodeError:
                            return native
                return native
            if isinstance(value, URIRef):
                return _compact_uri(value)
            if isinstance(value, BNode):
                return str(value)
            if isinstance(value, list):
                return [_json_safe(v) for v in value]
            return value

        def _literal_one(subject: str, predicate: str) -> Optional[Literal]:
            vals = self._quads_get_literals(
                subject, predicate, paths=[self._db_paths.steps]
            )
            return vals[0] if vals else None

        def _literal_list(subject: str, predicate: str) -> List[Literal]:
            return self._quads_get_literals(
                subject, predicate, paths=[self._db_paths.steps]
            )

        def _object_list(subject: str, predicate: str) -> List[URIRef | BNode]:
            return self._quads_get_objects(
                subject,
                predicate,
                paths=[self._db_paths.steps],
                include_bnodes=True,
            )

        def _parse_ports(predicate: str) -> List[Dict[str, Any]]:
            ports: List[Dict[str, Any]] = []

            def _literal_bool(value: Optional[Literal]) -> Optional[bool]:
                if value is None:
                    return None
                raw = str(value).strip().lower()
                if raw in {"true", "1", "yes", "y"}:
                    return True
                if raw in {"false", "0", "no", "n"}:
                    return False
                return None

            for port in _object_list(step_iri, predicate):
                port_id = str(port)
                port_name = _literal_one(port_id, "https://cogniflow.org/ns#portName")
                if not port_name:
                    port_name = _literal_one(
                        port_id, "http://cogniflow.org/ns#portName"
                    )
                port_type = self._quads_get_objects(
                    port_id,
                    "https://cogniflow.org/ns#portType",
                    paths=[self._db_paths.steps],
                )
                if not port_type:
                    port_type = self._quads_get_objects(
                        port_id,
                        "http://cogniflow.org/ns#portType",
                        paths=[self._db_paths.steps],
                    )
                port_desc = _literal_one(
                    port_id, "https://cogniflow.org/ns#description"
                )
                if not port_desc:
                    port_desc = _literal_one(
                        port_id, "http://cogniflow.org/ns#description"
                    )
                port_key = self._quads_get_objects(
                    port_id,
                    "https://cogniflow.org/ns#portKey",
                    paths=[self._db_paths.steps],
                )
                if not port_key:
                    port_key = self._quads_get_objects(
                        port_id,
                        "http://cogniflow.org/ns#portKey",
                        paths=[self._db_paths.steps],
                    )
                optional_literal = _literal_one(
                    port_id, "https://cogniflow.org/ns#isOptional"
                )
                default_literal = _literal_one(
                    port_id, "https://cogniflow.org/ns#defaultValue"
                )
                min_literal = _literal_one(port_id, "https://cogniflow.org/ns#minValue")
                max_literal = _literal_one(port_id, "https://cogniflow.org/ns#maxValue")
                allowed_literals = _literal_list(
                    port_id, "https://cogniflow.org/ns#allowedValue"
                )
                raw_port_type_compact = _compact_uri(port_type[0]) if port_type else None
                raw_port_type_uri = str(port_type[0]) if port_type else None
                normalized_port_type = normalize_port_literal_datatype(raw_port_type_compact)
                normalized_port_type_uri = normalize_port_literal_datatype(raw_port_type_uri)
                ports.append(
                    {
                        "@id": str(port),
                        "@type": ["cf:InputPort", "cf:OutputPort"],
                        "canonical_id": str(port),
                        "compact_id": _compact_uri(port),
                        "aliases": [
                            alias
                            for alias in [str(port), _compact_uri(port), _local_id(port)]
                            if isinstance(alias, str) and alias
                        ],
                        "cf:portName": _json_safe(port_name),
                        "cf:portType": normalized_port_type,
                        "cf:portTypeUri": normalized_port_type_uri,
                        "cf:description": _json_safe(port_desc),
                        "cf:portKey": _compact_uri(port_key[0]) if port_key else None,
                        "cf:isOptional": _literal_bool(optional_literal),
                        "cf:defaultValue": _json_safe(default_literal),
                        "cf:minValue": _json_safe(min_literal),
                        "cf:maxValue": _json_safe(max_literal),
                        "cf:allowedValue": _json_safe(allowed_literals),
                    }
                )
            return sorted(ports, key=lambda p: str(p.get("cf:portName") or ""))

        def _parse_parameters() -> List[Dict[str, Any]]:
            params: List[Dict[str, Any]] = []
            requires = {
                str(obj)
                for obj in _object_list(
                    step_iri, "https://cogniflow.org/ns#requiresParameter"
                )
                if isinstance(obj, URIRef)
            }
            for param in _object_list(
                step_iri, "https://cogniflow.org/ns#hasParameter"
            ):
                param_id = str(param)
                param_name = _literal_one(
                    param_id, "https://cogniflow.org/ns#parameterName"
                )
                if not param_name:
                    param_name = _literal_one(
                        param_id, "http://cogniflow.org/ns#parameterName"
                    )
                if not param_name:
                    continue
                param_type = self._quads_get_objects(
                    param_id,
                    "https://cogniflow.org/ns#parameterType",
                    paths=[self._db_paths.steps],
                )
                if not param_type:
                    param_type = self._quads_get_objects(
                        param_id,
                        "http://cogniflow.org/ns#parameterType",
                        paths=[self._db_paths.steps],
                    )
                param_desc = _literal_one(
                    param_id, "https://cogniflow.org/ns#description"
                )
                if not param_desc:
                    param_desc = _literal_one(
                        param_id, "http://cogniflow.org/ns#description"
                    )
                default_literal = _literal_one(
                    param_id, "https://cogniflow.org/ns#defaultValue"
                )
                min_literal = _literal_one(
                    param_id, "https://cogniflow.org/ns#minValue"
                )
                max_literal = _literal_one(
                    param_id, "https://cogniflow.org/ns#maxValue"
                )
                allowed_literals = _literal_list(
                    param_id, "https://cogniflow.org/ns#allowedValue"
                )
                required_literal = _literal_one(
                    param_id, "https://cogniflow.org/ns#isRequired"
                )
                required = str(param) in requires
                if required_literal is not None:
                    required = str(required_literal).strip().lower() in {
                        "true",
                        "1",
                        "yes",
                        "y",
                    }
                raw_param_type_compact = _compact_uri(param_type[0]) if param_type else None
                raw_param_type_uri = str(param_type[0]) if param_type else None
                normalized_param_type = normalize_parameter_type(raw_param_type_compact)
                normalized_param_type_uri = normalize_parameter_type(raw_param_type_uri)
                params.append(
                    {
                        "@id": str(param),
                        "@type": "cf:Parameter",
                        "canonical_id": str(param),
                        "compact_id": _compact_uri(param),
                        "aliases": [
                            alias
                            for alias in [str(param), _compact_uri(param), _local_id(param)]
                            if isinstance(alias, str) and alias
                        ],
                        "cf:parameterName": _json_safe(param_name),
                        "cf:parameterType": normalized_param_type,
                        "cf:parameterTypeUri": normalized_param_type_uri,
                        "cf:description": _json_safe(param_desc),
                        "cf:defaultValue": _json_safe(default_literal),
                        "cf:minValue": _json_safe(min_literal),
                        "cf:maxValue": _json_safe(max_literal),
                        "cf:allowedValue": _json_safe(allowed_literals),
                        "cf:isRequired": bool(required),
                    }
                )
            return sorted(params, key=lambda p: str(p.get("cf:parameterName") or ""))

        label = _literal_one(step_iri, self._rdf_iri(RDFS.label))
        description = _literal_one(step_iri, "https://cogniflow.org/ns#description")
        if not description:
            description = _literal_one(step_iri, "http://cogniflow.org/ns#description")
        category = self._quads_get_objects(
            step_iri,
            "https://cogniflow.org/ns#stepCategory",
            paths=[self._db_paths.steps],
        )
        package = self._quads_get_objects(
            step_iri,
            "https://cogniflow.org/ns#belongsToStepPackage",
            paths=[self._db_paths.steps],
        )
        impls = self._quads_get_objects(
            step_iri,
            "https://cogniflow.org/ns#hasImplementation",
            paths=[self._db_paths.steps],
        )
        step_types = self._quads_get_objects(
            step_iri,
            self._rdf_iri(RDF.type),
            paths=[self._db_paths.steps],
        )
        required_params = [
            _compact_uri(obj)
            for obj in _object_list(step_iri, "https://cogniflow.org/ns#requiresParameter")
            if isinstance(obj, URIRef)
        ]
        required_params = [item for item in required_params if item]
        aliases = [
            alias
            for alias in [str(step_uri), _compact_uri(step_uri), _local_id(step_uri)]
            if isinstance(alias, str) and alias
        ]

        return {
            "@id": str(step_uri),
            "@type": [_compact_uri(t) for t in step_types if isinstance(t, URIRef)],
            "canonical_id": str(step_uri),
            "compact_id": _compact_uri(step_uri),
            "aliases": aliases,
            "rdfs:label": _json_safe(label),
            "cf:description": _json_safe(description),
            "cf:stepCategory": _compact_uri(category[0]) if category else None,
            "cf:belongsToStepPackage": _compact_uri(package[0]) if package else None,
            "cf:hasImplementation": [
                _compact_uri(impl)
                for impl in impls
                if isinstance(impl, URIRef)
            ],
            "cf:hasInput": _parse_ports("https://cogniflow.org/ns#hasInput"),
            "cf:hasOutput": _parse_ports("https://cogniflow.org/ns#hasOutput"),
            "cf:hasParameter": _parse_parameters(),
            "cf:requiresParameter": required_params,
        }

    def get_processing_steps_info_quads_bulk(
        self, step_ids: Optional[List[str]] = None
    ) -> List[Dict[str, Any]]:
        """
        Return rich DTOs for many processing steps.

        When `step_ids` is omitted, all available processing steps are returned.
        Missing/invalid step ids are skipped.
        """
        if not self._db_paths.steps.exists():
            return []

        ctx = self._quads_context_map()
        ns_pairs: List[tuple[str, str]] = []
        for prefix, ns in ctx.items():
            if isinstance(prefix, str) and isinstance(ns, str):
                ns_pairs.append((prefix, ns))
        ns_pairs.sort(key=lambda item: len(item[1]), reverse=True)

        def _compact_uri(value: URIRef | str | None) -> Optional[str]:
            if value is None:
                return None
            text = str(value)
            for prefix, ns in ns_pairs:
                if text.startswith(ns):
                    local = text[len(ns):]
                    if local:
                        return f"{prefix}:{local}"
            return text

        def _local_id(value: URIRef | str | None) -> Optional[str]:
            if value is None:
                return None
            text = str(value)
            if "#" in text:
                return text.split("#", 1)[1]
            if "/" in text:
                return text.rsplit("/", 1)[-1]
            if ":" in text:
                return text.split(":", 1)[1]
            return text

        def _json_safe(value: Any) -> Any:
            if isinstance(value, Literal):
                native = value.toPython()
                if isinstance(native, str):
                    stripped = native.strip()
                    if stripped.startswith("{") or stripped.startswith("["):
                        try:
                            return json.loads(native)
                        except json.JSONDecodeError:
                            return native
                return native
            if isinstance(value, URIRef):
                return _compact_uri(value)
            if isinstance(value, BNode):
                return str(value)
            if isinstance(value, list):
                return [_json_safe(v) for v in value]
            return value

        def _to_term(
            obj: str, kind: Optional[str], datatype: Optional[str], lang: Optional[str]
        ) -> URIRef | BNode | Literal:
            if kind == "literal":
                return self._literal_from_row(str(obj), datatype, lang)
            if kind == "bnode":
                return BNode(str(obj))
            return URIRef(str(obj))

        def _literal_bool(value: Optional[Literal]) -> Optional[bool]:
            if value is None:
                return None
            raw = str(value).strip().lower()
            if raw in {"true", "1", "yes", "y"}:
                return True
            if raw in {"false", "0", "no", "n"}:
                return False
            return None

        def _first_value(
            predicate_map: Dict[str, List[URIRef | BNode | Literal]], predicates: List[str]
        ) -> Optional[URIRef | BNode | Literal]:
            for pred in predicates:
                values = predicate_map.get(pred)
                if values:
                    return values[0]
            return None

        def _all_values(
            predicate_map: Dict[str, List[URIRef | BNode | Literal]], predicates: List[str]
        ) -> List[URIRef | BNode | Literal]:
            for pred in predicates:
                values = predicate_map.get(pred)
                if values:
                    return list(values)
            return []

        def _chunk(items: List[str], size: int) -> Iterable[List[str]]:
            for idx in range(0, len(items), size):
                yield items[idx : idx + size]

        CF_HTTPS = "https://cogniflow.org/ns#"
        CF_HTTP = "http://cogniflow.org/ns#"

        P_STEP_HAS_INPUT = [f"{CF_HTTPS}hasInput", f"{CF_HTTP}hasInput"]
        P_STEP_HAS_OUTPUT = [f"{CF_HTTPS}hasOutput", f"{CF_HTTP}hasOutput"]
        P_STEP_HAS_PARAMETER = [f"{CF_HTTPS}hasParameter", f"{CF_HTTP}hasParameter"]
        P_STEP_REQUIRES_PARAMETER = [
            f"{CF_HTTPS}requiresParameter",
            f"{CF_HTTP}requiresParameter",
        ]
        P_STEP_CATEGORY = [f"{CF_HTTPS}stepCategory", f"{CF_HTTP}stepCategory"]
        P_STEP_PACKAGE = [f"{CF_HTTPS}belongsToStepPackage", f"{CF_HTTP}belongsToStepPackage"]
        P_STEP_IMPL = [f"{CF_HTTPS}hasImplementation", f"{CF_HTTP}hasImplementation"]
        P_DESCRIPTION = [f"{CF_HTTPS}description", f"{CF_HTTP}description"]

        P_PORT_NAME = [f"{CF_HTTPS}portName", f"{CF_HTTP}portName"]
        P_PORT_TYPE = [f"{CF_HTTPS}portType", f"{CF_HTTP}portType"]
        P_PORT_KEY = [f"{CF_HTTPS}portKey", f"{CF_HTTP}portKey"]
        P_PORT_IS_OPTIONAL = [f"{CF_HTTPS}isOptional", f"{CF_HTTP}isOptional"]
        P_DEFAULT_VALUE = [f"{CF_HTTPS}defaultValue", f"{CF_HTTP}defaultValue"]
        P_MIN_VALUE = [f"{CF_HTTPS}minValue", f"{CF_HTTP}minValue"]
        P_MAX_VALUE = [f"{CF_HTTPS}maxValue", f"{CF_HTTP}maxValue"]
        P_ALLOWED_VALUE = [f"{CF_HTTPS}allowedValue", f"{CF_HTTP}allowedValue"]

        P_PARAM_NAME = [f"{CF_HTTPS}parameterName", f"{CF_HTTP}parameterName"]
        P_PARAM_TYPE = [f"{CF_HTTPS}parameterType", f"{CF_HTTP}parameterType"]
        P_PARAM_IS_REQUIRED = [f"{CF_HTTPS}isRequired", f"{CF_HTTP}isRequired"]

        step_predicates = set(
            [
                self._rdf_iri(RDF.type),
                self._rdf_iri(RDFS.label),
                *P_DESCRIPTION,
                *P_STEP_HAS_INPUT,
                *P_STEP_HAS_OUTPUT,
                *P_STEP_HAS_PARAMETER,
                *P_STEP_REQUIRES_PARAMETER,
                *P_STEP_CATEGORY,
                *P_STEP_PACKAGE,
                *P_STEP_IMPL,
            ]
        )
        member_predicates = set(
            [
                *P_DESCRIPTION,
                *P_DEFAULT_VALUE,
                *P_MIN_VALUE,
                *P_MAX_VALUE,
                *P_ALLOWED_VALUE,
                *P_PORT_NAME,
                *P_PORT_TYPE,
                *P_PORT_KEY,
                *P_PORT_IS_OPTIONAL,
                *P_PARAM_NAME,
                *P_PARAM_TYPE,
                *P_PARAM_IS_REQUIRED,
            ]
        )

        con = self._quads_connect(self._db_paths.steps, read_only=True)
        try:
            active_graph_rows = con.execute(
                "SELECT graph_id FROM graphs WHERE is_active = TRUE"
            ).fetchall()
            active_graph_ids = [
                str(row[0]) for row in active_graph_rows if row and isinstance(row[0], str)
            ]
            if not active_graph_ids:
                return []
            g_placeholders = ", ".join(["?"] * len(active_graph_ids))

            if step_ids is None:
                step_rows = con.execute(
                    """
                    SELECT DISTINCT s
                    FROM rdf_quads
                    WHERE g IN ({g_placeholders})
                      AND p = ?
                      AND o IN (?, ?)
                      AND s_kind != 'bnode'
                    ORDER BY s
                    """.format(g_placeholders=g_placeholders),
                    [
                        *active_graph_ids,
                        self._rdf_iri(RDF.type),
                        "https://cogniflow.org/ns#ProcessingStep",
                        "http://cogniflow.org/ns#ProcessingStep",
                    ],
                ).fetchall()
                resolved_step_ids = [str(row[0]) for row in step_rows if isinstance(row[0], str)]
            else:
                seen: set[str] = set()
                requested: List[str] = []
                for raw_id in step_ids:
                    if raw_id is None:
                        continue
                    iri = self._resolve_curie_quads(str(raw_id), ctx)
                    if iri and iri not in seen:
                        seen.add(iri)
                        requested.append(iri)
                if not requested:
                    return []
                resolved_step_ids = []
                for batch in _chunk(requested, 250):
                    placeholders = ", ".join(["?"] * len(batch))
                    rows = con.execute(
                        f"""
                        SELECT DISTINCT s
                        FROM rdf_quads
                        WHERE g IN ({g_placeholders})
                          AND s IN ({placeholders})
                          AND p = ?
                          AND o IN (?, ?)
                          AND s_kind != 'bnode'
                        ORDER BY s
                        """,
                        [
                            *active_graph_ids,
                            *batch,
                            self._rdf_iri(RDF.type),
                            "https://cogniflow.org/ns#ProcessingStep",
                            "http://cogniflow.org/ns#ProcessingStep",
                        ],
                    ).fetchall()
                    resolved_step_ids.extend([str(row[0]) for row in rows if isinstance(row[0], str)])

            if not resolved_step_ids:
                return []

            step_triples: Dict[str, Dict[str, List[URIRef | BNode | Literal]]] = {
                sid: {} for sid in resolved_step_ids
            }
            for batch in _chunk(resolved_step_ids, 200):
                s_placeholders = ", ".join(["?"] * len(batch))
                p_values = sorted(step_predicates)
                p_placeholders = ", ".join(["?"] * len(p_values))
                rows = con.execute(
                    f"""
                    SELECT s, p, o, o_kind, o_datatype, o_lang
                    FROM rdf_quads
                    WHERE g IN ({g_placeholders})
                      AND s IN ({s_placeholders})
                      AND p IN ({p_placeholders})
                    """,
                    [*active_graph_ids, *batch, *p_values],
                ).fetchall()
                for s, p, o, o_kind, o_datatype, o_lang in rows:
                    if not (isinstance(s, str) and isinstance(p, str) and isinstance(o, str)):
                        continue
                    step_triples.setdefault(s, {}).setdefault(p, []).append(
                        _to_term(o, o_kind, o_datatype, o_lang)
                    )

            port_ids: set[str] = set()
            param_ids: set[str] = set()
            for sid in resolved_step_ids:
                pred_map = step_triples.get(sid, {})
                for pred in P_STEP_HAS_INPUT + P_STEP_HAS_OUTPUT:
                    for value in pred_map.get(pred, []):
                        if isinstance(value, URIRef):
                            port_ids.add(str(value))
                for pred in P_STEP_HAS_PARAMETER:
                    for value in pred_map.get(pred, []):
                        if isinstance(value, URIRef):
                            param_ids.add(str(value))
                for pred in P_STEP_REQUIRES_PARAMETER:
                    for value in pred_map.get(pred, []):
                        if isinstance(value, URIRef):
                            param_ids.add(str(value))

            member_subjects = sorted(port_ids | param_ids)
            member_triples: Dict[str, Dict[str, List[URIRef | BNode | Literal]]] = {
                sid: {} for sid in member_subjects
            }
            if member_subjects:
                for batch in _chunk(member_subjects, 200):
                    s_placeholders = ", ".join(["?"] * len(batch))
                    p_values = sorted(member_predicates)
                    p_placeholders = ", ".join(["?"] * len(p_values))
                    rows = con.execute(
                        f"""
                        SELECT s, p, o, o_kind, o_datatype, o_lang
                        FROM rdf_quads
                        WHERE g IN ({g_placeholders})
                          AND s IN ({s_placeholders})
                          AND p IN ({p_placeholders})
                        """,
                        [*active_graph_ids, *batch, *p_values],
                    ).fetchall()
                    for s, p, o, o_kind, o_datatype, o_lang in rows:
                        if not (isinstance(s, str) and isinstance(p, str) and isinstance(o, str)):
                            continue
                        member_triples.setdefault(s, {}).setdefault(p, []).append(
                            _to_term(o, o_kind, o_datatype, o_lang)
                        )
        finally:
            con.close()

        results: List[Dict[str, Any]] = []
        for step_iri in resolved_step_ids:
            step_uri = URIRef(step_iri)
            step_map = step_triples.get(step_iri, {})

            step_types = [
                value
                for value in step_map.get(self._rdf_iri(RDF.type), [])
                if isinstance(value, URIRef)
            ]
            label_value = _first_value(step_map, [self._rdf_iri(RDFS.label)])
            description_value = _first_value(step_map, P_DESCRIPTION)
            category_value = _first_value(step_map, P_STEP_CATEGORY)
            package_value = _first_value(step_map, P_STEP_PACKAGE)
            impl_values = [
                value for value in _all_values(step_map, P_STEP_IMPL) if isinstance(value, URIRef)
            ]

            step_input_ids = [
                str(value)
                for value in _all_values(step_map, P_STEP_HAS_INPUT)
                if isinstance(value, URIRef)
            ]
            step_output_ids = [
                str(value)
                for value in _all_values(step_map, P_STEP_HAS_OUTPUT)
                if isinstance(value, URIRef)
            ]
            step_param_ids = [
                str(value)
                for value in _all_values(step_map, P_STEP_HAS_PARAMETER)
                if isinstance(value, URIRef)
            ]
            step_required_param_ids = {
                str(value)
                for value in _all_values(step_map, P_STEP_REQUIRES_PARAMETER)
                if isinstance(value, URIRef)
            }

            ports_in: List[Dict[str, Any]] = []
            for port_id in step_input_ids:
                port_uri = URIRef(port_id)
                port_map = member_triples.get(port_id, {})
                port_name = _first_value(port_map, P_PORT_NAME)
                port_type = _first_value(port_map, P_PORT_TYPE)
                port_desc = _first_value(port_map, P_DESCRIPTION)
                port_key = _first_value(port_map, P_PORT_KEY)
                optional = _first_value(port_map, P_PORT_IS_OPTIONAL)
                default_value = _first_value(port_map, P_DEFAULT_VALUE)
                min_value = _first_value(port_map, P_MIN_VALUE)
                max_value = _first_value(port_map, P_MAX_VALUE)
                allowed_values = _all_values(port_map, P_ALLOWED_VALUE)
                raw_port_type_compact = _compact_uri(port_type) if isinstance(port_type, URIRef) else None
                raw_port_type_uri = str(port_type) if isinstance(port_type, URIRef) else None
                normalized_port_type = normalize_port_literal_datatype(raw_port_type_compact)
                normalized_port_type_uri = normalize_port_literal_datatype(raw_port_type_uri)
                ports_in.append(
                    {
                        "@id": port_id,
                        "@type": ["cf:InputPort", "cf:OutputPort"],
                        "canonical_id": port_id,
                        "compact_id": _compact_uri(port_uri),
                        "aliases": [
                            alias
                            for alias in [port_id, _compact_uri(port_uri), _local_id(port_uri)]
                            if isinstance(alias, str) and alias
                        ],
                        "cf:portName": _json_safe(port_name),
                        "cf:portType": normalized_port_type,
                        "cf:portTypeUri": normalized_port_type_uri,
                        "cf:description": _json_safe(port_desc),
                        "cf:portKey": _compact_uri(port_key) if isinstance(port_key, URIRef) else None,
                        "cf:isOptional": _literal_bool(optional if isinstance(optional, Literal) else None),
                        "cf:defaultValue": _json_safe(default_value),
                        "cf:minValue": _json_safe(min_value),
                        "cf:maxValue": _json_safe(max_value),
                        "cf:allowedValue": _json_safe(allowed_values),
                    }
                )
            ports_in = sorted(ports_in, key=lambda p: str(p.get("cf:portName") or ""))

            ports_out: List[Dict[str, Any]] = []
            for port_id in step_output_ids:
                port_uri = URIRef(port_id)
                port_map = member_triples.get(port_id, {})
                port_name = _first_value(port_map, P_PORT_NAME)
                port_type = _first_value(port_map, P_PORT_TYPE)
                port_desc = _first_value(port_map, P_DESCRIPTION)
                port_key = _first_value(port_map, P_PORT_KEY)
                optional = _first_value(port_map, P_PORT_IS_OPTIONAL)
                default_value = _first_value(port_map, P_DEFAULT_VALUE)
                min_value = _first_value(port_map, P_MIN_VALUE)
                max_value = _first_value(port_map, P_MAX_VALUE)
                allowed_values = _all_values(port_map, P_ALLOWED_VALUE)
                raw_port_type_compact = _compact_uri(port_type) if isinstance(port_type, URIRef) else None
                raw_port_type_uri = str(port_type) if isinstance(port_type, URIRef) else None
                normalized_port_type = normalize_port_literal_datatype(raw_port_type_compact)
                normalized_port_type_uri = normalize_port_literal_datatype(raw_port_type_uri)
                ports_out.append(
                    {
                        "@id": port_id,
                        "@type": ["cf:InputPort", "cf:OutputPort"],
                        "canonical_id": port_id,
                        "compact_id": _compact_uri(port_uri),
                        "aliases": [
                            alias
                            for alias in [port_id, _compact_uri(port_uri), _local_id(port_uri)]
                            if isinstance(alias, str) and alias
                        ],
                        "cf:portName": _json_safe(port_name),
                        "cf:portType": normalized_port_type,
                        "cf:portTypeUri": normalized_port_type_uri,
                        "cf:description": _json_safe(port_desc),
                        "cf:portKey": _compact_uri(port_key) if isinstance(port_key, URIRef) else None,
                        "cf:isOptional": _literal_bool(optional if isinstance(optional, Literal) else None),
                        "cf:defaultValue": _json_safe(default_value),
                        "cf:minValue": _json_safe(min_value),
                        "cf:maxValue": _json_safe(max_value),
                        "cf:allowedValue": _json_safe(allowed_values),
                    }
                )
            ports_out = sorted(ports_out, key=lambda p: str(p.get("cf:portName") or ""))

            params: List[Dict[str, Any]] = []
            for param_id in step_param_ids:
                param_uri = URIRef(param_id)
                param_map = member_triples.get(param_id, {})
                param_name = _first_value(param_map, P_PARAM_NAME)
                if not isinstance(param_name, Literal):
                    continue
                param_type = _first_value(param_map, P_PARAM_TYPE)
                param_desc = _first_value(param_map, P_DESCRIPTION)
                default_value = _first_value(param_map, P_DEFAULT_VALUE)
                min_value = _first_value(param_map, P_MIN_VALUE)
                max_value = _first_value(param_map, P_MAX_VALUE)
                allowed_values = _all_values(param_map, P_ALLOWED_VALUE)
                required_literal = _first_value(param_map, P_PARAM_IS_REQUIRED)
                required = param_id in step_required_param_ids
                if isinstance(required_literal, Literal):
                    lit_bool = _literal_bool(required_literal)
                    if lit_bool is not None:
                        required = lit_bool
                raw_param_type_compact = (
                    _compact_uri(param_type) if isinstance(param_type, URIRef) else None
                )
                raw_param_type_uri = str(param_type) if isinstance(param_type, URIRef) else None
                normalized_param_type = normalize_parameter_type(raw_param_type_compact)
                normalized_param_type_uri = normalize_parameter_type(raw_param_type_uri)
                params.append(
                    {
                        "@id": param_id,
                        "@type": "cf:Parameter",
                        "canonical_id": param_id,
                        "compact_id": _compact_uri(param_uri),
                        "aliases": [
                            alias
                            for alias in [param_id, _compact_uri(param_uri), _local_id(param_uri)]
                            if isinstance(alias, str) and alias
                        ],
                        "cf:parameterName": _json_safe(param_name),
                        "cf:parameterType": normalized_param_type,
                        "cf:parameterTypeUri": normalized_param_type_uri,
                        "cf:description": _json_safe(param_desc),
                        "cf:defaultValue": _json_safe(default_value),
                        "cf:minValue": _json_safe(min_value),
                        "cf:maxValue": _json_safe(max_value),
                        "cf:allowedValue": _json_safe(allowed_values),
                        "cf:isRequired": bool(required),
                    }
                )
            params = sorted(params, key=lambda p: str(p.get("cf:parameterName") or ""))

            required_params = [
                _compact_uri(URIRef(param_id))
                for param_id in sorted(step_required_param_ids)
            ]
            required_params = [item for item in required_params if item]
            aliases = [
                alias
                for alias in [str(step_uri), _compact_uri(step_uri), _local_id(step_uri)]
                if isinstance(alias, str) and alias
            ]

            results.append(
                {
                    "@id": str(step_uri),
                    "@type": [_compact_uri(t) for t in step_types],
                    "canonical_id": str(step_uri),
                    "compact_id": _compact_uri(step_uri),
                    "aliases": aliases,
                    "rdfs:label": _json_safe(label_value),
                    "cf:description": _json_safe(description_value),
                    "cf:stepCategory": (
                        _compact_uri(category_value)
                        if isinstance(category_value, URIRef)
                        else None
                    ),
                    "cf:belongsToStepPackage": (
                        _compact_uri(package_value)
                        if isinstance(package_value, URIRef)
                        else (
                            _json_safe(package_value)
                            if isinstance(package_value, Literal)
                            else None
                        )
                    ),
                    "cf:hasImplementation": [_compact_uri(impl) for impl in impl_values],
                    "cf:hasInput": ports_in,
                    "cf:hasOutput": ports_out,
                    "cf:hasParameter": params,
                    "cf:requiresParameter": required_params,
                }
            )

        return results


# MARK: get_ontology
@functools.lru_cache(maxsize=1)
def get_ontology() -> OntologyManager:
    """Get or create ontology manager singleton."""
    return OntologyManager()
