feat(acms): operationalize UKO runtime with provenance and temporal versioning
CI / build (pull_request) Successful in 16s
CI / helm (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m44s
CI / typecheck (pull_request) Successful in 4m0s
CI / security (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 9m12s
CI / docker (pull_request) Successful in 1m35s
CI / coverage (pull_request) Successful in 12m20s
CI / e2e_tests (pull_request) Successful in 19m58s
CI / integration_tests (pull_request) Successful in 24m40s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 55m41s
CI / build (pull_request) Successful in 16s
CI / helm (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m44s
CI / typecheck (pull_request) Successful in 4m0s
CI / security (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 9m12s
CI / docker (pull_request) Successful in 1m35s
CI / coverage (pull_request) Successful in 12m20s
CI / e2e_tests (pull_request) Successful in 19m58s
CI / integration_tests (pull_request) Successful in 24m40s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 55m41s
Implements the UKO runtime operationalization per issue #891: - UKO indexer now produces typed triples with provenance metadata (sourceResource, validFrom, isCurrent, sourcePath, sourceRange) - Temporal versioning (revision chain) already existed; wired into new BDD tests covering store_initial_node, create_revision, get_revision_chain, get_history, get_current, mark_historical - UKO query interface (UKOQueryInterface) wires UKO classification data into ACMS context strategies via classify_resource(), get_resources_by_layer(), and get_implicit_relations() - Four ontology layers (0=universal, 1=domain, 2=paradigm, 3=technology) are now populated during resource indexing via uko:layer triples on the resource URI - Implicit relationship inference (UKOInferenceEngine) runs during indexing to produce uko:implicitSiblingOf, uko:implicitContains, and uko:implicitDependsOn triples with confidence 0.7 - UKO graph persistence (UKOGraphPersistence) serialises graph state to JSON (JSONFilePersistenceBackend) or in-memory store (InMemoryPersistenceBackend) for cross-restart persistence - 47 new BDD scenarios in features/uko_runtime.feature covering all acceptance criteria; all existing UKO tests continue to pass ISSUES CLOSED: #891
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user