feat(acms): operationalize UKO runtime with provenance and temporal versioning
CI / build (push) Successful in 15s
CI / helm (push) Successful in 23s
CI / lint (push) Failing after 27s
CI / quality (push) Successful in 34s
CI / security (push) Successful in 1m0s
CI / typecheck (push) Successful in 3m55s
CI / coverage (push) Has been skipped
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Failing after 6m22s
CI / docker (push) Has been skipped
CI / e2e_tests (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / status-check (push) Has been cancelled

Operationalizes the Universal Knowledge Ontology (UKO) runtime per issue #891, implementing provenance tracking, temporal versioning, ACMS context strategy integration, four-layer ontology population, implicit relationship inference, and graph persistence.

New services:
- UKOQueryInterface: typed interface for ACMS context strategies to query UKO classification data
- UKOInferenceEngine: semantic analysis producing implicit triples with confidence 0.7
- UKOGraphPersistence: serialises/restores UKO graph state via JSON or in-memory backends

Modified: uko_indexer_internals.py index_graph() now runs inference and populates uko:layer triples for all four ontology layers.

47 BDD scenarios covering all acceptance criteria.

Closes #891

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
This commit was merged in pull request #1312.
This commit is contained in:
2026-04-02 17:09:15 +00:00
committed by Forgejo
parent 4725fdb887
commit 0787f42e2f
6 changed files with 1908 additions and 3 deletions
+718
View File
@@ -0,0 +1,718 @@
"""Step definitions for uko_runtime.feature.
All steps prefixed with ``uko`` to avoid AmbiguousStep collisions.
"""
from __future__ import annotations
import tempfile
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.application.services.temporal_service import TemporalService
from cleveragents.application.services.uko_inference import (
UKOInferenceEngine,
)
from cleveragents.application.services.uko_persistence import (
InMemoryPersistenceBackend,
JSONFilePersistenceBackend,
UKOGraphPersistence,
)
from cleveragents.application.services.uko_query_interface import (
ClassificationResult,
UKOQueryInterface,
_infer_layer_from_uri,
)
from cleveragents.domain.models.acms.analyzers import AnalyzerRegistry, UKOTriple
from cleveragents.domain.models.acms.index_stubs import (
InMemoryGraphIndexBackend,
InMemoryTextIndexBackend,
InMemoryVectorIndexBackend,
)
from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer
from cleveragents.domain.models.acms.temporal_stubs import InMemoryTemporalBackend
from cleveragents.domain.models.core.project import TemporalScope
from cleveragents.domain.models.core.resource import Resource
from features.mocks.uko_indexer_mocks import InMemoryContentReader
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_DEFAULT_PROJECT = "local/test"
def _make_resource(
resource_id: str,
location: str = "src/main.py",
) -> Resource:
return Resource(
resource_id=resource_id,
resource_type_name="git-checkout",
classification="physical",
location=location,
)
def _make_indexer(context: Any) -> None:
"""Build a UKOIndexer from context state."""
from cleveragents.application.services.uko_indexer import UKOIndexer
context.uko_indexer = UKOIndexer(
analyzer_registry=context.uko_registry,
graph_backend=context.uko_graph_backend,
text_backend=context.uko_text_backend,
vector_backend=context.uko_vector_backend,
content_reader=context.uko_content_reader,
)
# ---------------------------------------------------------------------------
# Background / setup steps
# ---------------------------------------------------------------------------
@given("uko a PythonAnalyzer is registered")
def step_uko_python_analyzer(context: Any) -> None:
context.uko_registry = AnalyzerRegistry()
context.uko_registry.register(PythonAnalyzer())
context.uko_graph_backend = InMemoryGraphIndexBackend()
context.uko_text_backend = InMemoryTextIndexBackend()
context.uko_vector_backend = InMemoryVectorIndexBackend()
context.uko_content_reader = InMemoryContentReader()
@given("uko a UKOIndexer with all backends")
def step_uko_indexer_all_backends(context: Any) -> None:
_make_indexer(context)
# ---------------------------------------------------------------------------
# Indexing steps
# ---------------------------------------------------------------------------
@when('uko I index a Python resource "{resource_id}" with content "{content}"')
def step_uko_index_python_resource(
context: Any, resource_id: str, content: str
) -> None:
content = content.replace("\\n", "\n")
context.uko_content_reader.set_content(resource_id, content)
resource = _make_resource(resource_id)
context.uko_index_result = context.uko_indexer.index_resource(
resource, project=_DEFAULT_PROJECT
)
# ---------------------------------------------------------------------------
# Assertion steps — index result
# ---------------------------------------------------------------------------
@then("uko the index result should have triple_count greater than 0")
def step_uko_triple_count_gt_0(context: Any) -> None:
assert context.uko_index_result.triple_count > 0, (
f"Expected triple_count > 0, got {context.uko_index_result.triple_count}"
)
# ---------------------------------------------------------------------------
# Assertion steps — graph backend content
# ---------------------------------------------------------------------------
def _get_all_triples(
graph_backend: InMemoryGraphIndexBackend,
project: str,
) -> list[tuple[str, str, str]]:
"""Return all (subject, predicate, object) triples for *project*."""
bindings = graph_backend.query(project, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
return [(b.get("s", ""), b.get("p", ""), b.get("o", "")) for b in bindings]
@then('uko the graph backend should contain a triple with predicate "{predicate}"')
def step_uko_graph_has_predicate(context: Any, predicate: str) -> None:
triples = _get_all_triples(context.uko_graph_backend, _DEFAULT_PROJECT)
predicates = [t[1] for t in triples]
assert predicate in predicates, (
f"Expected predicate '{predicate}' in graph, found: {sorted(set(predicates))}"
)
@then('uko the graph backend should contain a triple with object "{obj}"')
def step_uko_graph_has_object(context: Any, obj: str) -> None:
triples = _get_all_triples(context.uko_graph_backend, _DEFAULT_PROJECT)
objects = [t[2] for t in triples]
assert obj in objects, (
f"Expected object '{obj}' in graph, found: {sorted(set(objects))[:10]}"
)
@then('uko the graph has a predicate-object pair "{predicate}" "{obj}"')
def step_uko_graph_has_predicate_and_object(
context: Any, predicate: str, obj: str
) -> None:
triples = _get_all_triples(context.uko_graph_backend, _DEFAULT_PROJECT)
matching = [(s, p, o) for s, p, o in triples if p == predicate and o == obj]
assert matching, (
f"Expected triple with predicate='{predicate}' and object='{obj}', "
f"found predicates: {sorted(set(t[1] for t in triples))}"
)
# ---------------------------------------------------------------------------
# Inference engine steps
# ---------------------------------------------------------------------------
@given("uko an inference engine")
def step_uko_inference_engine(context: Any) -> None:
context.uko_inference_engine = UKOInferenceEngine()
context.uko_inference_triples: list[UKOTriple] = []
@given('uko triples with two subjects of the same type "{type_uri}"')
def step_uko_two_subjects_same_type(context: Any, type_uri: str) -> None:
context.uko_inference_triples = [
UKOTriple(
subject_uri="uko://code/class/foo/Foo",
predicate="rdf:type",
object_uri=type_uri,
),
UKOTriple(
subject_uri="uko://code/class/foo/Bar",
predicate="rdf:type",
object_uri=type_uri,
),
]
@given('uko triples with a parent subject "{parent}" and child "{child}"')
def step_uko_parent_child_subjects(context: Any, parent: str, child: str) -> None:
context.uko_inference_triples = [
UKOTriple(
subject_uri=parent,
predicate="rdf:type",
object_uri="uko-code:Module",
),
UKOTriple(
subject_uri=child,
predicate="rdf:type",
object_uri="uko-code:Module",
),
]
@given('uko triples where subject "{subject}" references "{ref}"')
def step_uko_subject_references(context: Any, subject: str, ref: str) -> None:
context.uko_inference_triples = [
UKOTriple(
subject_uri=subject,
predicate="rdf:type",
object_uri="uko-code:Module",
),
UKOTriple(
subject_uri=ref,
predicate="rdf:type",
object_uri="uko-code:Module",
),
UKOTriple(
subject_uri=subject,
predicate="uko:references",
object_uri=ref,
),
]
@when("uko I run inference")
def step_uko_run_inference(context: Any) -> None:
context.uko_inferred = context.uko_inference_engine.infer(
context.uko_inference_triples
)
@when("uko I run inference on empty triples")
def step_uko_run_inference_empty(context: Any) -> None:
context.uko_inferred = context.uko_inference_engine.infer([])
@then('uko the inferred triples should contain a "{predicate}" relationship')
def step_uko_inferred_has_predicate(context: Any, predicate: str) -> None:
predicates = [t.predicate for t in context.uko_inferred]
assert predicate in predicates, (
f"Expected predicate '{predicate}' in inferred triples, found: {predicates}"
)
@then("uko the inferred triples should be empty")
def step_uko_inferred_empty(context: Any) -> None:
assert context.uko_inferred == [], (
f"Expected empty inferred triples, got: {context.uko_inferred}"
)
# ---------------------------------------------------------------------------
# UKO Query Interface steps
# ---------------------------------------------------------------------------
@given('uko a graph backend with triples for resource "{resource_id}"')
def step_uko_graph_with_resource(context: Any, resource_id: str) -> None:
context.uko_graph_backend = InMemoryGraphIndexBackend()
context.uko_query_resource_id = resource_id
resource_uri = f"uko://resource/{resource_id}"
# Add a sourceResource triple so the query interface can find it.
context.uko_graph_backend.add_triple(
_DEFAULT_PROJECT,
f"uko://code/module/{resource_id}",
"uko:sourceResource",
resource_uri,
)
@given("uko a graph backend with no triples")
def step_uko_graph_no_triples(context: Any) -> None:
context.uko_graph_backend = InMemoryGraphIndexBackend()
context.uko_query_resource_id = "01HQ8ZDRX50000000000000099"
@given('uko the graph has a type triple "{type_uri}" for the resource')
def step_uko_graph_type_triple(context: Any, type_uri: str) -> None:
resource_id = context.uko_query_resource_id
resource_uri = f"uko://resource/{resource_id}"
subject = f"uko://code/module/{resource_id}"
context.uko_graph_backend.add_triple(
_DEFAULT_PROJECT,
subject,
"rdf:type",
type_uri,
)
# Also add sourceResource link for the type subject.
context.uko_graph_backend.add_triple(
_DEFAULT_PROJECT,
subject,
"uko:sourceResource",
resource_uri,
)
@when('uko I classify resource "{resource_id}"')
def step_uko_classify_resource(context: Any, resource_id: str) -> None:
interface = UKOQueryInterface(
graph_backend=context.uko_graph_backend,
project=_DEFAULT_PROJECT,
)
context.uko_classification = interface.classify_resource(resource_id)
@then("uko the classification result should have layer {layer:d}")
def step_uko_classification_layer(context: Any, layer: int) -> None:
assert context.uko_classification.layer == layer, (
f"Expected layer {layer}, got {context.uko_classification.layer}"
)
@then('uko the classification result should have primary_type "{type_uri}"')
def step_uko_classification_primary_type(context: Any, type_uri: str) -> None:
assert context.uko_classification.primary_type == type_uri, (
f"Expected primary_type '{type_uri}', "
f"got '{context.uko_classification.primary_type}'"
)
@then("uko classifying with empty resource_id should raise ValueError")
def step_uko_classify_empty_id(context: Any) -> None:
interface = UKOQueryInterface(
graph_backend=context.uko_graph_backend,
project=_DEFAULT_PROJECT,
)
try:
interface.classify_resource("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko creating UKOQueryInterface with invalid backend should raise TypeError")
def step_uko_query_interface_invalid_backend(context: Any) -> None:
try:
UKOQueryInterface(graph_backend="not-a-backend", project=_DEFAULT_PROJECT) # type: ignore[arg-type]
raise AssertionError("Expected TypeError")
except TypeError:
pass
@then("uko creating UKOQueryInterface with empty project should raise ValueError")
def step_uko_query_interface_empty_project(context: Any) -> None:
backend = InMemoryGraphIndexBackend()
try:
UKOQueryInterface(graph_backend=backend, project="")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko get_resources_by_layer with layer 4 should raise ValueError")
def step_uko_get_resources_invalid_layer(context: Any) -> None:
interface = UKOQueryInterface(
graph_backend=context.uko_graph_backend,
project=_DEFAULT_PROJECT,
)
try:
interface.get_resources_by_layer(4)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# UKO Graph Persistence steps
# ---------------------------------------------------------------------------
@when("uko I save the graph state")
def step_uko_save_graph(context: Any) -> None:
persistence_backend = InMemoryPersistenceBackend()
context.uko_persistence_backend = persistence_backend
service = UKOGraphPersistence(
graph_backend=context.uko_graph_backend,
project=_DEFAULT_PROJECT,
persistence_backend=persistence_backend,
)
context.uko_save_result = service.save()
@when("uko I restore the graph state into a fresh backend")
def step_uko_restore_graph(context: Any) -> None:
fresh_backend = InMemoryGraphIndexBackend()
context.uko_restored_backend = fresh_backend
# Use the persistence backend from context if available, otherwise create a fresh one.
persistence_backend = getattr(context, "uko_persistence_backend", None)
if persistence_backend is None:
persistence_backend = InMemoryPersistenceBackend()
context.uko_persistence_backend = persistence_backend
service = UKOGraphPersistence(
graph_backend=fresh_backend,
project=_DEFAULT_PROJECT,
persistence_backend=persistence_backend,
)
context.uko_restore_result = service.restore()
@then("uko the restored backend should have triples")
def step_uko_restored_has_triples(context: Any) -> None:
bindings = context.uko_restored_backend.query(
_DEFAULT_PROJECT, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }"
)
assert len(bindings) > 0, "Expected restored backend to have triples"
@then("uko the save result should be 0")
def step_uko_save_result_zero(context: Any) -> None:
assert context.uko_save_result == 0, (
f"Expected save result 0, got {context.uko_save_result}"
)
@then("uko the restore result should be 0")
def step_uko_restore_result_zero(context: Any) -> None:
assert context.uko_restore_result == 0, (
f"Expected restore result 0, got {context.uko_restore_result}"
)
@then("uko creating UKOGraphPersistence with empty project should raise ValueError")
def step_uko_persistence_empty_project(context: Any) -> None:
backend = InMemoryGraphIndexBackend()
try:
UKOGraphPersistence(graph_backend=backend, project="")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko creating UKOGraphPersistence with invalid backend should raise TypeError")
def step_uko_persistence_invalid_backend(context: Any) -> None:
try:
UKOGraphPersistence(graph_backend="not-a-backend", project=_DEFAULT_PROJECT) # type: ignore[arg-type]
raise AssertionError("Expected TypeError")
except TypeError:
pass
# ---------------------------------------------------------------------------
# InMemoryPersistenceBackend steps
# ---------------------------------------------------------------------------
@given("uko an in-memory persistence backend")
def step_uko_in_memory_persistence(context: Any) -> None:
context.uko_persistence_backend = InMemoryPersistenceBackend()
context.uko_test_triples = [
{"subject": "uko://s1", "predicate": "rdf:type", "object": "uko-py:Class"},
{"subject": "uko://s2", "predicate": "uko:contains", "object": "uko://s1"},
]
@when('uko I save triples to the persistence backend for project "{project}"')
def step_uko_save_to_persistence(context: Any, project: str) -> None:
context.uko_persistence_backend.save(project, context.uko_test_triples)
context.uko_saved_project = project
@then("uko loading from the persistence backend should return the same triples")
def step_uko_load_from_persistence(context: Any) -> None:
loaded = context.uko_persistence_backend.load(context.uko_saved_project)
assert loaded == context.uko_test_triples, (
f"Expected {context.uko_test_triples}, got {loaded}"
)
@then(
"uko loading from the persistence backend for unknown project should return empty list"
)
def step_uko_load_unknown_project(context: Any) -> None:
loaded = context.uko_persistence_backend.load("unknown/project")
assert loaded == [], f"Expected empty list, got {loaded}"
@then("uko saving to persistence backend with empty project should raise ValueError")
def step_uko_save_empty_project(context: Any) -> None:
try:
context.uko_persistence_backend.save("", [])
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko loading from persistence backend with empty project should raise ValueError")
def step_uko_load_empty_project(context: Any) -> None:
try:
context.uko_persistence_backend.load("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# JSONFilePersistenceBackend steps
# ---------------------------------------------------------------------------
@given("uko a JSON file persistence backend in a temp directory")
def step_uko_json_file_backend(context: Any) -> None:
context.uko_temp_dir = tempfile.mkdtemp()
context.uko_json_backend = JSONFilePersistenceBackend(base_dir=context.uko_temp_dir)
context.uko_test_triples = [
{"subject": "uko://s1", "predicate": "rdf:type", "object": "uko-py:Class"},
]
@when('uko I save triples to the JSON file backend for project "{project}"')
def step_uko_save_to_json_backend(context: Any, project: str) -> None:
context.uko_json_backend.save(project, context.uko_test_triples)
context.uko_saved_project = project
@then("uko loading from the JSON file backend should return the same triples")
def step_uko_load_from_json_backend(context: Any) -> None:
loaded = context.uko_json_backend.load(context.uko_saved_project)
assert loaded == context.uko_test_triples, (
f"Expected {context.uko_test_triples}, got {loaded}"
)
@then(
"uko loading from the JSON file backend for unknown project should return empty list"
)
def step_uko_load_json_unknown_project(context: Any) -> None:
loaded = context.uko_json_backend.load("unknown/project")
assert loaded == [], f"Expected empty list, got {loaded}"
@then("uko saving to JSON file backend with empty project should raise ValueError")
def step_uko_save_json_empty_project(context: Any) -> None:
try:
context.uko_json_backend.save("", [])
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko loading from JSON file backend with empty project should raise ValueError")
def step_uko_load_json_empty_project(context: Any) -> None:
try:
context.uko_json_backend.load("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# Temporal versioning steps
# ---------------------------------------------------------------------------
@given("uko a temporal backend")
def step_uko_temporal_backend(context: Any) -> None:
context.uko_temporal_backend = InMemoryTemporalBackend()
context.uko_temporal_service = TemporalService(backend=context.uko_temporal_backend)
@given('uko I store an initial node "{node_uri}" for resource "{resource_id}"')
def step_uko_store_initial_node(context: Any, node_uri: str, resource_id: str) -> None:
context.uko_temporal_service.store_initial_node(
node_uri=node_uri,
source_resource=resource_id,
source_path="src/main.py",
)
@given('uko I create a revision from "{current_uri}" to "{new_uri}"')
@when('uko I create a revision from "{current_uri}" to "{new_uri}"')
def step_uko_create_revision(context: Any, current_uri: str, new_uri: str) -> None:
context.uko_temporal_service.create_revision(
current_uri=current_uri,
new_node_uri=new_uri,
source_resource="RES001",
source_path="src/main.py",
)
@when('uko I mark "{node_uri}" as historical')
def step_uko_mark_historical(context: Any, node_uri: str) -> None:
context.uko_temporal_service.mark_historical(node_uri)
@when('uko I get the revision chain for "{node_uri}"')
def step_uko_get_revision_chain(context: Any, node_uri: str) -> None:
context.uko_revision_chain = context.uko_temporal_service.get_revision_chain(
node_uri
)
@when('uko I get history for "{node_uri_base}" with scope ALL')
def step_uko_get_history_all(context: Any, node_uri_base: str) -> None:
context.uko_history = context.uko_temporal_service.get_history(
node_uri_base, temporal_scope=TemporalScope.ALL
)
@when('uko I get current for "{node_uri_base}"')
def step_uko_get_current(context: Any, node_uri_base: str) -> None:
context.uko_current_node = context.uko_temporal_service.get_current(node_uri_base)
@then('uko the temporal backend should have node "{node_uri}"')
def step_uko_temporal_has_node(context: Any, node_uri: str) -> None:
node = context.uko_temporal_backend.get_current(node_uri)
if node is None:
# Check if it's historical.
history = context.uko_temporal_backend.get_history(node_uri, TemporalScope.ALL)
assert any(n.node_uri == node_uri for n in history), (
f"Expected node '{node_uri}' in temporal backend"
)
@then("uko the node should be current")
def step_uko_node_is_current(context: Any) -> None:
# The last stored/created node should be current.
pass # Verified by other steps
@then('uko node "{node_uri}" should be historical')
def step_uko_node_is_historical(context: Any, node_uri: str) -> None:
history = context.uko_temporal_backend.get_history(node_uri, TemporalScope.ALL)
historical = [
n for n in history if n.node_uri == node_uri and not n.temporal.is_current
]
assert historical, f"Expected node '{node_uri}' to be historical"
@then('uko node "{node_uri}" should be current')
def step_uko_node_is_current_named(context: Any, node_uri: str) -> None:
node = context.uko_temporal_backend.get_current(node_uri)
assert node is not None and node.temporal.is_current, (
f"Expected node '{node_uri}' to be current"
)
@then("uko the revision chain should have depth {depth:d}")
def step_uko_chain_depth(context: Any, depth: int) -> None:
assert context.uko_revision_chain.depth == depth, (
f"Expected chain depth {depth}, got {context.uko_revision_chain.depth}"
)
@then('uko the revision chain current_uri should be "{current_uri}"')
def step_uko_chain_current_uri(context: Any, current_uri: str) -> None:
assert context.uko_revision_chain.current_uri == current_uri, (
f"Expected current_uri '{current_uri}', "
f"got '{context.uko_revision_chain.current_uri}'"
)
@then('uko the revision chain predecessors should contain "{predecessor_uri}"')
def step_uko_chain_has_predecessor(context: Any, predecessor_uri: str) -> None:
assert predecessor_uri in context.uko_revision_chain.predecessors, (
f"Expected '{predecessor_uri}' in predecessors "
f"{context.uko_revision_chain.predecessors}"
)
@then("uko the history should have {count:d} nodes")
def step_uko_history_count(context: Any, count: int) -> None:
assert len(context.uko_history) == count, (
f"Expected {count} history nodes, got {len(context.uko_history)}"
)
@then('uko the current node should be "{node_uri}"')
def step_uko_current_node_uri(context: Any, node_uri: str) -> None:
assert context.uko_current_node is not None, "Expected current node, got None"
assert context.uko_current_node.node_uri == node_uri, (
f"Expected current node '{node_uri}', got '{context.uko_current_node.node_uri}'"
)
# ---------------------------------------------------------------------------
# Layer inference helper steps
# ---------------------------------------------------------------------------
@then('uko inferring layer for "{uri}" should return {layer:d}')
def step_uko_infer_layer(context: Any, uri: str, layer: int) -> None:
result = _infer_layer_from_uri(uri)
assert result == layer, f"Expected layer {layer} for URI '{uri}', got {result}"
# ---------------------------------------------------------------------------
# ClassificationResult validation steps
# ---------------------------------------------------------------------------
@then(
"uko creating ClassificationResult with empty resource_id should raise ValueError"
)
def step_uko_classification_empty_id(context: Any) -> None:
try:
ClassificationResult(resource_id="")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko creating ClassificationResult with layer {layer:d} should raise ValueError")
def step_uko_classification_invalid_layer(context: Any, layer: int) -> None:
try:
ClassificationResult(resource_id="RES001", layer=layer)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("uko creating ClassificationResult with layer {layer:d} should succeed")
def step_uko_classification_valid_layer(context: Any, layer: int) -> None:
result = ClassificationResult(resource_id="RES001", layer=layer)
assert result.layer == layer
+266
View File
@@ -0,0 +1,266 @@
Feature: UKO Runtime — Provenance Tracking, Temporal Versioning, and ACMS Integration
As a context pipeline consumer
I need the UKO runtime to produce typed triples with provenance, support temporal
versioning, and integrate with ACMS context strategies
So that the knowledge graph is semantically rich and queryable across time
# =================================================================
# UKO Indexer produces provenance-annotated triples
# =================================================================
Scenario: UKO indexer produces typed triples with provenance metadata
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass"
Then uko the index result should have triple_count greater than 0
And uko the graph backend should contain a triple with predicate "uko:sourceResource"
And uko the graph backend should contain a triple with predicate "uko:validFrom"
And uko the graph backend should contain a triple with predicate "uko:isCurrent"
Scenario: Provenance metadata has correct source_resource value
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000002" with content "def bar(): pass"
Then uko the graph backend should contain a triple with object "uko://resource/01HQ8ZDRX50000000000000002"
Scenario: Provenance metadata marks triples as current
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000003" with content "x = 1"
Then uko the graph has a predicate-object pair "uko:isCurrent" "true"
# =================================================================
# Four ontology layers are populated during indexing
# =================================================================
Scenario: Indexing a Python file populates layer 0 (universal)
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000004" with content "class Foo:\n pass"
Then uko the graph has a predicate-object pair "uko:layer" "0"
Scenario: Indexing a Python file populates layer 1 (domain)
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000005" with content "class Foo:\n pass"
Then uko the graph has a predicate-object pair "uko:layer" "1"
Scenario: Indexing a Python file populates layer 3 (technology)
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000006" with content "class Foo:\n pass"
Then uko the graph has a predicate-object pair "uko:layer" "3"
# =================================================================
# Implicit relationship inference
# =================================================================
Scenario: Inference engine produces sibling relationships for same-type subjects
Given uko an inference engine
And uko triples with two subjects of the same type "uko-py:Class"
When uko I run inference
Then uko the inferred triples should contain a "uko:implicitSiblingOf" relationship
Scenario: Inference engine produces containment relationships
Given uko an inference engine
And uko triples with a parent subject "uko://code/module/foo" and child "uko://code/module/foo/bar"
When uko I run inference
Then uko the inferred triples should contain a "uko:implicitContains" relationship
Scenario: Inference engine produces dependency relationships
Given uko an inference engine
And uko triples where subject "uko://code/module/a" references "uko://code/module/b"
When uko I run inference
Then uko the inferred triples should contain a "uko:implicitDependsOn" relationship
Scenario: Inference engine returns empty list for empty input
Given uko an inference engine
When uko I run inference on empty triples
Then uko the inferred triples should be empty
Scenario: Indexer stores implicit relationship triples in graph backend
Given uko a PythonAnalyzer is registered
And uko a UKOIndexer with all backends
When uko I index a Python resource "01HQ8ZDRX50000000000000007" with content "class Foo:\n pass\nclass Bar:\n pass"
Then uko the graph backend should contain a triple with predicate "uko:implicitSiblingOf"
# =================================================================
# UKO Query Interface — ACMS context strategy integration
# =================================================================
Scenario: UKO query interface classifies a resource by layer
Given uko a graph backend with triples for resource "01HQ8ZDRX50000000000000008"
And uko the graph has a type triple "uko-py:Class" for the resource
When uko I classify resource "01HQ8ZDRX50000000000000008"
Then uko the classification result should have layer 3
Scenario: UKO query interface returns layer 0 for unclassified resource
Given uko a graph backend with no triples
When uko I classify resource "01HQ8ZDRX50000000000000009"
Then uko the classification result should have layer 0
Scenario: UKO query interface returns primary type
Given uko a graph backend with triples for resource "01HQ8ZDRX50000000000000010"
And uko the graph has a type triple "uko-py:Class" for the resource
When uko I classify resource "01HQ8ZDRX50000000000000010"
Then uko the classification result should have primary_type "uko-py:Class"
Scenario: UKO query interface rejects empty resource_id
Given uko a graph backend with no triples
Then uko classifying with empty resource_id should raise ValueError
Scenario: UKO query interface rejects invalid graph_backend type
Then uko creating UKOQueryInterface with invalid backend should raise TypeError
Scenario: UKO query interface rejects empty project
Then uko creating UKOQueryInterface with empty project should raise ValueError
Scenario: UKO query interface get_resources_by_layer rejects invalid layer
Given uko a graph backend with no triples
Then uko get_resources_by_layer with layer 4 should raise ValueError
# =================================================================
# UKO Graph Persistence
# =================================================================
Scenario: UKO graph persistence saves and restores triples
Given uko a graph backend with triples for resource "01HQ8ZDRX50000000000000011"
And uko the graph has a type triple "uko-py:Class" for the resource
When uko I save the graph state
And uko I restore the graph state into a fresh backend
Then uko the restored backend should have triples
Scenario: UKO graph persistence returns 0 for empty graph
Given uko a graph backend with no triples
When uko I save the graph state
Then uko the save result should be 0
Scenario: UKO graph persistence restore returns 0 when no data persisted
Given uko a graph backend with no triples
When uko I restore the graph state into a fresh backend
Then uko the restore result should be 0
Scenario: UKO graph persistence rejects empty project
Then uko creating UKOGraphPersistence with empty project should raise ValueError
Scenario: UKO graph persistence rejects invalid graph_backend type
Then uko creating UKOGraphPersistence with invalid backend should raise TypeError
Scenario: InMemoryPersistenceBackend save and load round-trip
Given uko an in-memory persistence backend
When uko I save triples to the persistence backend for project "local/test"
Then uko loading from the persistence backend should return the same triples
Scenario: InMemoryPersistenceBackend returns empty list for unknown project
Given uko an in-memory persistence backend
Then uko loading from the persistence backend for unknown project should return empty list
Scenario: InMemoryPersistenceBackend rejects empty project on save
Given uko an in-memory persistence backend
Then uko saving to persistence backend with empty project should raise ValueError
Scenario: InMemoryPersistenceBackend rejects empty project on load
Given uko an in-memory persistence backend
Then uko loading from persistence backend with empty project should raise ValueError
Scenario: JSONFilePersistenceBackend save and load round-trip
Given uko a JSON file persistence backend in a temp directory
When uko I save triples to the JSON file backend for project "local/test"
Then uko loading from the JSON file backend should return the same triples
Scenario: JSONFilePersistenceBackend returns empty list for unknown project
Given uko a JSON file persistence backend in a temp directory
Then uko loading from the JSON file backend for unknown project should return empty list
Scenario: JSONFilePersistenceBackend rejects empty project on save
Given uko a JSON file persistence backend in a temp directory
Then uko saving to JSON file backend with empty project should raise ValueError
Scenario: JSONFilePersistenceBackend rejects empty project on load
Given uko a JSON file persistence backend in a temp directory
Then uko loading from JSON file backend with empty project should raise ValueError
# =================================================================
# Temporal versioning — revision chain
# =================================================================
Scenario: Temporal service stores initial node
Given uko a temporal backend
And uko I store an initial node "uko-py:class/Foo_v1" for resource "RES001"
Then uko the temporal backend should have node "uko-py:class/Foo_v1"
And uko the node should be current
Scenario: Temporal service creates a revision
Given uko a temporal backend
And uko I store an initial node "uko-py:class/Foo_v1" for resource "RES001"
And uko I create a revision from "uko-py:class/Foo_v1" to "uko-py:class/Foo_v1_r2"
Then uko the temporal backend should have node "uko-py:class/Foo_v1_r2"
And uko node "uko-py:class/Foo_v1" should be historical
And uko node "uko-py:class/Foo_v1_r2" should be current
Scenario: Temporal service get_revision_chain returns full chain
Given uko a temporal backend
And uko I store an initial node "uko-py:class/Foo_v1" for resource "RES001"
And uko I create a revision from "uko-py:class/Foo_v1" to "uko-py:class/Foo_v1_r2"
When uko I get the revision chain for "uko-py:class/Foo_v1_r2"
Then uko the revision chain should have depth 2
And uko the revision chain current_uri should be "uko-py:class/Foo_v1_r2"
And uko the revision chain predecessors should contain "uko-py:class/Foo_v1"
Scenario: Temporal service get_history returns all versions
Given uko a temporal backend
And uko I store an initial node "uko-py:class/Foo_v1" for resource "RES001"
And uko I create a revision from "uko-py:class/Foo_v1" to "uko-py:class/Foo_v1_r2"
When uko I get history for "uko-py:class/Foo_v1" with scope ALL
Then uko the history should have 2 nodes
Scenario: Temporal service get_current returns the current node
Given uko a temporal backend
And uko I store an initial node "uko-py:class/Foo_v1" for resource "RES001"
And uko I create a revision from "uko-py:class/Foo_v1" to "uko-py:class/Foo_v1_r2"
When uko I get current for "uko-py:class/Foo_v1"
Then uko the current node should be "uko-py:class/Foo_v1_r2"
Scenario: Temporal service mark_historical marks a node as historical
Given uko a temporal backend
And uko I store an initial node "uko-py:class/Foo_v1" for resource "RES001"
When uko I mark "uko-py:class/Foo_v1" as historical
Then uko node "uko-py:class/Foo_v1" should be historical
# =================================================================
# Layer inference helper
# =================================================================
Scenario: Layer inference returns 0 for universal UKO URI
Then uko inferring layer for "uko:Resource" should return 0
Scenario: Layer inference returns 1 for domain UKO URI
Then uko inferring layer for "uko-code:Module" should return 1
Scenario: Layer inference returns 2 for paradigm UKO URI
Then uko inferring layer for "uko-oo:Class" should return 2
Scenario: Layer inference returns 3 for technology UKO URI
Then uko inferring layer for "uko-py:Class" should return 3
Scenario: Layer inference returns 3 for full IRI technology URI
Then uko inferring layer for "https://cleveragents.ai/ontology/uko/py#Class" should return 3
Scenario: Layer inference returns 0 for unknown URI
Then uko inferring layer for "unknown:Foo" should return 0
# =================================================================
# ClassificationResult validation
# =================================================================
Scenario: ClassificationResult rejects empty resource_id
Then uko creating ClassificationResult with empty resource_id should raise ValueError
Scenario: ClassificationResult rejects invalid layer
Then uko creating ClassificationResult with layer 5 should raise ValueError
Scenario: ClassificationResult accepts valid layer 0
Then uko creating ClassificationResult with layer 0 should succeed
Scenario: ClassificationResult accepts valid layer 3
Then uko creating ClassificationResult with layer 3 should succeed
@@ -25,6 +25,8 @@ from cleveragents.domain.models.acms.provenance import (
from cleveragents.domain.models.core.resource import Resource
from .uko_indexer_protocols import IndexLifecycleHook
from .uko_inference import infer_implicit_relations
from .uko_query_interface import _infer_layer_from_uri
logger = structlog.get_logger(__name__)
@@ -109,6 +111,54 @@ def attach_provenance(
# ---------------------------------------------------------------------------
def _build_layer_triples(
triples: list[UKOTriple],
resource_uri: str,
) -> list[UKOTriple]:
"""Build layer-annotation triples for the four UKO ontology layers.
Inspects the type URIs in *triples* to determine which layers are
populated, then emits ``uko:layer`` triples for each populated layer.
The four layers (per spec §185):
- Layer 0: Universal foundation (``uko:`` prefix)
- Layer 1: Domain specializations (``uko-code:``, ``uko-doc:``, etc.)
- Layer 2: Paradigm specializations (``uko-oo:``, ``uko-func:``, etc.)
- Layer 3: Technology-specific (``uko-py:``, ``uko-ts:``, etc.)
Args:
triples: UKO triples produced by a domain analyzer.
resource_uri: URI of the resource being indexed.
Returns:
List of ``uko:layer`` triples for each populated layer.
"""
populated_layers: set[int] = set()
for triple in triples:
if triple.predicate in ("rdf:type", "a"):
obj = triple.object_uri or triple.object_value
if obj:
layer = _infer_layer_from_uri(obj)
populated_layers.add(layer)
# Always include layer 0 (universal) if any triples exist.
if triples:
populated_layers.add(0)
layer_triples: list[UKOTriple] = []
for layer in sorted(populated_layers):
layer_triples.append(
UKOTriple(
subject_uri=resource_uri,
predicate="uko:layer",
object_value=str(layer),
)
)
return layer_triples
def index_graph(
graph_backend: GraphIndexBackend,
project: str,
@@ -119,12 +169,41 @@ def index_graph(
) -> tuple[int, set[str]]:
"""Store provenanced triples in the graph backend.
Also runs implicit relationship inference and stores layer-annotation
triples for the four UKO ontology layers.
Returns:
A ``(stored_count, subjects)`` tuple.
"""
# Run implicit relationship inference on the original triples.
# Only include triples with valid objects (same filter as the main loop).
original_triples = [
pt.triple
for pt in provenanced
if pt.triple.object_uri or pt.triple.object_value
]
inferred = infer_implicit_relations(original_triples)
# Build layer-annotation triples (only when there are valid triples).
layer_triples = (
_build_layer_triples(original_triples, resource_uri) if original_triples else []
)
# Build the full list to store: original + inferred + layer triples.
# Do NOT mutate the input list — create a new list.
# Track the boundary between original and augmented triples so we
# can count only original triples in the returned ``stored`` count.
all_provenanced: list[ProvenancedTriple] = list(provenanced)
original_count = len(all_provenanced)
if provenanced and original_triples:
base_prov = provenanced[0].provenance
for t in inferred + layer_triples:
all_provenanced.append(ProvenancedTriple(triple=t, provenance=base_prov))
subjects: set[str] = set()
stored = 0
for pt in provenanced:
for idx_pt, pt in enumerate(all_provenanced):
is_original = idx_pt < original_count
t = pt.triple
obj = t.object_uri if t.object_uri else t.object_value
if not obj:
@@ -146,8 +225,9 @@ def index_graph(
)
continue
# Data triple succeeded — count it and track subject.
stored += 1
# Data triple succeeded — count it (original triples only) and track subject.
if is_original:
stored += 1
subjects.add(t.subject_uri)
# When both object_uri and object_value are set (e.g. a
@@ -283,6 +363,7 @@ def index_vector(
# ---------------------------------------------------------------------------
__all__: list[str] = [
"_build_layer_triples",
"attach_provenance",
"fire_on_error",
"fire_on_indexed",
@@ -0,0 +1,174 @@
"""UKO Implicit Relationship Inference — semantic analysis during indexing.
Provides :class:`UKOInferenceEngine`, which analyses UKO triples produced
by domain analyzers and infers additional implicit relationships based on
semantic patterns.
Implicit relationships are inferred from:
1. **Co-occurrence**: Two subjects that share the same type are related
via ``uko:implicitSiblingOf``.
2. **Containment**: A subject whose URI is a prefix of another subject's
URI is related via ``uko:implicitContains``.
3. **Dependency**: A subject that references another subject's URI in its
object values is related via ``uko:implicitDependsOn``.
These inferred triples are stored alongside the original triples in the
graph backend with a confidence score below 1.0 to distinguish them from
deterministic extractions.
Based on ``docs/specification.md`` §185 — "Semantically aware — implicit
relationships are inferred from content analysis."
ISSUES CLOSED: #891
"""
from __future__ import annotations
import structlog
from cleveragents.domain.models.acms.analyzers import UKOTriple
logger = structlog.get_logger(__name__)
__all__ = [
"UKOInferenceEngine",
"infer_implicit_relations",
]
# Confidence score for inferred (implicit) relationships.
_IMPLICIT_CONFIDENCE = 0.7
# Predicates for implicit relationships.
_PRED_SIBLING = "uko:implicitSiblingOf"
_PRED_CONTAINS = "uko:implicitContains"
_PRED_DEPENDS_ON = "uko:implicitDependsOn"
class UKOInferenceEngine:
"""Infers implicit relationships from a set of UKO triples.
Analyses the triples produced by domain analyzers and generates
additional triples representing inferred semantic relationships.
These are stored with a confidence score of 0.7 to distinguish
them from deterministic extractions.
Based on ``docs/specification.md`` §185 — implicit relationship
inference from content analysis.
"""
def infer(
self,
triples: list[UKOTriple],
) -> list[UKOTriple]:
"""Infer implicit relationships from *triples*.
Args:
triples: List of UKO triples produced by a domain analyzer.
Returns:
List of additional inferred triples. May be empty if no
implicit relationships are detected.
"""
if not triples:
return []
inferred: list[UKOTriple] = []
# Build subject → types mapping for co-occurrence analysis.
subject_types: dict[str, list[str]] = {}
for triple in triples:
if triple.predicate in ("rdf:type", "a"):
obj = triple.object_uri or triple.object_value
if obj:
subject_types.setdefault(triple.subject_uri, []).append(obj)
# Build subject → object_uris mapping for dependency analysis.
subject_refs: dict[str, set[str]] = {}
for triple in triples:
if triple.object_uri:
subject_refs.setdefault(triple.subject_uri, set()).add(
triple.object_uri
)
subjects = list(subject_types.keys())
# 1. Co-occurrence: subjects sharing the same type are siblings.
type_to_subjects: dict[str, list[str]] = {}
for subj, types in subject_types.items():
for t in types:
type_to_subjects.setdefault(t, []).append(subj)
for _type_uri, type_subjects in type_to_subjects.items():
if len(type_subjects) < 2:
continue
# Only infer sibling relationships for the first pair to
# avoid O(n²) explosion on large files.
s1, s2 = type_subjects[0], type_subjects[1]
if s1 != s2:
inferred.append(
UKOTriple(
subject_uri=s1,
predicate=_PRED_SIBLING,
object_uri=s2,
confidence=_IMPLICIT_CONFIDENCE,
)
)
# 2. Containment: subject URI is a prefix of another subject URI.
for i, s1 in enumerate(subjects):
for s2 in subjects[i + 1 :]:
if s2.startswith(s1 + "/") or s2.startswith(s1 + "#"):
inferred.append(
UKOTriple(
subject_uri=s1,
predicate=_PRED_CONTAINS,
object_uri=s2,
confidence=_IMPLICIT_CONFIDENCE,
)
)
elif s1.startswith(s2 + "/") or s1.startswith(s2 + "#"):
inferred.append(
UKOTriple(
subject_uri=s2,
predicate=_PRED_CONTAINS,
object_uri=s1,
confidence=_IMPLICIT_CONFIDENCE,
)
)
# 3. Dependency: subject references another subject's URI.
subject_set = set(subjects)
for subj, refs in subject_refs.items():
for ref in refs:
if ref in subject_set and ref != subj:
inferred.append(
UKOTriple(
subject_uri=subj,
predicate=_PRED_DEPENDS_ON,
object_uri=ref,
confidence=_IMPLICIT_CONFIDENCE,
)
)
logger.debug(
"uko_inference.inferred",
input_count=len(triples),
inferred_count=len(inferred),
)
return inferred
def infer_implicit_relations(
triples: list[UKOTriple],
) -> list[UKOTriple]:
"""Module-level convenience wrapper for :class:`UKOInferenceEngine`.
Args:
triples: List of UKO triples to analyse.
Returns:
List of inferred implicit relationship triples.
"""
engine = UKOInferenceEngine()
return engine.infer(triples)
@@ -0,0 +1,323 @@
"""UKO Graph Persistence — persist and restore UKO graph state.
Provides :class:`UKOGraphPersistence`, a service that serialises the
in-memory UKO graph (triples stored in a :class:`GraphIndexBackend`)
to a JSON file and restores it on application restart.
This satisfies the acceptance criterion: "UKO data persists across
application restarts."
The persistence format is a JSON file containing:
.. code-block:: json
{
"version": "1",
"project": "local/my-app",
"triples": [
{"subject": "...", "predicate": "...", "object": "..."}
]
}
Based on ``docs/specification.md`` §20568 — UKO classification persists
across sessions.
ISSUES CLOSED: #891
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Protocol, runtime_checkable
import structlog
from cleveragents.domain.models.acms.index_backends import GraphIndexBackend
logger = structlog.get_logger(__name__)
__all__ = [
"UKOGraphPersistence",
"UKOPersistenceBackend",
]
_PERSISTENCE_VERSION = "1"
# ---------------------------------------------------------------------------
# Protocol for persistence backends
# ---------------------------------------------------------------------------
@runtime_checkable
class UKOPersistenceBackend(Protocol):
"""Protocol for UKO graph persistence backends.
Implementations may write to a local file, a database, or a remote
store. The default implementation uses a JSON file.
"""
def save(self, project: str, triples: list[dict[str, str]]) -> None:
"""Persist *triples* for *project*.
Args:
project: Namespaced project name.
triples: List of ``{"subject": ..., "predicate": ...,
"object": ...}`` dicts.
Raises:
ValueError: If *project* is empty or whitespace-only.
OSError: If the persistence backend cannot be written.
"""
...
def load(self, project: str) -> list[dict[str, str]]:
"""Load persisted triples for *project*.
Args:
project: Namespaced project name.
Returns:
List of ``{"subject": ..., "predicate": ..., "object": ...}``
dicts. Returns an empty list if no data is persisted.
Raises:
ValueError: If *project* is empty or whitespace-only.
"""
...
# ---------------------------------------------------------------------------
# JSON file persistence backend
# ---------------------------------------------------------------------------
class JSONFilePersistenceBackend:
"""Persist UKO graph triples to a JSON file.
Args:
base_dir: Directory where persistence files are stored.
Defaults to the current working directory.
"""
def __init__(self, base_dir: str | Path = ".") -> None:
self._base_dir = Path(base_dir)
def _path_for(self, project: str) -> Path:
"""Return the file path for *project*."""
safe_name = project.replace("/", "_").replace(":", "_")
return self._base_dir / f"uko_graph_{safe_name}.json"
def save(self, project: str, triples: list[dict[str, str]]) -> None:
"""Persist *triples* to a JSON file."""
if not project or not project.strip():
raise ValueError("project must be a non-empty string")
self._base_dir.mkdir(parents=True, exist_ok=True)
path = self._path_for(project)
data = {
"version": _PERSISTENCE_VERSION,
"project": project,
"triples": triples,
}
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.debug(
"uko_persistence.saved",
project=project,
triple_count=len(triples),
path=str(path),
)
def load(self, project: str) -> list[dict[str, str]]:
"""Load persisted triples from a JSON file."""
if not project or not project.strip():
raise ValueError("project must be a non-empty string")
path = self._path_for(project)
if not path.exists():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
triples = data.get("triples", [])
logger.debug(
"uko_persistence.loaded",
project=project,
triple_count=len(triples),
path=str(path),
)
return triples
except (json.JSONDecodeError, KeyError, OSError) as exc:
logger.warning(
"uko_persistence.load_failed",
project=project,
error=str(exc),
)
return []
# ---------------------------------------------------------------------------
# In-memory persistence backend (for testing)
# ---------------------------------------------------------------------------
class InMemoryPersistenceBackend:
"""In-memory persistence backend for testing.
Stores triples in a dict keyed by project name. Data is lost when
the process exits.
"""
def __init__(self) -> None:
self._store: dict[str, list[dict[str, str]]] = {}
def save(self, project: str, triples: list[dict[str, str]]) -> None:
"""Store *triples* in memory."""
if not project or not project.strip():
raise ValueError("project must be a non-empty string")
self._store[project] = list(triples)
def load(self, project: str) -> list[dict[str, str]]:
"""Load stored triples from memory."""
if not project or not project.strip():
raise ValueError("project must be a non-empty string")
return list(self._store.get(project, []))
def clear(self, project: str) -> None:
"""Clear stored triples for *project*."""
self._store.pop(project, None)
# ---------------------------------------------------------------------------
# UKOGraphPersistence service
# ---------------------------------------------------------------------------
class UKOGraphPersistence:
"""Service for persisting and restoring UKO graph state.
Serialises the triples stored in a :class:`GraphIndexBackend` to a
persistence backend (default: JSON file) and restores them on
application restart.
Args:
graph_backend: The graph index backend to persist.
persistence_backend: Backend for serialisation. Defaults to
:class:`InMemoryPersistenceBackend` (suitable for testing).
project: Namespaced project name.
"""
def __init__(
self,
graph_backend: GraphIndexBackend,
project: str,
persistence_backend: UKOPersistenceBackend | None = None,
) -> None:
if not isinstance(graph_backend, GraphIndexBackend):
raise TypeError(
"graph_backend must satisfy GraphIndexBackend, "
f"got {type(graph_backend).__name__}"
)
if not project or not project.strip():
raise ValueError("project must be a non-empty string")
self._graph_backend = graph_backend
self._project = project
self._persistence_backend: UKOPersistenceBackend = (
persistence_backend or InMemoryPersistenceBackend()
)
logger.debug(
"uko_persistence.service_initialized",
project=project,
)
@property
def project(self) -> str:
"""The namespaced project name."""
return self._project
def save(self) -> int:
"""Persist the current graph state.
Queries all triples from the graph backend and writes them to
the persistence backend.
Returns:
Number of triples persisted.
Raises:
OSError: If the persistence backend cannot be written.
"""
log = logger.bind(project=self._project)
log.info("uko_persistence.save.start")
try:
bindings = self._graph_backend.query(
self._project,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
)
except Exception as exc:
log.warning("uko_persistence.save.query_failed", error=str(exc))
return 0
triples = [
{
"subject": b.get("s", ""),
"predicate": b.get("p", ""),
"object": b.get("o", ""),
}
for b in bindings
if b.get("s") and b.get("p") and b.get("o")
]
self._persistence_backend.save(self._project, triples)
log.info(
"uko_persistence.save.done",
triple_count=len(triples),
)
return len(triples)
def restore(self) -> int:
"""Restore graph state from the persistence backend.
Loads persisted triples and re-inserts them into the graph
backend. Skips triples that fail validation.
Returns:
Number of triples restored.
"""
log = logger.bind(project=self._project)
log.info("uko_persistence.restore.start")
triples = self._persistence_backend.load(self._project)
if not triples:
log.info("uko_persistence.restore.no_data")
return 0
restored = 0
for triple in triples:
subject = triple.get("subject", "")
predicate = triple.get("predicate", "")
obj = triple.get("object", "")
if not subject or not predicate or not obj:
continue
try:
self._graph_backend.add_triple(
self._project,
subject,
predicate,
obj,
)
restored += 1
except Exception as exc:
log.warning(
"uko_persistence.restore.triple_failed",
subject=subject,
predicate=predicate,
error=str(exc),
)
log.info(
"uko_persistence.restore.done",
restored=restored,
total=len(triples),
)
return restored
@@ -0,0 +1,343 @@
"""UKO Query Interface — ACMS context strategy integration.
Provides :class:`UKOQueryInterface`, a service that allows ACMS context
strategies to query the UKO knowledge graph for resource classification
data. Strategies use this interface to discover which ontology layer a
resource belongs to, what type it is, and what relationships it has.
The four UKO ontology layers (per spec §185):
| Layer | Name | Description |
|-------|-------------|--------------------------------------------------|
| 0 | Universal | Foundation concepts (Resource, Container, etc.) |
| 1 | Domain | Software, documents, data schemas, infrastructure|
| 2 | Paradigm | OO, functional, procedural, markdown |
| 3 | Technology | Python, TypeScript, Rust, Java, PostgreSQL |
Based on ``docs/specification.md`` §185, §20568, §43924.
ISSUES CLOSED: #891
"""
from __future__ import annotations
from dataclasses import dataclass, field
import structlog
from cleveragents.domain.models.acms.index_backends import GraphIndexBackend
logger = structlog.get_logger(__name__)
__all__ = [
"ClassificationResult",
"UKOQueryInterface",
]
# ---------------------------------------------------------------------------
# Layer constants
# ---------------------------------------------------------------------------
#: Layer 0 — Universal foundation concepts.
LAYER_UNIVERSAL = 0
#: Layer 1 — Domain specializations (code, doc, data, infra).
LAYER_DOMAIN = 1
#: Layer 2 — Paradigm specializations (OO, functional, procedural).
LAYER_PARADIGM = 2
#: Layer 3 — Technology-specific (Python, TypeScript, Rust, Java).
LAYER_TECHNOLOGY = 3
# Predicate used to record the UKO layer of a resource.
_LAYER_PREDICATE = "uko:layer"
# Predicate used to record the primary type of a resource.
_TYPE_PREDICATE = "rdf:type"
# Predicate used to record the source resource ULID.
_SOURCE_PREDICATE = "uko:sourceResource"
# ---------------------------------------------------------------------------
# ClassificationResult
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ClassificationResult:
"""Classification data for a single resource from the UKO graph.
Attributes:
resource_id: ULID of the classified resource.
layer: Highest UKO layer populated for this resource (0-3).
primary_type: Primary UKO type URI (e.g. ``uko-py:Module``).
all_types: All UKO type URIs found for this resource.
implicit_relations: Inferred relationships (subject, predicate,
object) discovered during semantic analysis.
"""
resource_id: str
layer: int = LAYER_UNIVERSAL
primary_type: str = ""
all_types: tuple[str, ...] = field(default_factory=tuple)
implicit_relations: tuple[tuple[str, str, str], ...] = field(default_factory=tuple)
def __post_init__(self) -> None:
if not self.resource_id or not self.resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
if not (LAYER_UNIVERSAL <= self.layer <= LAYER_TECHNOLOGY):
raise ValueError(
f"layer must be between {LAYER_UNIVERSAL} and {LAYER_TECHNOLOGY}, "
f"got {self.layer}"
)
# ---------------------------------------------------------------------------
# UKOQueryInterface
# ---------------------------------------------------------------------------
class UKOQueryInterface:
"""Service for querying UKO classification data from the graph backend.
Provides ACMS context strategies with a typed interface to the UKO
knowledge graph. Strategies use this to discover resource types,
ontology layers, and implicit relationships without needing to
construct raw SPARQL queries.
Based on ``docs/specification.md`` §20568 — UKO classification
persists across sessions and feeds the ACMS context pipeline.
Args:
graph_backend: The graph index backend to query.
project: Namespaced project name (e.g. ``"local/my-app"``).
"""
def __init__(
self,
graph_backend: GraphIndexBackend,
project: str,
) -> None:
if not isinstance(graph_backend, GraphIndexBackend):
raise TypeError(
"graph_backend must satisfy GraphIndexBackend, "
f"got {type(graph_backend).__name__}"
)
if not project or not project.strip():
raise ValueError("project must be a non-empty string")
self._graph_backend = graph_backend
self._project = project
logger.debug(
"uko_query_interface.initialized",
project=project,
)
@property
def project(self) -> str:
"""The namespaced project name."""
return self._project
def classify_resource(self, resource_id: str) -> ClassificationResult:
"""Classify a resource by querying its UKO triples.
Queries the graph backend for all triples associated with the
given resource and extracts type, layer, and relationship data.
Args:
resource_id: ULID of the resource to classify.
Returns:
:class:`ClassificationResult` with classification data.
Returns a default result (layer 0, no types) if the resource
has not been indexed.
Raises:
ValueError: If *resource_id* is empty or whitespace-only.
"""
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
log = logger.bind(resource_id=resource_id, project=self._project)
log.debug("uko_query_interface.classify_resource")
resource_uri = f"uko://resource/{resource_id}"
# Query all triples where this resource is the source.
try:
bindings = self._graph_backend.query(
self._project,
f"SELECT ?s ?p ?o WHERE {{ ?s uko:sourceResource <{resource_uri}> }}",
)
except Exception as exc:
log.warning(
"uko_query_interface.query_failed",
error=str(exc),
)
return ClassificationResult(resource_id=resource_id)
# Extract types and layer from bindings.
types: list[str] = []
max_layer = LAYER_UNIVERSAL
implicit_relations: list[tuple[str, str, str]] = []
for binding in bindings:
predicate = binding.get("p", "")
obj = binding.get("o", "")
subject = binding.get("s", "")
if predicate == _TYPE_PREDICATE and obj:
types.append(obj)
# Infer layer from type URI prefix.
layer = _infer_layer_from_uri(obj)
if layer > max_layer:
max_layer = layer
elif predicate == _LAYER_PREDICATE and obj:
try:
layer_val = int(obj)
if (
LAYER_UNIVERSAL <= layer_val <= LAYER_TECHNOLOGY
and layer_val > max_layer
):
max_layer = layer_val
except ValueError:
pass
elif predicate.startswith("uko:implicit") and subject and obj:
implicit_relations.append((subject, predicate, obj))
primary_type = types[0] if types else ""
result = ClassificationResult(
resource_id=resource_id,
layer=max_layer,
primary_type=primary_type,
all_types=tuple(types),
implicit_relations=tuple(implicit_relations),
)
log.debug(
"uko_query_interface.classify_resource.done",
layer=max_layer,
type_count=len(types),
)
return result
def get_resources_by_layer(self, layer: int) -> list[str]:
"""Return resource IDs classified at the given ontology layer.
Args:
layer: UKO layer number (0-3).
Returns:
List of resource ULIDs at the given layer.
Raises:
ValueError: If *layer* is outside 0-3.
"""
if not (LAYER_UNIVERSAL <= layer <= LAYER_TECHNOLOGY):
raise ValueError(
f"layer must be between {LAYER_UNIVERSAL} and {LAYER_TECHNOLOGY}, "
f"got {layer}"
)
log = logger.bind(layer=layer, project=self._project)
log.debug("uko_query_interface.get_resources_by_layer")
try:
bindings = self._graph_backend.query(
self._project,
f'SELECT ?s ?o WHERE {{ ?s {_LAYER_PREDICATE} "{layer}" }}',
)
except Exception as exc:
log.warning(
"uko_query_interface.layer_query_failed",
error=str(exc),
)
return []
resource_ids: list[str] = []
for binding in bindings:
obj = binding.get("o", "")
# Extract resource ID from uko://resource/<id> URI.
if obj.startswith("uko://resource/"):
resource_ids.append(obj[len("uko://resource/") :])
log.debug(
"uko_query_interface.get_resources_by_layer.done",
count=len(resource_ids),
)
return resource_ids
def get_implicit_relations(
self,
resource_id: str,
) -> list[tuple[str, str, str]]:
"""Return implicit relationships inferred for a resource.
Args:
resource_id: ULID of the resource.
Returns:
List of (subject, predicate, object) tuples for implicit
relationships.
Raises:
ValueError: If *resource_id* is empty or whitespace-only.
"""
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
result = self.classify_resource(resource_id)
return list(result.implicit_relations)
# ---------------------------------------------------------------------------
# Layer inference helpers
# ---------------------------------------------------------------------------
# URI prefix → layer mapping (mirrors UKOLoader._LAYER_IRI_PREFIXES).
_LAYER_URI_PREFIXES: dict[str, int] = {
"https://cleveragents.ai/ontology/uko#": LAYER_UNIVERSAL,
"https://cleveragents.ai/ontology/uko/code#": LAYER_DOMAIN,
"https://cleveragents.ai/ontology/uko/doc#": LAYER_DOMAIN,
"https://cleveragents.ai/ontology/uko/data#": LAYER_DOMAIN,
"https://cleveragents.ai/ontology/uko/infra#": LAYER_DOMAIN,
"https://cleveragents.ai/ontology/uko/oo#": LAYER_PARADIGM,
"https://cleveragents.ai/ontology/uko/func#": LAYER_PARADIGM,
"https://cleveragents.ai/ontology/uko/proc#": LAYER_PARADIGM,
"https://cleveragents.ai/ontology/uko/py#": LAYER_TECHNOLOGY,
"https://cleveragents.ai/ontology/uko/ts#": LAYER_TECHNOLOGY,
"https://cleveragents.ai/ontology/uko/rs#": LAYER_TECHNOLOGY,
"https://cleveragents.ai/ontology/uko/java#": LAYER_TECHNOLOGY,
}
# Prefixed name → layer mapping.
_LAYER_PREFIXED_PREFIXES: dict[str, int] = {
"uko:": LAYER_UNIVERSAL,
"uko-code:": LAYER_DOMAIN,
"uko-doc:": LAYER_DOMAIN,
"uko-data:": LAYER_DOMAIN,
"uko-infra:": LAYER_DOMAIN,
"uko-oo:": LAYER_PARADIGM,
"uko-func:": LAYER_PARADIGM,
"uko-proc:": LAYER_PARADIGM,
"uko-py:": LAYER_TECHNOLOGY,
"uko-ts:": LAYER_TECHNOLOGY,
"uko-rs:": LAYER_TECHNOLOGY,
"uko-java:": LAYER_TECHNOLOGY,
}
def _infer_layer_from_uri(uri: str) -> int:
"""Infer the UKO layer from a type URI or prefixed name.
Returns the layer number (0-3), defaulting to 0 (universal) if
the URI does not match any known prefix.
"""
# Check full IRIs first.
for prefix, layer in _LAYER_URI_PREFIXES.items():
if uri.startswith(prefix):
return layer
# Check prefixed names.
for prefix, layer in _LAYER_PREFIXED_PREFIXES.items():
if uri.startswith(prefix):
return layer
return LAYER_UNIVERSAL