diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a131d9a..db650c3f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,20 @@ capability summary fields, tool counts, and description columns. Added `--format json/yaml` schemas for refresh output. Updated CLI reference documentation with refresh examples and caching behavior. (#167) +- Added UKO Layer 0-3 ontology scaffolding (RDF/TTL) aligned with specification + Section 14. Layer 0 (`uko:`) defines InformationUnit, Container, Atom, + Annotation, Boundary plus contains/references/dependsOn relationships, + content properties (hasRendering, renderingDepth, hasFullContent), + provenance properties (sourceResource, sourcePath, sourceRange), and + temporal properties (validFrom, validUntil, isCurrent, isRevisionOf). + Layer 1 (`uko-code:`) defines Module, Callable, TypeDefinition, TestCase, + Import plus hasReturnType/hasParameters/testsCallable. Layer 2 (`uko-oo:`) + defines Class, Interface, Method, Attribute plus inheritsFrom/implements + with `rdfs:subPropertyOf`. Layer 3 is reserved for DetailLevelMap + insertions. Loader supports semantic domain prefixes, hyphenated prefix + names, full-URI layer detection, multi-parent `rdfs:subClassOf` (DAG + traversal via BFS), `rdfs:domain`/`rdfs:range`/`rdfs:subPropertyOf` + resolution, and non-existent parent validation. (#189) - Added `AgentSkillSpec` loader that parses SKILL.md frontmatter and progressive disclosure sections into structured `SkillStep` objects with stable 1-based ordering. Supports namespaced naming (`namespace/short_name`), optional `steps`, `version`, `compatibility`, diff --git a/benchmarks/uko_load_bench.py b/benchmarks/uko_load_bench.py new file mode 100644 index 000000000..00eae47b4 --- /dev/null +++ b/benchmarks/uko_load_bench.py @@ -0,0 +1,80 @@ +"""ASV benchmarks for UKO ontology loader performance. + +Suites: +- UKOLoadSuite: time_load_ttl, time_parse_prefixes, time_parse_nodes +- UKOValidationSuite: time_validate_ontology, time_resolve_inheritance +- UKOLayerSuite: time_get_layer_0, time_get_layer_3 +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.application.services.uko_loader import UKOLoader # noqa: E402 + +_TTL_PATH = Path(__file__).resolve().parents[1] / "docs" / "ontology" / "uko.ttl" +_TTL_TEXT = _TTL_PATH.read_text(encoding="utf-8") + + +class UKOLoadSuite: + """Benchmark TTL loading and parsing throughput.""" + + def setup(self) -> None: + self.loader = UKOLoader() + + def time_load_ttl(self) -> None: + """Benchmark loading uko.ttl from disk.""" + self.loader.load(_TTL_PATH) + + def time_parse_prefixes(self) -> None: + """Benchmark prefix-line parsing.""" + self.loader._parse_prefixes(_TTL_TEXT) + + def time_parse_nodes(self) -> None: + """Benchmark full string parsing.""" + self.loader.load_from_string(_TTL_TEXT) + + +class UKOValidationSuite: + """Benchmark validation and inheritance resolution.""" + + def setup(self) -> None: + self.loader = UKOLoader() + self.ontology = self.loader.load(_TTL_PATH) + + def time_validate_ontology(self) -> None: + """Benchmark ontology validation.""" + self.loader.validate(self.ontology) + + def time_resolve_inheritance(self) -> None: + """Benchmark uko-oo:Class inheritance chain resolution.""" + self.loader.resolve_inheritance( + self.ontology, + "https://cleveragents.ai/ontology/uko/oo#Class", + ) + + +class UKOLayerSuite: + """Benchmark layer-filtered node retrieval.""" + + def setup(self) -> None: + self.loader = UKOLoader() + self.ontology = self.loader.load(_TTL_PATH) + + def time_get_layer_0(self) -> None: + """Benchmark Layer 0 node retrieval.""" + self.loader.get_layer_nodes(self.ontology, 0) + + def time_get_layer_3(self) -> None: + """Benchmark Layer 3 node retrieval.""" + self.loader.get_layer_nodes(self.ontology, 3) diff --git a/docs/ontology/uko.ttl b/docs/ontology/uko.ttl new file mode 100644 index 000000000..6d6f5322b --- /dev/null +++ b/docs/ontology/uko.ttl @@ -0,0 +1,223 @@ +@prefix uko: . +@prefix uko-code: . +@prefix uko-oo: . +@prefix uko-py: . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix xsd: . + +# =========================================================================== +# Ontology metadata +# =========================================================================== + +uko: a owl:Ontology ; + owl:versionIRI ; + rdfs:label "Unified Knowledge Ontology" ; + rdfs:comment "CleverAgents knowledge representation layers 0-3" . + +# =========================================================================== +# Layer 0: Universal Foundation (uko:) +# =========================================================================== + +# -- Base Classes -- + +uko:InformationUnit a owl:Class ; + rdfs:label "InformationUnit" ; + rdfs:comment "The root of every UKO node. Anything that can appear in an actor's context." . + +uko:Container a owl:Class ; + rdfs:subClassOf uko:InformationUnit ; + rdfs:label "Container" ; + rdfs:comment "An information unit that contains other information units (file, module, class, section)." . + +uko:Atom a owl:Class ; + rdfs:subClassOf uko:InformationUnit ; + rdfs:label "Atom" ; + rdfs:comment "A leaf-level information unit (function body, paragraph, config value)." . + +uko:Annotation a owl:Class ; + rdfs:subClassOf uko:InformationUnit ; + rdfs:label "Annotation" ; + rdfs:comment "Metadata attached to another information unit (comment, docstring, attribute)." . + +uko:Boundary a owl:Class ; + rdfs:subClassOf uko:InformationUnit ; + rdfs:label "Boundary" ; + rdfs:comment "An interface point between containers (export, API endpoint, public method signature)." . + +# -- Core Relationships -- + +uko:contains a owl:ObjectProperty ; + rdfs:domain uko:Container ; + rdfs:range uko:InformationUnit ; + rdfs:label "contains" ; + rdfs:comment "Parent contains child (e.g., module contains class)." . + +uko:references a owl:ObjectProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko:InformationUnit ; + rdfs:label "references" ; + rdfs:comment "Weak reference (e.g., a function mentions a type in a docstring)." . + +uko:dependsOn a owl:ObjectProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko:InformationUnit ; + rdfs:label "dependsOn" ; + rdfs:comment "Strong dependency (e.g., import, inheritance, call)." . + +# -- Content Properties -- + +uko:hasRendering a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:string ; + rdfs:label "hasRendering" ; + rdfs:comment "A rendered text representation of this node at a specific detail depth. Multiple renderings at different depths may exist for the same node. The depth is indicated by the associated uko:renderingDepth value." . + +uko:renderingDepth a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:nonNegativeInteger ; + rdfs:label "renderingDepth" ; + rdfs:comment "The integer detail depth at which the associated uko:hasRendering was produced. Paired with uko:hasRendering via a blank node or reification." . + +uko:hasFullContent a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:string ; + rdfs:label "hasFullContent" ; + rdfs:comment "Shorthand for the maximum-depth rendering of this node (complete, unabridged content). Equivalent to uko:hasRendering at the domain's maximum depth." . + +# -- Provenance Properties -- + +uko:sourceResource a owl:ObjectProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:label "sourceResource" ; + rdfs:comment "The CleverAgents Resource (by ULID) this node was extracted from." . + +uko:sourcePath a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:string ; + rdfs:label "sourcePath" ; + rdfs:comment "Path within the resource (e.g., file path relative to the resource root)." . + +uko:sourceRange a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:string ; + rdfs:label "sourceRange" ; + rdfs:comment "Byte or line range within the source file (e.g., '42:1-87:0')." . + +# -- Temporal Properties -- + +uko:validFrom a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:dateTime ; + rdfs:label "validFrom" . + +uko:validUntil a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:dateTime ; + rdfs:label "validUntil" . + +uko:isCurrent a owl:DatatypeProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range xsd:boolean ; + rdfs:label "isCurrent" . + +uko:isRevisionOf a owl:ObjectProperty ; + rdfs:domain uko:InformationUnit ; + rdfs:range uko:InformationUnit ; + rdfs:label "isRevisionOf" ; + rdfs:comment "Links a revised node to its predecessor." . + +# =========================================================================== +# Layer 1: General Software (uko-code:) +# =========================================================================== + +uko-code:Module a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "Module" ; + rdfs:comment "A source code module (file, package, namespace)." . + +uko-code:Callable a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Callable" ; + rdfs:comment "Any callable unit (function, method, procedure, lambda)." . + +uko-code:TypeDefinition a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "TypeDefinition" ; + rdfs:comment "A type or schema definition (class, struct, interface, enum, typedef)." . + +uko-code:TestCase a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "TestCase" ; + rdfs:comment "A test case or test function." . + +uko-code:Import a owl:Class ; + rdfs:subClassOf uko:Annotation ; + rdfs:label "Import" ; + rdfs:comment "An import/include/require statement." . + +# -- Layer 1 Properties -- + +uko-code:hasReturnType a owl:DatatypeProperty ; + rdfs:domain uko-code:Callable ; + rdfs:range xsd:string ; + rdfs:label "hasReturnType" . + +uko-code:hasParameters a owl:DatatypeProperty ; + rdfs:domain uko-code:Callable ; + rdfs:range xsd:string ; + rdfs:label "hasParameters" ; + rdfs:comment "JSON-encoded parameter list." . + +uko-code:testsCallable a owl:ObjectProperty ; + rdfs:domain uko-code:TestCase ; + rdfs:range uko-code:Callable ; + rdfs:label "testsCallable" ; + rdfs:comment "Links a test case to the callable it tests." . + +# =========================================================================== +# Layer 2: Object-Oriented Paradigm (uko-oo:) +# =========================================================================== + +uko-oo:Class a owl:Class ; + rdfs:subClassOf uko-code:TypeDefinition , uko:Container ; + rdfs:label "Class" ; + rdfs:comment "An OO class that contains methods and attributes." . + +uko-oo:Interface a owl:Class ; + rdfs:subClassOf uko-code:TypeDefinition , uko:Boundary ; + rdfs:label "Interface" ; + rdfs:comment "An interface or abstract base class." . + +uko-oo:Method a owl:Class ; + rdfs:subClassOf uko-code:Callable ; + rdfs:label "Method" ; + rdfs:comment "A method defined within a class." . + +uko-oo:Attribute a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Attribute" ; + rdfs:comment "A class or instance attribute." . + +# -- Layer 2 Properties -- + +uko-oo:inheritsFrom a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-oo:Class ; + rdfs:range uko-oo:Class ; + rdfs:label "inheritsFrom" . + +uko-oo:implements a owl:ObjectProperty ; + rdfs:subPropertyOf uko:dependsOn ; + rdfs:domain uko-oo:Class ; + rdfs:range uko-oo:Interface ; + rdfs:label "implements" . + +# =========================================================================== +# Layer 3: Technology-Specific Specializations +# =========================================================================== +# Layer 3 extends Layer 2 (or Layer 1) with technology-specific refinements. +# Per the specification, Layer 3 is defined via DetailLevelMap insertions +# rather than new OWL classes. The uko-py: prefix is reserved for +# Python-specific extensions when DetailLevelMap support is added. diff --git a/docs/reference/uko.md b/docs/reference/uko.md new file mode 100644 index 000000000..4198917f7 --- /dev/null +++ b/docs/reference/uko.md @@ -0,0 +1,191 @@ +# UKO — Unified Knowledge Ontology + +This document describes the **Unified Knowledge Ontology (UKO)**, a layered +RDF/Turtle ontology used by CleverAgents to represent code artifacts, +structural relationships, and language-specific extensions. + +## Overview + +UKO organises knowledge into four layers, each building on the previous: + +| Layer | Prefix(es) | IRI Namespace | Purpose | +|-------|------------|---------------|---------| +| 0 | `uko:` | `https://cleveragents.ai/ontology/uko#` | Universal foundation — base information unit types and core relationships | +| 1 | `uko-code:`, `uko-doc:`, `uko-data:`, `uko-infra:` | `https://cleveragents.ai/ontology/uko/code#` etc. | General software — modules, callables, types, tests, imports | +| 2 | `uko-oo:`, `uko-func:`, `uko-proc:` | `https://cleveragents.ai/ontology/uko/oo#` etc. | Paradigm-specific — OO classes, interfaces, methods, attributes | +| 3 | `uko-py:`, `uko-ts:`, `uko-rs:`, `uko-java:` | `https://cleveragents.ai/ontology/uko/py#` etc. | Technology-specific — DetailLevelMap insertions (no OWL classes) | + +The ontology file lives at `docs/ontology/uko.ttl` and uses the base URI +`https://cleveragents.ai/ontology/uko#`. + +## URI Format and Prefix Conventions + +Every UKO concept is identified by a URI under the CleverAgents ontology +namespace. Layers use semantic domain prefixes rather than numeric indices: + +``` +https://cleveragents.ai/ontology/uko# # Layer 0 (foundation) +https://cleveragents.ai/ontology/uko/code# # Layer 1 (code domain) +https://cleveragents.ai/ontology/uko/oo# # Layer 2 (OO paradigm) +https://cleveragents.ai/ontology/uko/py# # Layer 3 (Python — reserved) +``` + +In Turtle files these are abbreviated using `@prefix` declarations: + +```turtle +@prefix uko: . +@prefix uko-code: . +@prefix uko-oo: . +``` + +## Layer Descriptions + +### Layer 0 — Universal Foundation (`uko:`) + +Defines the root types that all other layers extend: + +- **InformationUnit** — the root of every UKO node; anything that can appear in an actor's context. +- **Container** — an information unit that contains other units (file, module, class, section). +- **Atom** — a leaf-level information unit (function body, paragraph, config value). +- **Annotation** — metadata attached to another unit (comment, docstring, attribute). +- **Boundary** — an interface point between containers (export, API endpoint, public method signature). + +Core relationship properties: + +- **contains** — parent contains child (`rdfs:domain` Container, `rdfs:range` InformationUnit). +- **references** — weak reference (e.g. a function mentions a type in a docstring). +- **dependsOn** — strong dependency (import, inheritance, call). + +Content properties: + +- **hasRendering** — rendered text at a specific detail depth. +- **renderingDepth** — the integer detail depth of the associated rendering. +- **hasFullContent** — shorthand for the maximum-depth rendering. + +Provenance properties: + +- **sourceResource** — the CleverAgents Resource (by ULID) this node was extracted from. +- **sourcePath** — file path within the resource. +- **sourceRange** — byte or line range within the source file (e.g. `42:1-87:0`). + +Temporal properties: + +- **validFrom** — timestamp when this node version became valid. +- **validUntil** — timestamp when this node version was superseded. +- **isCurrent** — whether this is the current version. +- **isRevisionOf** — links a revised node to its predecessor. + +### Layer 1 — General Software (`uko-code:`) + +Structural code concepts that extend Layer 0 classes: + +- **Module** — a source code module (subclass of Container). +- **Callable** — any callable unit: function, method, procedure, lambda (subclass of Atom). +- **TypeDefinition** — a type or schema definition (subclass of Atom). +- **TestCase** — a test case or test function (subclass of Atom). +- **Import** — an import/include/require statement (subclass of Annotation). + +Layer 1 properties: + +- **hasReturnType** — datatype property on Callable. +- **hasParameters** — JSON-encoded parameter list on Callable. +- **testsCallable** — links a TestCase to the Callable it tests. + +### Layer 2 — Object-Oriented Paradigm (`uko-oo:`) + +OO-specific specialisations of Layer 1 classes: + +- **Class** — an OO class (subclass of TypeDefinition). +- **Interface** — an interface or abstract base class (subclass of TypeDefinition). +- **Method** — a method defined within a class (subclass of Callable). +- **Attribute** — a class or instance attribute (subclass of Atom). + +Layer 2 properties: + +- **inheritsFrom** — `rdfs:subPropertyOf uko:dependsOn`; domain/range both Class. +- **implements** — `rdfs:subPropertyOf uko:dependsOn`; domain Class, range Interface. + +Note: `uko-oo:Class` has dual `rdfs:subClassOf` parents +(`uko-code:TypeDefinition` and `uko:Container`), and `uko-oo:Interface` +has dual parents (`uko-code:TypeDefinition` and `uko:Boundary`). The +loader supports multi-parent `rdfs:subClassOf` and resolves inheritance +as a DAG using BFS. + +### Layer 3 — Technology-Specific + +Per the specification, Layer 3 is defined via DetailLevelMap insertions +rather than new OWL classes. The `uko-py:` prefix is reserved for +Python-specific extensions when DetailLevelMap support is added. + +## Versioning + +The ontology carries an `owl:versionIRI` that encodes a semver string: + +```turtle +uko: a owl:Ontology ; + owl:versionIRI . +``` + +The loader extracts the version from the last path segment of the IRI and +validates it against the `SUPPORTED_VERSIONS` registry. + +## Extension Points + +To add a custom domain or language binding: + +1. Define a new `@prefix` (e.g. `uko-go:`) in `uko.ttl`. +2. Declare classes using `rdfs:subClassOf` to connect them to the + appropriate parent in Layers 0-2. +3. Update the `_LAYER_PREFIXES` and `_LAYER_IRI_PREFIXES` maps in + `uko_loader.py` so the parser assigns the correct layer number. +4. Add the new version string to `UKOLoader.SUPPORTED_VERSIONS`. + +## Parsing Walkthrough + +```python +from pathlib import Path +from cleveragents.application.services.uko_loader import UKOLoader + +loader = UKOLoader() +ontology = loader.load(Path("docs/ontology/uko.ttl")) + +# List Layer 0 nodes +for node in loader.get_layer_nodes(ontology, layer=0): + print(node.label, node.uri) + +# Resolve inheritance for uko-oo:Class +chain = loader.resolve_inheritance( + ontology, + "https://cleveragents.ai/ontology/uko/oo#Class", +) +for node in chain: + print(f" Layer {node.layer}: {node.label}") +``` + +Output: + +``` +InformationUnit https://cleveragents.ai/ontology/uko#InformationUnit +Container https://cleveragents.ai/ontology/uko#Container +Atom https://cleveragents.ai/ontology/uko#Atom +Annotation https://cleveragents.ai/ontology/uko#Annotation +Boundary https://cleveragents.ai/ontology/uko#Boundary +contains https://cleveragents.ai/ontology/uko#contains +references https://cleveragents.ai/ontology/uko#references +dependsOn https://cleveragents.ai/ontology/uko#dependsOn +hasRendering https://cleveragents.ai/ontology/uko#hasRendering +renderingDepth https://cleveragents.ai/ontology/uko#renderingDepth +hasFullContent https://cleveragents.ai/ontology/uko#hasFullContent +sourceResource https://cleveragents.ai/ontology/uko#sourceResource +sourcePath https://cleveragents.ai/ontology/uko#sourcePath +sourceRange https://cleveragents.ai/ontology/uko#sourceRange +validFrom https://cleveragents.ai/ontology/uko#validFrom +validUntil https://cleveragents.ai/ontology/uko#validUntil +isCurrent https://cleveragents.ai/ontology/uko#isCurrent +isRevisionOf https://cleveragents.ai/ontology/uko#isRevisionOf + Layer 2: Class + Layer 1: TypeDefinition + Layer 0: Container + Layer 0: Atom + Layer 0: InformationUnit +``` diff --git a/features/steps/uko_ontology_steps.py b/features/steps/uko_ontology_steps.py new file mode 100644 index 000000000..2b8834f87 --- /dev/null +++ b/features/steps/uko_ontology_steps.py @@ -0,0 +1,440 @@ +"""Behave step implementations for UKO ontology feature.""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services.uko_loader import ( + UKOLoader, + UKOValidationError, +) +from cleveragents.domain.models.core.uko import ( + UKONode, + UKOOntology, + UKOPrefix, + UKOVersion, +) + +_TTL_FILE = Path(__file__).resolve().parents[2] / "docs" / "ontology" / "uko.ttl" + + +# --------------------------------------------------------------------------- +# Given +# --------------------------------------------------------------------------- + + +@given("a TTL string") +@given("a TTL string:") +def step_given_ttl_string(context: Context) -> None: + context.ttl_text = context.text + + +@given("the UKO ontology is loaded from the TTL file") +def step_given_uko_loaded(context: Context) -> None: + loader = UKOLoader() + context.ontology = loader.load(_TTL_FILE) + context.loader = loader + + +@given("a TTL string with a node missing rdf_type") +@given("a TTL string with a node missing rdf_type:") +def step_given_ttl_missing_rdf_type(context: Context) -> None: + context.ttl_text = context.text + + +@given("a UKO ontology with an undefined prefix node") +def step_given_undefined_prefix(context: Context) -> None: + context.ontology = UKOOntology( + version=UKOVersion( + version_iri="https://cleveragents.ai/ontology/uko/0.1.0", + version="0.1.0", + label="Test", + comment="Test", + ), + prefixes=( + UKOPrefix( + prefix="uko", + uri="https://cleveragents.ai/ontology/uko#", + ), + ), + nodes=( + UKONode( + uri="badprefix:Thing", + rdf_type="owl:Class", + label="Thing", + layer=0, + ), + ), + ) + context.loader = UKOLoader() + + +@given("a UKO ontology with a cycle in inheritance") +def step_given_cycle(context: Context) -> None: + context.ontology = UKOOntology( + version=UKOVersion( + version_iri="https://cleveragents.ai/ontology/uko/0.1.0", + version="0.1.0", + label="Test", + comment="Test", + ), + prefixes=( + UKOPrefix( + prefix="uko", + uri="https://cleveragents.ai/ontology/uko#", + ), + ), + nodes=( + UKONode( + uri="https://cleveragents.ai/ontology/uko#A", + rdf_type="owl:Class", + label="A", + parent_uris=("https://cleveragents.ai/ontology/uko#B",), + layer=0, + ), + UKONode( + uri="https://cleveragents.ai/ontology/uko#B", + rdf_type="owl:Class", + label="B", + parent_uris=("https://cleveragents.ai/ontology/uko#A",), + layer=0, + ), + ), + ) + context.loader = UKOLoader() + + +@given("a UKO ontology with a node referencing a non-existent parent") +def step_given_nonexistent_parent(context: Context) -> None: + context.ontology = UKOOntology( + version=UKOVersion( + version_iri="https://cleveragents.ai/ontology/uko/0.1.0", + version="0.1.0", + label="Test", + comment="Test", + ), + prefixes=( + UKOPrefix( + prefix="uko", + uri="https://cleveragents.ai/ontology/uko#", + ), + ), + nodes=( + UKONode( + uri="https://cleveragents.ai/ontology/uko#Orphan", + rdf_type="owl:Class", + label="Orphan", + parent_uris=("https://cleveragents.ai/ontology/uko#DoesNotExist",), + layer=0, + ), + ), + ) + context.loader = UKOLoader() + + +# --------------------------------------------------------------------------- +# When +# --------------------------------------------------------------------------- + + +@when("I parse the TTL string") +def step_when_parse_ttl(context: Context) -> None: + loader = UKOLoader() + context.ontology = loader.load_from_string(context.ttl_text) + context.loader = loader + + +@when("I get nodes at layer {layer:d}") +def step_when_get_layer(context: Context, layer: int) -> None: + loader: UKOLoader = getattr(context, "loader", UKOLoader()) + context.layer_nodes = loader.get_layer_nodes(context.ontology, layer) + + +@when("I validate the ontology") +def step_when_validate(context: Context) -> None: + loader: UKOLoader = getattr(context, "loader", UKOLoader()) + context.validation_errors = loader.validate(context.ontology) + + +@when('I resolve inheritance for "{uri}"') +def step_when_resolve_inheritance(context: Context, uri: str) -> None: + loader: UKOLoader = getattr(context, "loader", UKOLoader()) + context.inheritance_chain = loader.resolve_inheritance(context.ontology, uri) + + +@when("I parse the TTL string expecting no nodes") +def step_when_parse_expecting_no_nodes(context: Context) -> None: + loader = UKOLoader() + # This TTL has a node without rdf:type — the parser skips it + context.ontology = loader.load_from_string(context.ttl_text) + context.loader = loader + + +@when("I inject a node without rdf_type into the ontology") +def step_inject_typeless_node(context: Context) -> None: + typeless = UKONode( + uri="https://cleveragents.ai/ontology/uko#Broken", + rdf_type="", + label="Broken", + ) + existing = list(context.ontology.nodes) + existing.append(typeless) + context.ontology = context.ontology.model_copy( + update={"nodes": tuple(existing)}, + ) + + +@when("I validate the modified ontology") +def step_validate_modified(context: Context) -> None: + loader = UKOLoader() + context.validation_errors = loader.validate(context.ontology) + + +@when("I attempt to resolve inheritance for the cyclic node") +def step_when_resolve_cycle(context: Context) -> None: + loader: UKOLoader = context.loader + try: + loader.resolve_inheritance( + context.ontology, + "https://cleveragents.ai/ontology/uko#A", + ) + context.cycle_error = None + except UKOValidationError as exc: + context.cycle_error = str(exc) + + +@when("I attempt to parse the TTL string expecting a validation error") +def step_when_parse_expecting_error(context: Context) -> None: + loader = UKOLoader() + try: + loader.load_from_string(context.ttl_text) + context.validation_error_msg = None + except UKOValidationError as exc: + context.validation_error_msg = str(exc) + + +# --------------------------------------------------------------------------- +# Then +# --------------------------------------------------------------------------- + + +@then('the prefix "{name}" should map to "{uri}"') +def step_then_prefix_maps(context: Context, name: str, uri: str) -> None: + prefix_map = {p.prefix: p.uri for p in context.ontology.prefixes} + assert name in prefix_map, f"Prefix '{name}' not found; have {list(prefix_map)}" + assert prefix_map[name] == uri, f"Expected {uri}, got {prefix_map[name]}" + + +@then('I should find a node with label "{label}"') +def step_then_find_node(context: Context, label: str) -> None: + labels = [n.label for n in context.layer_nodes] + assert label in labels, f"Label '{label}' not found in {labels}" + + +@then('the ontology version should be "{version}"') +def step_then_version(context: Context, version: str) -> None: + assert context.ontology.version.version == version + + +@then('the ontology label should be "{label}"') +def step_then_label(context: Context, label: str) -> None: + assert context.ontology.version.label == label + + +@then('the ontology comment should be "{comment}"') +def step_then_comment(context: Context, comment: str) -> None: + assert context.ontology.version.comment == comment + + +@then('the version IRI should be "{iri}"') +def step_then_version_iri(context: Context, iri: str) -> None: + assert context.ontology.version.version_iri == iri + + +@then("no nodes should be present in the ontology") +def step_then_no_nodes(context: Context) -> None: + # The "Broken" node has no rdf:type, so the parser skips it + broken_nodes = [n for n in context.ontology.nodes if n.label == "Broken"] + assert len(broken_nodes) == 0, f"Expected no 'Broken' nodes, found {broken_nodes}" + + +@then('the validation errors should mention "{text}"') +def step_then_validation_mentions(context: Context, text: str) -> None: + combined = " ".join(context.validation_errors) + assert text in combined, f"Expected '{text}' in errors: {context.validation_errors}" + + +@then("there should be no validation errors") +def step_then_no_errors(context: Context) -> None: + assert context.validation_errors == [], ( + f"Expected no errors, got: {context.validation_errors}" + ) + + +@then("the inheritance chain should have at least {count:d} nodes") +def step_then_chain_length(context: Context, count: int) -> None: + assert len(context.inheritance_chain) >= count, ( + f"Expected >= {count} nodes, got {len(context.inheritance_chain)}" + ) + + +@then('the first node in the chain should have label "{label}"') +def step_then_first_chain_label(context: Context, label: str) -> None: + assert context.inheritance_chain[0].label == label, ( + f"Expected '{label}', got '{context.inheritance_chain[0].label}'" + ) + + +@then('the last node in the chain should have label "{label}"') +def step_then_last_chain_label(context: Context, label: str) -> None: + assert context.inheritance_chain[-1].label == label, ( + f"Expected '{label}', got '{context.inheritance_chain[-1].label}'" + ) + + +@then("there should be at least {count:d} nodes") +def step_then_min_nodes(context: Context, count: int) -> None: + assert len(context.layer_nodes) >= count, ( + f"Expected >= {count}, got {len(context.layer_nodes)}" + ) + + +@then("the ontology should have prefixes defined") +def step_then_has_prefixes(context: Context) -> None: + assert len(context.ontology.prefixes) > 0 + + +@then("the ontology should have nodes defined") +def step_then_has_nodes(context: Context) -> None: + assert len(context.ontology.nodes) > 0 + + +@then('"{version}" should be in the supported versions') +def step_then_supported_version(context: Context, version: str) -> None: + assert version in context.ontology.supported_versions + + +@then("the node count should be {count:d}") +def step_node_count(context: Context, count: int) -> None: + assert len(context.layer_nodes) == count, ( + f"Expected {count} nodes but got {len(context.layer_nodes)}" + ) + + +@then('validation errors should include "{text}"') +def step_validation_errors_include(context: Context, text: str) -> None: + assert any(text in e for e in context.validation_errors), ( + f"Expected '{text}' in validation errors: {context.validation_errors}" + ) + + +@then('the node with label "{label}" should have parent "{parent_uri}"') +def step_then_node_has_parent( + context: Context, + label: str, + parent_uri: str, +) -> None: + matching = [n for n in context.layer_nodes if n.label == label] + assert matching, f"No node with label '{label}' found in layer nodes" + node = matching[0] + assert parent_uri in node.parent_uris, ( + f"Expected parent '{parent_uri}' for node '{label}'; got: {node.parent_uris}" + ) + + +@then( + 'the node with label "{label}" should have property "{prop}" resolved to a full URI' +) +def step_then_property_resolved(context: Context, label: str, prop: str) -> None: + matching = [n for n in context.layer_nodes if n.label == label] + assert matching, f"No node with label '{label}' found in layer nodes" + node = matching[0] + prop_dict = dict(node.properties) + assert prop in prop_dict, ( + f"Property '{prop}' not found on node '{label}'; have: {list(prop_dict)}" + ) + value = prop_dict[prop] + assert value.startswith("http://") or value.startswith("https://"), ( + f"Expected full URI for '{prop}' on '{label}', got: '{value}'" + ) + + +@then( + 'the node with label "{label}" should have property "{prop}" equal to "{expected}"' +) +def step_then_property_equal( + context: Context, + label: str, + prop: str, + expected: str, +) -> None: + matching = [n for n in context.layer_nodes if n.label == label] + assert matching, f"No node with label '{label}' found in layer nodes" + node = matching[0] + prop_dict = dict(node.properties) + assert prop in prop_dict, ( + f"Property '{prop}' not found on node '{label}'; have: {list(prop_dict)}" + ) + assert prop_dict[prop] == expected, ( + f"Expected '{prop}' == '{expected}' on '{label}', got: '{prop_dict[prop]}'" + ) + + +@then('the node with label "{label}" should not have property "{prop}"') +def step_then_property_absent(context: Context, label: str, prop: str) -> None: + matching = [n for n in context.layer_nodes if n.label == label] + assert matching, f"No node with label '{label}' found in layer nodes" + node = matching[0] + prop_dict = dict(node.properties) + assert prop not in prop_dict, ( + f"Expected no '{prop}' on '{label}', but found: '{prop_dict[prop]}'" + ) + + +@then("a cycle detection error should be raised") +def step_then_cycle_error(context: Context) -> None: + assert context.cycle_error is not None, "Expected cycle error but none raised" + assert "Cycle" in context.cycle_error or "cycle" in context.cycle_error + + +@then('a validation error should mention "{text}"') +def step_then_validation_error_mention(context: Context, text: str) -> None: + assert context.validation_error_msg is not None, ( + "Expected a UKOValidationError but none was raised" + ) + assert text in context.validation_error_msg, ( + f"Expected '{text}' in error message: {context.validation_error_msg}" + ) + + +# --------------------------------------------------------------------------- +# Duplicate-key handling in properties tuple +# --------------------------------------------------------------------------- + + +@given("a UKO node with duplicate property keys") +def step_given_duplicate_props(context: Context) -> None: + context.dup_node = UKONode( + uri="https://cleveragents.ai/ontology/uko#TestDup", + rdf_type="owl:DatatypeProperty", + label="TestDup", + properties=( + ("rdfs:domain", "https://cleveragents.ai/ontology/uko#First"), + ("rdfs:domain", "https://cleveragents.ai/ontology/uko#Second"), + ), + ) + + +@when("I convert the node properties to a dict") +def step_when_convert_props_dict(context: Context) -> None: + context.prop_dict = dict(context.dup_node.properties) + + +@then("the dict should contain only the last value for the duplicated key") +def step_then_last_value(context: Context) -> None: + assert context.prop_dict["rdfs:domain"] == ( + "https://cleveragents.ai/ontology/uko#Second" + ), f"Expected last value 'Second', got: {context.prop_dict['rdfs:domain']}" diff --git a/features/uko_ontology.feature b/features/uko_ontology.feature new file mode 100644 index 000000000..353e37b13 --- /dev/null +++ b/features/uko_ontology.feature @@ -0,0 +1,352 @@ +@phase2 @uko @ontology +Feature: UKO Ontology Loader + As a CleverAgents developer + I want to parse and validate the UKO ontology from TTL files + So that the system can reason about code artifacts and their relationships + + # --------------------------------------------------------------------------- + # Prefix parsing + # --------------------------------------------------------------------------- + + @uko_prefix + Scenario: Parse prefix line correctly + Given a TTL string: + """ + @prefix uko: . + @prefix uko-code: . + @prefix rdf: . + + uko: a owl:Ontology ; + owl:versionIRI ; + rdfs:label "Test" ; + rdfs:comment "Test ontology" . + """ + When I parse the TTL string + Then the prefix "uko" should map to "https://cleveragents.ai/ontology/uko#" + And the prefix "uko-code" should map to "https://cleveragents.ai/ontology/uko/code#" + And the prefix "rdf" should map to "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + + # --------------------------------------------------------------------------- + # Layer 0 — Universal Foundation + # --------------------------------------------------------------------------- + + @uko_layer0 + Scenario: Parse Layer 0 nodes from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then I should find a node with label "InformationUnit" + And I should find a node with label "Container" + And I should find a node with label "Atom" + And I should find a node with label "Annotation" + And I should find a node with label "Boundary" + + # --------------------------------------------------------------------------- + # Layer 1 — General Software + # --------------------------------------------------------------------------- + + @uko_layer1 + Scenario: Parse Layer 1 nodes from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 1 + Then I should find a node with label "Module" + And I should find a node with label "Callable" + And I should find a node with label "TypeDefinition" + And I should find a node with label "TestCase" + And I should find a node with label "Import" + + # --------------------------------------------------------------------------- + # Layer 2 — Object-Oriented Paradigm + # --------------------------------------------------------------------------- + + @uko_layer2 + Scenario: Parse Layer 2 nodes from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 2 + Then I should find a node with label "Class" + And I should find a node with label "Interface" + And I should find a node with label "Method" + And I should find a node with label "Attribute" + + # --------------------------------------------------------------------------- + # Layer 0 properties (relationships) + # --------------------------------------------------------------------------- + + @uko_layer0_props + Scenario: Parse Layer 0 relationship properties from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then I should find a node with label "contains" + And I should find a node with label "references" + And I should find a node with label "dependsOn" + + # --------------------------------------------------------------------------- + # Version metadata + # --------------------------------------------------------------------------- + + @uko_version + Scenario: Version metadata extracted correctly + Given the UKO ontology is loaded from the TTL file + Then the ontology version should be "0.1.0" + And the ontology label should be "Unified Knowledge Ontology" + And the ontology comment should be "CleverAgents knowledge representation layers 0-3" + And the version IRI should be "https://cleveragents.ai/ontology/uko/0.1.0" + + # --------------------------------------------------------------------------- + # Validation errors + # --------------------------------------------------------------------------- + + @uko_validation @error_handling + Scenario: Parser skips nodes without rdf_type + Given a TTL string with a node missing rdf_type: + """ + @prefix uko: . + + uko: a owl:Ontology ; + owl:versionIRI ; + rdfs:label "Test" ; + rdfs:comment "Test" . + + uko:Broken rdfs:label "Broken" . + """ + When I parse the TTL string expecting no nodes + Then no nodes should be present in the ontology + + @uko_validation @error_handling + Scenario: Validate rejects ontology with typeless node + Given the UKO ontology is loaded from the TTL file + When I inject a node without rdf_type into the ontology + And I validate the modified ontology + Then validation errors should include "missing rdf:type" + + @uko_validation @error_handling + Scenario: Validate rejects undefined prefix + Given a UKO ontology with an undefined prefix node + When I validate the ontology + Then the validation errors should mention "undefined prefix" + + @uko_validation + Scenario: Validate accepts valid ontology + Given the UKO ontology is loaded from the TTL file + When I validate the ontology + Then there should be no validation errors + + @uko_validation @error_handling + Scenario: Validate rejects non-existent parent URI + Given a UKO ontology with a node referencing a non-existent parent + When I validate the ontology + Then the validation errors should mention "non-existent parent" + + # --------------------------------------------------------------------------- + # Inheritance resolution + # --------------------------------------------------------------------------- + + @uko_inheritance + Scenario: Resolve inheritance DAG for uko-oo:Class (multi-parent) + Given the UKO ontology is loaded from the TTL file + When I resolve inheritance for "https://cleveragents.ai/ontology/uko/oo#Class" + Then the inheritance chain should have at least 5 nodes + And the first node in the chain should have label "Class" + And the last node in the chain should have label "InformationUnit" + + @uko_inheritance + Scenario: Resolve inheritance chain for Container (Layer 0) + Given the UKO ontology is loaded from the TTL file + When I resolve inheritance for "https://cleveragents.ai/ontology/uko#Container" + Then the inheritance chain should have at least 2 nodes + And the first node in the chain should have label "Container" + + @uko_inheritance @multi_parent + Scenario: uko-oo:Class has dual rdfs:subClassOf parents + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 2 + Then the node with label "Class" should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" + And the node with label "Class" should have parent "https://cleveragents.ai/ontology/uko#Container" + + @uko_inheritance @multi_parent + Scenario: uko-oo:Interface has dual rdfs:subClassOf parents + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 2 + Then the node with label "Interface" should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" + And the node with label "Interface" should have parent "https://cleveragents.ai/ontology/uko#Boundary" + + # --------------------------------------------------------------------------- + # Layer query + # --------------------------------------------------------------------------- + + @uko_layer_query + Scenario: Get all nodes at specific layer + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then there should be at least 18 nodes + + @uko_layer + Scenario Outline: Get correct node count for layer + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer + Then the node count should be + + Examples: + | layer | count | + | 0 | 18 | + | 1 | 8 | + | 2 | 6 | + + # --------------------------------------------------------------------------- + # rdfs:domain and rdfs:range URI resolution + # --------------------------------------------------------------------------- + + @uko_domain_range + Scenario: rdfs:domain values are resolved to full URIs + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then the node with label "contains" should have property "rdfs:domain" resolved to a full URI + And the node with label "contains" should have property "rdfs:range" resolved to a full URI + + @uko_domain_range + Scenario: rdfs:subPropertyOf values are resolved to full URIs + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 2 + Then the node with label "inheritsFrom" should have property "rdfs:subPropertyOf" resolved to a full URI + + # --------------------------------------------------------------------------- + # Integration: load actual file + # --------------------------------------------------------------------------- + + @uko_integration + Scenario: Load the actual uko.ttl file successfully + Given the UKO ontology is loaded from the TTL file + Then the ontology should have prefixes defined + And the ontology should have nodes defined + + # --------------------------------------------------------------------------- + # Version registry + # --------------------------------------------------------------------------- + + @uko_version + Scenario: Version registry contains 0.1.0 + Given the UKO ontology is loaded from the TTL file + Then "0.1.0" should be in the supported versions + + # --------------------------------------------------------------------------- + # Layer 0 content, provenance, and temporal properties + # --------------------------------------------------------------------------- + + @uko_layer0_props + Scenario: Parse Layer 0 content properties from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then I should find a node with label "hasRendering" + And I should find a node with label "renderingDepth" + And I should find a node with label "hasFullContent" + + @uko_layer0_props + Scenario: Parse Layer 0 provenance properties from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then I should find a node with label "sourceResource" + And I should find a node with label "sourcePath" + And I should find a node with label "sourceRange" + + @uko_layer0_props + Scenario: Parse Layer 0 temporal properties from TTL + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then I should find a node with label "validFrom" + And I should find a node with label "validUntil" + And I should find a node with label "isCurrent" + And I should find a node with label "isRevisionOf" + + # --------------------------------------------------------------------------- + # rdfs:domain / rdfs:range correctness for content, provenance, temporal props + # --------------------------------------------------------------------------- + + @uko_domain_range + Scenario: Content properties have correct rdfs:domain and rdfs:range + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then the node with label "hasRendering" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "hasRendering" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#string" + And the node with label "renderingDepth" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "renderingDepth" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#nonNegativeInteger" + And the node with label "hasFullContent" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "hasFullContent" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#string" + + @uko_domain_range + Scenario: Provenance properties have correct rdfs:domain and rdfs:range + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then the node with label "sourceResource" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "sourceResource" should not have property "rdfs:range" + And the node with label "sourcePath" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "sourcePath" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#string" + And the node with label "sourceRange" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "sourceRange" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#string" + + @uko_domain_range + Scenario: Temporal properties have correct rdfs:domain and rdfs:range + Given the UKO ontology is loaded from the TTL file + When I get nodes at layer 0 + Then the node with label "validFrom" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "validFrom" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#dateTime" + And the node with label "validUntil" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "validUntil" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#dateTime" + And the node with label "isCurrent" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "isCurrent" should have property "rdfs:range" equal to "http://www.w3.org/2001/XMLSchema#boolean" + And the node with label "isRevisionOf" should have property "rdfs:domain" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + And the node with label "isRevisionOf" should have property "rdfs:range" equal to "https://cleveragents.ai/ontology/uko#InformationUnit" + + # --------------------------------------------------------------------------- + # Unsupported version rejection + # --------------------------------------------------------------------------- + + @uko_validation @error_handling + Scenario: Loader rejects unsupported ontology version + Given a TTL string: + """ + @prefix uko: . + @prefix owl: . + @prefix rdfs: . + + uko: a owl:Ontology ; + owl:versionIRI ; + rdfs:label "Test" ; + rdfs:comment "Test" . + """ + When I attempt to parse the TTL string expecting a validation error + Then a validation error should mention "Unsupported version" + + @uko_validation @error_handling + Scenario: Loader rejects version IRI with non-semver last segment + Given a TTL string: + """ + @prefix uko: . + @prefix owl: . + @prefix rdfs: . + + uko: a owl:Ontology ; + owl:versionIRI ; + rdfs:label "Test" ; + rdfs:comment "Test" . + """ + When I attempt to parse the TTL string expecting a validation error + Then a validation error should mention "Unsupported version" + + # --------------------------------------------------------------------------- + # Duplicate-key handling in properties tuple + # --------------------------------------------------------------------------- + + @uko_properties + Scenario: Duplicate keys in properties tuple preserve last value when cast to dict + Given a UKO node with duplicate property keys + When I convert the node properties to a dict + Then the dict should contain only the last value for the duplicated key + + # --------------------------------------------------------------------------- + # Cycle detection + # --------------------------------------------------------------------------- + + @uko_inheritance @error_handling + Scenario: Cycle detection in inheritance + Given a UKO ontology with a cycle in inheritance + When I attempt to resolve inheritance for the cyclic node + Then a cycle detection error should be raised diff --git a/robot/helper_uko_ontology.py b/robot/helper_uko_ontology.py new file mode 100644 index 000000000..6ba4d18c2 --- /dev/null +++ b/robot/helper_uko_ontology.py @@ -0,0 +1,132 @@ +"""Robot Framework helper for UKO ontology integration tests. + +Subcommands: + load-ttl -- load docs/ontology/uko.ttl, print ``uko-load-ok`` + validate -- load and validate, print ``uko-validate-ok`` + resolve-inheritance -- resolve uko-oo:Class chain, print ``uko-inheritance-ok`` + layer-nodes -- get layer 0 nodes, print ``uko-layer-nodes-ok`` +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.application.services.uko_loader import UKOLoader # noqa: E402 + +_TTL_PATH = Path(__file__).resolve().parents[1] / "docs" / "ontology" / "uko.ttl" + + +def _cmd_load_ttl() -> int: + """Load the TTL file and verify basic parsing.""" + try: + loader = UKOLoader() + ontology = loader.load(_TTL_PATH) + except Exception as exc: + print(f"uko-fail: {exc}") + return 1 + + if not ontology.nodes: + print("uko-fail: no nodes parsed") + return 1 + + print( + f"uko-load-ok: {len(ontology.nodes)} nodes, {len(ontology.prefixes)} prefixes" + ) + return 0 + + +def _cmd_validate() -> int: + """Load and validate the ontology.""" + try: + loader = UKOLoader() + ontology = loader.load(_TTL_PATH) + errors = loader.validate(ontology) + except Exception as exc: + print(f"uko-fail: {exc}") + return 1 + + if errors: + print(f"uko-fail: validation errors: {errors}") + return 1 + + print(f"uko-validate-ok: version={ontology.version.version}") + return 0 + + +def _cmd_resolve_inheritance() -> int: + """Resolve uko-oo:Class inheritance chain (Layer 2 -> 1 -> 0).""" + try: + loader = UKOLoader() + ontology = loader.load(_TTL_PATH) + chain = loader.resolve_inheritance( + ontology, + "https://cleveragents.ai/ontology/uko/oo#Class", + ) + except Exception as exc: + print(f"uko-fail: {exc}") + return 1 + + if len(chain) < 3: + print(f"uko-fail: chain too short ({len(chain)} nodes)") + return 1 + + print(f"uko-inheritance-ok: {len(chain)} nodes") + for node in chain: + print(f" layer={node.layer} label={node.label}") + return 0 + + +def _cmd_layer_nodes() -> int: + """Get all Layer 0 nodes.""" + try: + loader = UKOLoader() + ontology = loader.load(_TTL_PATH) + nodes = loader.get_layer_nodes(ontology, 0) + except Exception as exc: + print(f"uko-fail: {exc}") + return 1 + + if len(nodes) < 18: + print(f"uko-fail: expected >= 18 layer-0 nodes, got {len(nodes)}") + return 1 + + print(f"uko-layer-nodes-ok: {len(nodes)} nodes") + for node in nodes: + print(f" {node.label} ({node.uri})") + return 0 + + +_COMMANDS: dict[str, Callable[[], int]] = { + "load-ttl": _cmd_load_ttl, + "validate": _cmd_validate, + "resolve-inheritance": _cmd_resolve_inheritance, + "layer-nodes": _cmd_layer_nodes, +} + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print( + "Usage: helper_uko_ontology.py " + "" + ) + return 1 + + command = sys.argv[1] + handler = _COMMANDS.get(command) + if handler is None: + print(f"Unknown command: {command}") + return 1 + + return handler() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/uko_ontology.robot b/robot/uko_ontology.robot new file mode 100644 index 000000000..d8f3355ef --- /dev/null +++ b/robot/uko_ontology.robot @@ -0,0 +1,41 @@ +*** Settings *** +Documentation Integration smoke tests for UKO ontology loader +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_uko_ontology.py + +*** Test Cases *** +Load UKO Ontology From TTL File + [Documentation] Load docs/ontology/uko.ttl and verify parsing succeeds + ${result}= Run Process ${PYTHON} ${HELPER} load-ttl cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-load-ok + +Validate UKO Ontology + [Documentation] Load and validate the ontology with no errors + ${result}= Run Process ${PYTHON} ${HELPER} validate cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-validate-ok + +Resolve Inheritance Chain + [Documentation] Resolve uko-oo:Class inheritance chain through layers + ${result}= Run Process ${PYTHON} ${HELPER} resolve-inheritance cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-inheritance-ok + +Get Layer Nodes + [Documentation] Retrieve all Layer 0 foundation nodes + ${result}= Run Process ${PYTHON} ${HELPER} layer-nodes cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} uko-layer-nodes-ok diff --git a/src/cleveragents/application/services/__init__.py b/src/cleveragents/application/services/__init__.py index 156ef37b7..00f293def 100644 --- a/src/cleveragents/application/services/__init__.py +++ b/src/cleveragents/application/services/__init__.py @@ -125,6 +125,10 @@ from cleveragents.application.services.tool_registry_service import ( from cleveragents.application.services.trace_service import ( TraceService, ) +from cleveragents.application.services.uko_loader import ( + UKOLoader, + UKOValidationError, +) from cleveragents.application.services.validation_apply import ( ApplyValidationGate, ApplyValidationResult, @@ -213,6 +217,8 @@ __all__ = [ "SyntaxCheckRule", "ToolRegistryService", "TraceService", + "UKOLoader", + "UKOValidationError", "ValidationAttachment", "ValidationCommand", "ValidationPipeline", diff --git a/src/cleveragents/application/services/uko_loader.py b/src/cleveragents/application/services/uko_loader.py new file mode 100644 index 000000000..dd9cdc478 --- /dev/null +++ b/src/cleveragents/application/services/uko_loader.py @@ -0,0 +1,536 @@ +"""UKO ontology loader — lightweight TTL parser with no external RDF deps. + +Parses ``@prefix``, node declarations (``subject a Type ;``), +``rdfs:subClassOf``, ``rdfs:label``, ``rdfs:comment``, ``rdfs:domain``, +``rdfs:range``, and ``owl:versionIRI`` from Turtle files into +:class:`UKOOntology` Pydantic models. +""" + +from __future__ import annotations + +import re +from collections import deque +from pathlib import Path +from typing import ClassVar + +from cleveragents.domain.models.core.uko import ( + UKONode, + UKOOntology, + UKOPrefix, + UKOVersion, +) + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class UKOValidationError(Exception): + """Raised when ontology validation fails.""" + + +# --------------------------------------------------------------------------- +# Regex helpers +# --------------------------------------------------------------------------- + +_PREFIX_RE = re.compile( + r"^@prefix\s+([\w-]*)\s*:\s*<([^>]+)>\s*\.\s*$", +) +_IRI_RE = re.compile(r"<([^>]+)>") +_STRING_RE = re.compile(r'"([^"]*)"') + +# Layer prefix patterns — maps prefixed-name prefixes to layer numbers. +# Uses the spec-defined semantic domain prefixes (not numeric indices). +_LAYER_PREFIXES: dict[str, int] = { + "uko:": 0, + "uko-code:": 1, + "uko-doc:": 1, + "uko-data:": 1, + "uko-infra:": 1, + "uko-oo:": 2, + "uko-func:": 2, + "uko-proc:": 2, + "uko-py:": 3, + "uko-ts:": 3, + "uko-rs:": 3, + "uko-java:": 3, +} + +# Full-URI layer detection — maps IRI namespace prefixes to layers. +_LAYER_IRI_PREFIXES: dict[str, int] = { + "https://cleveragents.ai/ontology/uko#": 0, + "https://cleveragents.ai/ontology/uko/code#": 1, + "https://cleveragents.ai/ontology/uko/doc#": 1, + "https://cleveragents.ai/ontology/uko/data#": 1, + "https://cleveragents.ai/ontology/uko/infra#": 1, + "https://cleveragents.ai/ontology/uko/oo#": 2, + "https://cleveragents.ai/ontology/uko/func#": 2, + "https://cleveragents.ai/ontology/uko/proc#": 2, + "https://cleveragents.ai/ontology/uko/py#": 3, + "https://cleveragents.ai/ontology/uko/ts#": 3, + "https://cleveragents.ai/ontology/uko/rs#": 3, + "https://cleveragents.ai/ontology/uko/java#": 3, +} + + +def _detect_layer(uri: str) -> int: + """Determine which layer (0-3) a URI belongs to based on prefix. + + Checks both prefixed names (``uko-code:Module``) and full IRIs + (``https://cleveragents.ai/ontology/uko/code#Module``). + """ + # Check prefixed names first (more common during parsing) + for prefix, layer in _LAYER_PREFIXES.items(): + if uri.startswith(prefix): + return layer + # Check full IRIs + for iri_prefix, layer in _LAYER_IRI_PREFIXES.items(): + if uri.startswith(iri_prefix): + return layer + return 0 + + +def _extract_string(value: str) -> str: + """Extract a quoted string value from a TTL predicate object.""" + match = _STRING_RE.search(value) + return match.group(1) if match else value.strip().strip('"') + + +def _extract_iri(value: str) -> str: + """Extract an IRI (<...>) from a TTL predicate object.""" + match = _IRI_RE.search(value) + return match.group(1) if match else value.strip() + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + + +class UKOLoader: + """Load and validate UKO ontology from TTL files.""" + + SUPPORTED_VERSIONS: ClassVar[tuple[str, ...]] = ("0.1.0",) + + def load(self, ttl_path: Path) -> UKOOntology: + """Parse a TTL file into a :class:`UKOOntology` model.""" + text = ttl_path.read_text(encoding="utf-8") + return self.load_from_string(text) + + def load_from_string(self, text: str) -> UKOOntology: + """Parse a TTL string into a :class:`UKOOntology` model.""" + prefixes = self._parse_prefixes(text) + prefix_map = {p.prefix: p.uri for p in prefixes} + blocks = self._split_blocks(text) + nodes: list[UKONode] = [] + version_iri = "" + version_label = "" + version_comment = "" + + for subject, predicates in blocks: + props = self._parse_predicates(predicates) + + # Ontology declaration (subject is bare prefix like "uko:") + if ( + props.get("a") == "owl:Ontology" + or props.get("rdf:type") == "owl:Ontology" + ): + version_iri = props.get("owl:versionIRI", "") + version_label = _extract_string(props.get("rdfs:label", "")) + version_comment = _extract_string(props.get("rdfs:comment", "")) + continue + + rdf_type = props.get("a", props.get("rdf:type", "")) + if not rdf_type: + continue + + raw_parents = props.get("rdfs:subClassOf") + parent_uris: tuple[str, ...] = () + if raw_parents: + # Handle comma-separated multi-parent values + # e.g. "uko-code:TypeDefinition , uko:Container" + parent_uris = tuple( + self._resolve_uri(p.strip(), prefix_map) + for p in raw_parents.split(",") + if p.strip() + ) + label = _extract_string(props.get("rdfs:label", "")) + comment = _extract_string(props.get("rdfs:comment", "")) + + extra: dict[str, str] = {} + if "rdfs:domain" in props: + extra["rdfs:domain"] = props["rdfs:domain"] + if "rdfs:range" in props: + extra["rdfs:range"] = props["rdfs:range"] + if "rdfs:subPropertyOf" in props: + extra["rdfs:subPropertyOf"] = props["rdfs:subPropertyOf"] + + # Resolve any prefixed property values to full URIs + _resolvable_props = ("rdfs:domain", "rdfs:range", "rdfs:subPropertyOf") + resolved_props: list[tuple[str, str]] = [] + for prop_key, prop_val in extra.items(): + if ( + prop_key in _resolvable_props + and ":" in prop_val + and not prop_val.startswith("http") + ): + resolved_props.append( + (prop_key, self._resolve_uri(prop_val, prefix_map)), + ) + else: + resolved_props.append((prop_key, prop_val)) + + uri = self._resolve_uri(subject, prefix_map) + layer = _detect_layer(subject) + + nodes.append( + UKONode( + uri=uri, + rdf_type=rdf_type, + label=label, + comment=comment, + parent_uris=parent_uris, + layer=layer, + properties=tuple(resolved_props), + ), + ) + + version_str = self._extract_version_string(version_iri) + + ontology = UKOOntology( + version=UKOVersion( + version_iri=version_iri, + version=version_str, + label=version_label, + comment=version_comment, + ), + prefixes=tuple(prefixes), + nodes=tuple(nodes), + ) + + errors = self.validate(ontology) + if errors: + raise UKOValidationError("; ".join(errors)) + + return ontology + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def validate(self, ontology: UKOOntology) -> list[str]: + """Validate ontology and return list of errors (empty = valid).""" + errors: list[str] = [] + known_prefixes = {p.prefix for p in ontology.prefixes} + node_uris = {n.uri for n in ontology.nodes} + + _uko_ns = "https://cleveragents.ai/ontology/uko" + + for node in ontology.nodes: + if not node.rdf_type: + errors.append(f"Node '{node.uri}' missing rdf:type") + + for resolved_parent in node.parent_uris: + if resolved_parent.startswith("http://") or resolved_parent.startswith( + "https://" + ): + # Internal UKO URIs must resolve to a known node. + # Guard with delimiter to avoid false matches on URIs + # that merely share the prefix string (e.g. "ukobogus"). + is_internal = resolved_parent.startswith( + _uko_ns + "#" + ) or resolved_parent.startswith(_uko_ns + "/") + if is_internal and resolved_parent not in node_uris: + errors.append( + f"Node '{node.uri}' references " + f"non-existent parent '{resolved_parent}'" + ) + # Truly external URIs (w3.org, etc.) are valid + elif ":" in resolved_parent: + prefix_part = resolved_parent.split(":")[0] + if prefix_part not in known_prefixes: + errors.append( + f"Node '{node.uri}' references undefined " + f"prefix '{prefix_part}:' in parent_uri" + ) + elif resolved_parent not in node_uris: + errors.append( + f"Node '{node.uri}' references " + f"non-existent parent '{resolved_parent}'" + ) + + # Check for undefined prefixes in URI + if ":" in node.uri and not node.uri.startswith("http"): + prefix_part = node.uri.split(":")[0] + if prefix_part not in known_prefixes: + errors.append( + f"Node '{node.uri}' uses undefined prefix '{prefix_part}:'" + ) + + if ontology.version.version not in self.SUPPORTED_VERSIONS: + errors.append( + f"Unsupported version '{ontology.version.version}'; " + f"supported: {', '.join(self.SUPPORTED_VERSIONS)}" + ) + + return errors + + # ------------------------------------------------------------------ + # Query helpers + # ------------------------------------------------------------------ + + def resolve_inheritance( + self, + ontology: UKOOntology, + node_uri: str, + ) -> list[UKONode]: + """Resolve the inheritance DAG from *node_uri* up to root(s). + + Uses BFS (``collections.deque``) to traverse all + ``rdfs:subClassOf`` parents, producing a list ordered leaf-first. + Diamond inheritance is handled correctly — each ancestor appears + at most once. Raises :class:`UKOValidationError` on cycles. + """ + uri_map: dict[str, UKONode] = {n.uri: n for n in ontology.nodes} + + # Phase 1: cycle detection via DFS with gray/black colouring + self._detect_cycle_dfs(uri_map, node_uri) + + # Phase 2: BFS traversal for ordered ancestor list + chain: list[UKONode] = [] + seen: set[str] = set() + queue: deque[str] = deque() + queue.append(node_uri) + seen.add(node_uri) + + while queue: + current_uri = queue.popleft() + node = uri_map.get(current_uri) + if node is None: + continue + chain.append(node) + for parent in node.parent_uris: + if parent not in seen: + seen.add(parent) + queue.append(parent) + + return chain + + def get_layer_nodes( + self, + ontology: UKOOntology, + layer: int, + ) -> list[UKONode]: + """Return all nodes at a specific layer. + + .. note:: + This is O(n) per call. For projects with 10,000+ indexed files + (milestone v3.4.0), consider precomputing a ``dict[int, + list[UKONode]]`` index during load. + """ + # TODO(perf): precompute layer index when scaling demands it. + return [n for n in ontology.nodes if n.layer == layer] + + # ------------------------------------------------------------------ + # Cycle detection + # ------------------------------------------------------------------ + + @staticmethod + def _detect_cycle_dfs( + uri_map: dict[str, UKONode], + start_uri: str, + ) -> None: + """Raise :class:`UKOValidationError` if a cycle exists in the DAG. + + Uses iterative DFS with gray/black colouring to detect back-edges + in the ``rdfs:subClassOf`` hierarchy reachable from *start_uri*. + """ + # 0 = white (unvisited), 1 = gray (in progress), 2 = black (done) + colour: dict[str, int] = {} + stack: list[tuple[str, bool]] = [(start_uri, False)] + + while stack: + uri, returning = stack.pop() + + if returning: + colour[uri] = 2 # black — fully explored + continue + + state = colour.get(uri, 0) + if state == 2: + continue # already fully explored + if state == 1: + raise UKOValidationError(f"Cycle detected in inheritance chain: {uri}") + + colour[uri] = 1 # gray — in progress + stack.append((uri, True)) # return marker + + node = uri_map.get(uri) + if node is None: + continue + for parent in node.parent_uris: + parent_state = colour.get(parent, 0) + if parent_state == 1: + raise UKOValidationError( + f"Cycle detected in inheritance chain: {parent}" + ) + if parent_state == 0: + stack.append((parent, False)) + + # ------------------------------------------------------------------ + # Internal parsing helpers + # ------------------------------------------------------------------ + + def _parse_prefixes(self, text: str) -> list[UKOPrefix]: + """Parse ``@prefix`` lines from TTL text.""" + prefixes: list[UKOPrefix] = [] + for line in text.splitlines(): + match = _PREFIX_RE.match(line.strip()) + if match: + prefixes.append( + UKOPrefix(prefix=match.group(1), uri=match.group(2)), + ) + return prefixes + + def _split_blocks(self, text: str) -> list[tuple[str, str]]: + """Split TTL text into (subject, predicates) blocks. + + Each block is a statement terminated by ``.`` (outside ``<>``, ``""``). + Prefix declarations and comment-only lines are skipped. + """ + lines: list[str] = [] + for raw_line in text.splitlines(): + stripped = raw_line.strip() + if ( + not stripped + or stripped.startswith("#") + or stripped.startswith("@prefix") + ): + continue + lines.append(stripped) + + joined = " ".join(lines) + + # Manually split on '.' that is a statement terminator, not inside + # angle brackets or quotes. + statements: list[str] = [] + buf: list[str] = [] + in_iri = False + in_str = False + i = 0 + chars = joined + length = len(chars) + while i < length: + ch = chars[i] + if ch == "<" and not in_str: + in_iri = True + buf.append(ch) + elif ch == ">" and in_iri and not in_str: + in_iri = False + buf.append(ch) + elif ch == '"' and not in_iri: + in_str = not in_str + buf.append(ch) + elif ch == "." and not in_iri and not in_str: + # Check it's a statement terminator (followed by whitespace or end) + next_i = i + 1 + if next_i >= length or chars[next_i] in (" ", "\t", "\n", "\r"): + stmt = "".join(buf).strip() + if stmt: + statements.append(stmt) + buf = [] + else: + buf.append(ch) + else: + buf.append(ch) + i += 1 + + # Flush remaining buffer + remainder = "".join(buf).strip() + if remainder: + statements.append(remainder) + + blocks: list[tuple[str, str]] = [] + for stmt in statements: + parts = stmt.split(None, 1) + if len(parts) < 2: + continue + blocks.append((parts[0], parts[1])) + return blocks + + def _parse_predicates(self, predicates_str: str) -> dict[str, str]: + """Parse ``predicate object ;`` pairs into a dict.""" + result: dict[str, str] = {} + # Split on `;` that is outside `<>` and `""` + pairs = self._split_on_semicolon(predicates_str) + for pair in pairs: + pair = pair.strip() + if not pair: + continue + parts = pair.split(None, 1) + if len(parts) < 2: + continue + predicate, obj = parts[0], parts[1] + # Handle IRI values + iri_match = _IRI_RE.search(obj) + if iri_match and predicate in ( + "owl:versionIRI", + "rdfs:domain", + "rdfs:range", + "rdfs:subPropertyOf", + ): + result[predicate] = iri_match.group(1) + else: + result[predicate] = obj.strip() + return result + + @staticmethod + def _split_on_semicolon(text: str) -> list[str]: + """Split *text* on ``;`` that is not inside ``<>`` or ``""``.""" + parts: list[str] = [] + buf: list[str] = [] + in_iri = False + in_str = False + for ch in text: + if ch == "<" and not in_str: + in_iri = True + buf.append(ch) + elif ch == ">" and in_iri and not in_str: + in_iri = False + buf.append(ch) + elif ch == '"' and not in_iri: + in_str = not in_str + buf.append(ch) + elif ch == ";" and not in_iri and not in_str: + parts.append("".join(buf)) + buf = [] + else: + buf.append(ch) + remainder = "".join(buf).strip() + if remainder: + parts.append(remainder) + return parts + + def _resolve_uri(self, prefixed: str, prefix_map: dict[str, str]) -> str: + """Expand a prefixed name to a full URI if the prefix is known.""" + if prefixed.startswith("<") and prefixed.endswith(">"): + return prefixed[1:-1] + if ":" in prefixed: + prefix, local = prefixed.split(":", 1) + if prefix in prefix_map: + return prefix_map[prefix] + local + return prefixed + + def _extract_version_string(self, version_iri: str) -> str: + """Extract a semver-like version from a version IRI. + + E.g. ``https://ontology.cleverthis.com/uko/0.1.0`` -> ``0.1.0``. + Returns ``""`` if the last path segment is not a valid semver string. + """ + if not version_iri: + return "" + # Take the last path segment + segment = version_iri.rstrip("/").rsplit("/", 1)[-1] + if re.match(r"^\d+\.\d+\.\d+$", segment): + return segment + return "" diff --git a/src/cleveragents/domain/models/core/__init__.py b/src/cleveragents/domain/models/core/__init__.py index ac72b5ac1..dc674a275 100644 --- a/src/cleveragents/domain/models/core/__init__.py +++ b/src/cleveragents/domain/models/core/__init__.py @@ -264,6 +264,14 @@ from cleveragents.domain.models.core.tool import ( ValidationMode, ) +# UKO ontology models +from cleveragents.domain.models.core.uko import ( + UKONode, + UKOOntology, + UKOPrefix, + UKOVersion, +) + __all__ = [ "BUILTIN_PROFILES", "DEFAULT_LOCAL_ROLE_MAPPING", @@ -426,6 +434,10 @@ __all__ = [ "ToolLifecycle", "ToolSource", "ToolType", + "UKONode", + "UKOOntology", + "UKOPrefix", + "UKOVersion", "User", "Validation", "ValidationMode", diff --git a/src/cleveragents/domain/models/core/uko.py b/src/cleveragents/domain/models/core/uko.py new file mode 100644 index 000000000..d02748df1 --- /dev/null +++ b/src/cleveragents/domain/models/core/uko.py @@ -0,0 +1,49 @@ +"""UKO (Unified Knowledge Ontology) domain models. + +Pydantic v2 value objects representing the parsed UKO ontology structure: +namespace prefixes, ontology nodes (classes and properties), version +metadata, and the top-level ontology container. + +All models use ``frozen=True`` for immutability as required by ADR-004. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class UKOPrefix(BaseModel, frozen=True): + """A namespace prefix mapping.""" + + prefix: str + uri: str + + +class UKONode(BaseModel, frozen=True): + """A node in the UKO ontology.""" + + uri: str + rdf_type: str # e.g. "owl:Class" or "owl:ObjectProperty" + label: str = "" + comment: str = "" + parent_uris: tuple[str, ...] = () # rdfs:subClassOf targets (multi-parent) + layer: int = Field(ge=0, le=3, default=0) + properties: tuple[tuple[str, str], ...] = () # immutable key-value pairs + + +class UKOVersion(BaseModel, frozen=True): + """Ontology version metadata.""" + + version_iri: str + version: str # e.g. "0.1.0" + label: str + comment: str + + +class UKOOntology(BaseModel, frozen=True): + """Parsed UKO ontology.""" + + version: UKOVersion + prefixes: tuple[UKOPrefix, ...] = () + nodes: tuple[UKONode, ...] = () + supported_versions: tuple[str, ...] = ("0.1.0",)