1e606553d4
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m32s
CI / integration_tests (pull_request) Successful in 3m18s
CI / docker (pull_request) Successful in 49s
CI / coverage (pull_request) Successful in 5m29s
CI / benchmark-regression (pull_request) Successful in 33m48s
Implement the UKOIndexer service that produces UKO triples from resources using pluggable domain-specific analyzers, wraps each triple with provenance metadata, and simultaneously indexes into text, vector, and graph backends. Key design decisions and components: - UKOIndexer orchestrates the full index lifecycle: add_resource, update_resource (remove-then-add), remove_resource, and maintenance triggers. Each operation fires lifecycle hooks (on_indexed, on_removed, on_error) so callers can observe progress. - Analyzer selection is pluggable via ContentAnalyzer protocol. The indexer accepts a registry mapping resource types to analyzers. PythonAnalyzer and MarkdownAnalyzer are provided as built-in implementations. - LocationContentReader protocol abstracts file I/O with a base_dir parameter for path-traversal prevention (post-resolve validation rejects paths escaping the base directory and non-regular files). - UKOTriple model includes a @model_validator ensuring at least one of object_uri or object_value is populated, preventing empty triples at construction time. - Triple removal uses scoped deletion via uko:sourceResource predicate to avoid shared-subject collision — only triples originating from the specific resource are removed, not all triples for a shared subject. - _resource_subjects.pop is deferred until after all backend removal operations succeed, preventing inconsistent state on partial failure. - analyzer.analyze() is wrapped in try/except so that analyzer errors produce an IndexResult with error details rather than propagating exceptions to callers. - All lifecycle hook calls are guarded via _fire_on_indexed, _fire_on_removed, and _fire_on_error helpers that catch and log hook exceptions without disrupting the indexing pipeline. - max_triples parameter (default 50,000) bounds analyzer output size to prevent runaway resource consumption. - ResourceFileWatcher monitors filesystem paths via watchdog and triggers re-indexing callbacks on file changes with configurable debouncing. Emits RESOURCE_MODIFIED domain events via EventBus when file changes are detected. Debounce timers coalesce rapid edits into a single callback invocation. Thread-safe design with daemon threads for clean shutdown. - SearchResult.__post_init__ validates score is in [0.0, 1.0], correctly rejecting NaN values. - Placeholder embedding uses [1.0] instead of [float(len(content))] to avoid leaking content size information. - isinstance check on graph_backend ensures GraphIndexBackend protocol compliance at runtime. - Test doubles extracted to features/mocks/uko_indexer_mocks.py for reuse across BDD steps and Robot helpers. Spec reference: Architecture > ACMS > Real-time Index Synchronization (specification.md lines ~43205-43300). ISSUES CLOSED: #578
473 lines
16 KiB
Python
473 lines
16 KiB
Python
"""Edge-case coverage steps for UKO Indexer behave tests.
|
|
|
|
Covers removal-failure paths, max_triples validation, reindex on fresh
|
|
resource, index_graph internals (empty obj, rdfs:label, provenance),
|
|
index_stubs edge cases, and index_backends protocol base methods.
|
|
|
|
All steps prefixed with ``idx`` to avoid AmbiguousStep collisions with
|
|
other feature files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from behave import given, then, when # type: ignore[import-untyped]
|
|
|
|
from cleveragents.application.services.uko_indexer import UKOIndexer
|
|
from cleveragents.domain.models.acms.analyzers import UKOTriple
|
|
from cleveragents.domain.models.acms.index_stubs import (
|
|
InMemoryGraphIndexBackend,
|
|
InMemoryTextIndexBackend,
|
|
InMemoryVectorIndexBackend,
|
|
)
|
|
from cleveragents.domain.models.acms.provenance import (
|
|
ProvenancedTriple,
|
|
ProvenanceMetadata,
|
|
)
|
|
from features.mocks.uko_indexer_mocks import (
|
|
RemovalFailingGraphBackend,
|
|
RemovalFailingTextBackend,
|
|
RemovalFailingVectorBackend,
|
|
SelectiveFailingGraphBackend,
|
|
)
|
|
from features.steps.uko_indexer_common import ULID_1, _make_resource
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Removal-failing backend steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("idx a UKOIndexer with removal-failing graph backend")
|
|
def step_indexer_removal_failing_graph(context: Any) -> None:
|
|
context.idx_indexer = UKOIndexer(
|
|
analyzer_registry=context.idx_registry,
|
|
graph_backend=RemovalFailingGraphBackend(),
|
|
text_backend=context.idx_text_backend,
|
|
vector_backend=context.idx_vector_backend,
|
|
content_reader=context.idx_content_reader,
|
|
)
|
|
|
|
|
|
@given("idx a UKOIndexer with removal-failing text backend")
|
|
def step_indexer_removal_failing_text(context: Any) -> None:
|
|
context.idx_indexer = UKOIndexer(
|
|
analyzer_registry=context.idx_registry,
|
|
graph_backend=context.idx_graph_backend,
|
|
text_backend=RemovalFailingTextBackend(),
|
|
vector_backend=context.idx_vector_backend,
|
|
content_reader=context.idx_content_reader,
|
|
)
|
|
|
|
|
|
@given("idx a UKOIndexer with removal-failing vector backend")
|
|
def step_indexer_removal_failing_vector(context: Any) -> None:
|
|
context.idx_indexer = UKOIndexer(
|
|
analyzer_registry=context.idx_registry,
|
|
graph_backend=context.idx_graph_backend,
|
|
text_backend=context.idx_text_backend,
|
|
vector_backend=RemovalFailingVectorBackend(),
|
|
content_reader=context.idx_content_reader,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# max_triples validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx creating a UKOIndexer with max_triples 0 should raise ValueError")
|
|
def step_max_triples_zero(context: Any) -> None:
|
|
from cleveragents.domain.models.acms.analyzers import AnalyzerRegistry
|
|
|
|
try:
|
|
UKOIndexer(
|
|
analyzer_registry=AnalyzerRegistry(),
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
max_triples=0,
|
|
)
|
|
assert False, "Expected ValueError" # noqa: B011
|
|
except ValueError as exc:
|
|
assert "positive" in str(exc).lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reindex_resource on fresh resource
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('idx I reindex resource "{rid}" in project "{project}"')
|
|
def step_reindex_resource(context: Any, rid: str, project: str) -> None:
|
|
resource = _make_resource(rid)
|
|
context.idx_result = context.idx_indexer.reindex_resource(
|
|
resource,
|
|
project=project,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# index_graph internals edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx index_graph should skip triples with empty object")
|
|
def step_index_graph_empty_obj(context: Any) -> None:
|
|
from cleveragents.application.services.uko_indexer_internals import index_graph
|
|
|
|
backend = InMemoryGraphIndexBackend()
|
|
# Use model_construct to bypass the validator that requires at
|
|
# least one of object_uri / object_value to be non-empty. This
|
|
# tests the defensive guard inside index_graph.
|
|
triple = UKOTriple.model_construct(
|
|
subject_uri="urn:x",
|
|
predicate="uko:name",
|
|
object_uri="",
|
|
object_value="",
|
|
confidence=1.0,
|
|
)
|
|
prov = ProvenanceMetadata(source_resource=ULID_1, source_path="f.py")
|
|
pt = ProvenancedTriple.model_construct(triple=triple, provenance=prov)
|
|
errors: list[str] = []
|
|
stored, subjects = index_graph(
|
|
backend,
|
|
"local/test",
|
|
_make_resource(ULID_1),
|
|
[pt],
|
|
errors,
|
|
"uko://resource/" + ULID_1,
|
|
)
|
|
assert stored == 0
|
|
assert len(subjects) == 0
|
|
|
|
|
|
@then("idx index_graph should tolerate rdfs:label failure")
|
|
def step_index_graph_label_failure(context: Any) -> None:
|
|
from cleveragents.application.services.uko_indexer_internals import index_graph
|
|
|
|
backend = SelectiveFailingGraphBackend(fail_on={"rdfs:label"})
|
|
triple = UKOTriple(
|
|
subject_uri="urn:x",
|
|
predicate="uko:name",
|
|
object_uri="urn:target",
|
|
object_value="Target Label",
|
|
)
|
|
pt = ProvenancedTriple(
|
|
triple=triple,
|
|
provenance=ProvenanceMetadata(source_resource=ULID_1, source_path="f.py"),
|
|
)
|
|
errors: list[str] = []
|
|
stored, _subjects = index_graph(
|
|
backend,
|
|
"local/test",
|
|
_make_resource(ULID_1),
|
|
[pt],
|
|
errors,
|
|
"uko://resource/" + ULID_1,
|
|
)
|
|
assert stored == 1
|
|
assert any("label" in e.lower() for e in errors)
|
|
|
|
|
|
@then("idx index_graph should tolerate provenance link failure")
|
|
def step_index_graph_provenance_failure(context: Any) -> None:
|
|
from cleveragents.application.services.uko_indexer_internals import index_graph
|
|
|
|
backend = SelectiveFailingGraphBackend(fail_on={"uko:sourceResource"})
|
|
triple = UKOTriple(
|
|
subject_uri="urn:x",
|
|
predicate="uko:name",
|
|
object_uri="urn:target",
|
|
)
|
|
pt = ProvenancedTriple(
|
|
triple=triple,
|
|
provenance=ProvenanceMetadata(source_resource=ULID_1, source_path="f.py"),
|
|
)
|
|
errors: list[str] = []
|
|
stored, _subjects = index_graph(
|
|
backend,
|
|
"local/test",
|
|
_make_resource(ULID_1),
|
|
[pt],
|
|
errors,
|
|
"uko://resource/" + ULID_1,
|
|
)
|
|
assert stored == 1
|
|
assert any("provenance" in e.lower() for e in errors)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# index_stubs edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx text search should stop at the limit")
|
|
def step_text_search_limit(context: Any) -> None:
|
|
backend = InMemoryTextIndexBackend()
|
|
backend.index_document("p", "d1", "hello world", {})
|
|
backend.index_document("p", "d2", "hello again", {})
|
|
backend.index_document("p", "d3", "hello third", {})
|
|
results = backend.search("p", "hello", limit=2)
|
|
assert len(results) == 2
|
|
|
|
|
|
@then("idx vector search_similar with empty embedding should raise ValueError")
|
|
def step_vector_empty_embedding(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend()
|
|
try:
|
|
backend.search_similar("p", [])
|
|
assert False, "Expected ValueError" # noqa: B011
|
|
except ValueError as exc:
|
|
assert "non-empty" in str(exc).lower()
|
|
|
|
|
|
@then("idx vector search_similar with limit 0 should raise ValueError")
|
|
def step_vector_zero_limit(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend()
|
|
try:
|
|
backend.search_similar("p", [1.0], limit=0)
|
|
assert False, "Expected ValueError" # noqa: B011
|
|
except ValueError as exc:
|
|
assert "positive" in str(exc).lower()
|
|
|
|
|
|
@then("idx vector search_similar filters by project")
|
|
def step_vector_project_filter(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend()
|
|
backend.index_embedding("proj_a", "d1", [1.0], {})
|
|
backend.index_embedding("proj_b", "d2", [1.0], {})
|
|
results = backend.search_similar("proj_a", [1.0], limit=10)
|
|
assert len(results) == 1
|
|
assert results[0].doc_id == "d1"
|
|
|
|
|
|
@then("idx graph remove_triples on missing project should be no-op")
|
|
def step_graph_remove_missing_project(context: Any) -> None:
|
|
backend = InMemoryGraphIndexBackend()
|
|
backend.remove_triples("nonexistent", subject="s", predicate=None, obj=None)
|
|
assert backend.triple_count() == 0
|
|
|
|
|
|
@then("idx graph triple_count without project should sum all projects")
|
|
def step_graph_triple_count_all(context: Any) -> None:
|
|
backend = InMemoryGraphIndexBackend()
|
|
backend.add_triple("p1", "s1", "pred", "o1")
|
|
backend.add_triple("p2", "s2", "pred", "o2")
|
|
assert backend.triple_count(project=None) == 2
|
|
assert backend.triple_count(project="p1") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# index_backends protocol base method coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx all index backend protocol base methods should be callable")
|
|
def step_protocol_base_methods(context: Any) -> None:
|
|
from cleveragents.domain.models.acms.index_backends import (
|
|
GraphIndexBackend,
|
|
TextIndexBackend,
|
|
VectorIndexBackend,
|
|
)
|
|
|
|
class _TextSub(TextIndexBackend):
|
|
pass
|
|
|
|
class _VectorSub(VectorIndexBackend):
|
|
pass
|
|
|
|
class _GraphSub(GraphIndexBackend):
|
|
pass
|
|
|
|
t = _TextSub() # type: ignore[abstract]
|
|
t.index_document("p", "d", "c", {})
|
|
t.search("p", "q", limit=1)
|
|
t.remove_document("p", "d")
|
|
t.rebuild_index("p")
|
|
|
|
v = _VectorSub() # type: ignore[abstract]
|
|
v.index_embedding("p", "d", [1.0], {})
|
|
v.search_similar("p", [1.0], limit=1)
|
|
v.remove_embedding("p", "d")
|
|
|
|
g = _GraphSub() # type: ignore[abstract]
|
|
g.add_triple("p", "s", "pred", "o")
|
|
g.query("p", "SELECT ?s WHERE {}")
|
|
g.remove_triples("p", subject="s", predicate=None, obj=None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# min_relevance filtering (P3-3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx vector search_similar with min_relevance above 1.0 raises ValueError")
|
|
def step_vector_min_relevance_above(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend()
|
|
backend.index_embedding("p", "d1", [1.0], {})
|
|
try:
|
|
backend.search_similar("p", [1.0], limit=10, min_relevance=1.1)
|
|
assert False, "Expected ValueError" # noqa: B011
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then("idx vector search_similar with min_relevance 1.0 includes exact matches")
|
|
def step_vector_min_relevance_exact(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend()
|
|
backend.index_embedding("p", "d1", [1.0], {})
|
|
results = backend.search_similar("p", [1.0], limit=10, min_relevance=1.0)
|
|
assert len(results) == 1
|
|
assert results[0].doc_id == "d1"
|
|
|
|
|
|
@then("idx vector search_similar with min_relevance 0.0 includes all matches")
|
|
def step_vector_min_relevance_zero(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend()
|
|
backend.index_embedding("p", "d1", [1.0], {})
|
|
backend.index_embedding("p", "d2", [2.0], {})
|
|
results = backend.search_similar("p", [1.0], limit=10, min_relevance=0.0)
|
|
assert len(results) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub size-limit enforcement (P2-16)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx text stub should raise RuntimeError when max_entries exceeded")
|
|
def step_text_stub_size_limit(context: Any) -> None:
|
|
backend = InMemoryTextIndexBackend(max_entries=2)
|
|
backend.index_document("p", "d1", "content1", {})
|
|
backend.index_document("p", "d2", "content2", {})
|
|
try:
|
|
backend.index_document("p", "d3", "content3", {})
|
|
assert False, "Expected RuntimeError" # noqa: B011
|
|
except RuntimeError as exc:
|
|
assert "max_entries" in str(exc)
|
|
|
|
|
|
@then("idx vector stub should raise RuntimeError when max_entries exceeded")
|
|
def step_vector_stub_size_limit(context: Any) -> None:
|
|
backend = InMemoryVectorIndexBackend(max_entries=2)
|
|
backend.index_embedding("p", "d1", [1.0], {})
|
|
backend.index_embedding("p", "d2", [2.0], {})
|
|
try:
|
|
backend.index_embedding("p", "d3", [3.0], {})
|
|
assert False, "Expected RuntimeError" # noqa: B011
|
|
except RuntimeError as exc:
|
|
assert "max_entries" in str(exc)
|
|
|
|
|
|
@then("idx graph stub should raise RuntimeError when max_entries exceeded")
|
|
def step_graph_stub_size_limit(context: Any) -> None:
|
|
backend = InMemoryGraphIndexBackend(max_entries=2)
|
|
backend.add_triple("p", "s1", "pred", "o1")
|
|
backend.add_triple("p", "s2", "pred", "o2")
|
|
try:
|
|
backend.add_triple("p", "s3", "pred", "o3")
|
|
assert False, "Expected RuntimeError" # noqa: B011
|
|
except RuntimeError as exc:
|
|
assert "max_entries" in str(exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provenance metadata persistence (P2-22, P2-27)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx index_graph should store provenance metadata triples")
|
|
def step_index_graph_provenance_metadata(context: Any) -> None:
|
|
from cleveragents.application.services.uko_indexer_internals import index_graph
|
|
|
|
backend = InMemoryGraphIndexBackend()
|
|
triple = UKOTriple(
|
|
subject_uri="urn:x",
|
|
predicate="uko:name",
|
|
object_uri="urn:target",
|
|
object_value="TargetLabel",
|
|
confidence=0.85,
|
|
)
|
|
prov = ProvenanceMetadata(
|
|
source_resource=ULID_1,
|
|
source_path="src/example.py",
|
|
source_range="10-25",
|
|
)
|
|
pt = ProvenancedTriple(triple=triple, provenance=prov)
|
|
errors: list[str] = []
|
|
stored, _subjects = index_graph(
|
|
backend,
|
|
"local/test",
|
|
_make_resource(ULID_1),
|
|
[pt],
|
|
errors,
|
|
"uko://resource/" + ULID_1,
|
|
)
|
|
assert stored == 1
|
|
|
|
# Query all triples for the project
|
|
bindings = backend.query("local/test", "SELECT * WHERE { ?s ?p ?o }")
|
|
|
|
# Build a predicate -> object map for the subject
|
|
preds = {b["p"]: b["o"] for b in bindings if b["s"] == "urn:x"}
|
|
|
|
# Data triple
|
|
assert preds.get("uko:name") == "urn:target"
|
|
# Provenance metadata
|
|
assert preds.get("uko:sourceResource") == "uko://resource/" + ULID_1
|
|
assert preds.get("uko:sourcePath") == "src/example.py"
|
|
assert preds.get("uko:sourceRange") == "10-25"
|
|
assert "uko:validFrom" in preds # ISO timestamp
|
|
assert preds.get("uko:isCurrent") == "true"
|
|
# Confidence < 1.0 should be stored
|
|
assert preds.get("uko:confidence") == "0.85"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Concurrency — thread-safe indexing (#58)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("idx concurrent indexing of different resources should be thread-safe")
|
|
def step_idx_concurrent_indexing(context: Any) -> None:
|
|
import threading
|
|
|
|
from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer
|
|
from features.mocks.uko_indexer_mocks import InMemoryContentReader
|
|
|
|
reg = context.idx_registry
|
|
reg.register(PythonAnalyzer(), priority=10)
|
|
reader = InMemoryContentReader()
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=reg,
|
|
graph_backend=context.idx_graph_backend,
|
|
text_backend=context.idx_text_backend,
|
|
vector_backend=context.idx_vector_backend,
|
|
content_reader=reader,
|
|
)
|
|
results: list[Any] = [None] * 4
|
|
errs: list[Exception] = []
|
|
|
|
rids = [f"01HQ8ZDRX5000000000000{i:04d}" for i in range(4)]
|
|
|
|
def _do(i: int) -> None:
|
|
rid = rids[i]
|
|
reader.set_content(rid, f"def f{i}(): pass\n")
|
|
try:
|
|
results[i] = indexer.index_resource(
|
|
_make_resource(rid, f"src/m{i}.py"),
|
|
project="local/app",
|
|
)
|
|
except Exception as e:
|
|
errs.append(e)
|
|
|
|
ts = [threading.Thread(target=_do, args=(i,)) for i in range(4)]
|
|
for t in ts:
|
|
t.start()
|
|
for t in ts:
|
|
t.join(timeout=10)
|
|
assert not errs, f"Concurrent errors: {errs}"
|
|
assert indexer.indexed_resource_count == 4
|