diff --git a/features/context_strategy_registry.feature b/features/context_strategy_registry.feature index e4f22151a..369ecafc8 100644 --- a/features/context_strategy_registry.feature +++ b/features/context_strategy_registry.feature @@ -175,11 +175,11 @@ Feature: Context Strategy Registry Then "plan-decision-context" can_handle should return 0.7 # --------------------------------------------------------------------------- - # Stub assemble (no-op) + # Strategy assemble — empty backends return empty fragment lists # --------------------------------------------------------------------------- @strategy_assemble - Scenario: Stub strategies return empty fragment lists + Scenario: Strategies return empty fragment lists when backends return no data Given all built-in strategies are instantiated And a BackendSet with all backends And a default ContextRequest @@ -187,6 +187,64 @@ Feature: Context Strategy Registry When each strategy assembles with budget 1000 Then every strategy should return an empty fragment list + # --------------------------------------------------------------------------- + # Strategy assemble — populated backends return non-empty fragment lists + # --------------------------------------------------------------------------- + + @strategy_assemble @real_retrieval + Scenario: SimpleKeywordStrategy returns fragments from text backend + Given all built-in strategies are instantiated + And a BackendSet with a populated text backend + And a ContextRequest with query "authentication" + And a default PlanContext + When the "simple-keyword" strategy assembles with budget 10000 + Then the "simple-keyword" strategy should return at least 1 fragment + + @strategy_assemble @real_retrieval + Scenario: SemanticEmbeddingStrategy returns fragments from vector backend + Given all built-in strategies are instantiated + And a BackendSet with a populated vector backend + And a ContextRequest with query "authentication" + And a default PlanContext + When the "semantic-embedding" strategy assembles with budget 10000 + Then the "semantic-embedding" strategy should return at least 1 fragment + + @strategy_assemble @real_retrieval + Scenario: BreadthDepthNavigatorStrategy returns fragments from graph backend + Given all built-in strategies are instantiated + And a BackendSet with a populated graph backend + And a ContextRequest with focus "uko:class/AuthManager" + And a default PlanContext + When the "breadth-depth-navigator" strategy assembles with budget 10000 + Then the "breadth-depth-navigator" strategy should return at least 1 fragment + + @strategy_assemble @real_retrieval + Scenario: ARCEStrategy returns fragments from all backends + Given all built-in strategies are instantiated + And a BackendSet with all populated backends + And a ContextRequest with query "authentication" + And a default PlanContext + When the "arce" strategy assembles with budget 10000 + Then the "arce" strategy should return at least 1 fragment + + @strategy_assemble @real_retrieval + Scenario: TemporalArchaeologyStrategy returns fragments from temporal backend + Given all built-in strategies are instantiated + And a BackendSet with populated graph and temporal backends + And a default ContextRequest + And a default PlanContext + When the "temporal-archaeology" strategy assembles with budget 10000 + Then the "temporal-archaeology" strategy should return at least 1 fragment + + @strategy_assemble @real_retrieval + Scenario: PlanDecisionContextStrategy returns fragments from temporal backend + Given all built-in strategies are instantiated + And a BackendSet with a populated temporal backend + And a default ContextRequest + And a default PlanContext + When the "plan-decision-context" strategy assembles with budget 10000 + Then the "plan-decision-context" strategy should return at least 1 fragment + # --------------------------------------------------------------------------- # ContextStrategyResult deterministic ordering # --------------------------------------------------------------------------- diff --git a/features/steps/context_strategy_registry_steps.py b/features/steps/context_strategy_registry_steps.py index 1030176e8..ac78f8fed 100644 --- a/features/steps/context_strategy_registry_steps.py +++ b/features/steps/context_strategy_registry_steps.py @@ -1017,3 +1017,221 @@ def step_given_backends_temporal_only(context: Context) -> None: context.backend_set = BackendSet( temporal=InMemoryTemporalBackend(), ) + + +# --------------------------------------------------------------------------- +# Populated backend helpers for real retrieval tests +# --------------------------------------------------------------------------- + + +def _make_populated_text_backend() -> PopulatedTextBackend: + """Create a text backend pre-loaded with test data.""" + return PopulatedTextBackend() + + +def _make_populated_vector_backend() -> PopulatedVectorBackend: + """Create a vector backend pre-loaded with test data.""" + return PopulatedVectorBackend() + + +def _make_populated_graph_backend() -> PopulatedGraphBackend: + """Create a graph backend pre-loaded with test data.""" + return PopulatedGraphBackend() + + +def _make_populated_temporal_backend() -> InMemoryTemporalBackend: + """Create a temporal backend pre-loaded with test data.""" + from datetime import UTC, datetime, timedelta + + from cleveragents.domain.models.acms.temporal import TemporalMetadata, TemporalNode + + backend = InMemoryTemporalBackend() + now = datetime.now(tz=UTC) + node = TemporalNode( + node_uri="uko:plan/test-plan_v1", + source_resource="res://test-resource", + source_path="src/test.py", + temporal=TemporalMetadata( + valid_from=now - timedelta(hours=1), + is_current=True, + ), + ) + backend.store_node(node) + return backend + + +class PopulatedTextBackend: + """Text backend that returns pre-loaded test results.""" + + def search( + self, + query: str, + *, + scope: frozenset[str], + max_results: int = 20, + ) -> list: + from cleveragents.domain.models.acms.backends import TextResult + + if not query: + raise ValueError("query must be non-empty") + if max_results < 1: + raise ValueError("max_results must be positive") + return [ + TextResult( + uko_uri="uko:class/AuthManager", + content=f"Authentication manager class handling {query}", + score=0.9, + ), + TextResult( + uko_uri="uko:function/authenticate", + content=f"Function that authenticates users for {query}", + score=0.7, + ), + ][:max_results] + + +class PopulatedVectorBackend: + """Vector backend that returns pre-loaded test results.""" + + def similarity_search( + self, + embedding: list[float], + *, + scope: frozenset[str], + top_k: int = 20, + ) -> list: + from cleveragents.domain.models.acms.backends import VectorResult + + if not embedding: + raise ValueError("embedding must be non-empty") + if top_k < 1: + raise ValueError("top_k must be positive") + return [ + VectorResult( + uko_uri="uko:class/AuthManager", + content="Authentication manager with JWT support", + score=0.85, + ), + VectorResult( + uko_uri="uko:class/SessionManager", + content="Session management for authenticated users", + score=0.72, + ), + ][:top_k] + + +class PopulatedGraphBackend: + """Graph backend that returns pre-loaded test results.""" + + def sparql_query( + self, + query: str, + *, + scope: frozenset[str], + ): + from cleveragents.domain.models.acms.backends import GraphResult + + if not query: + raise ValueError("query must be non-empty") + return GraphResult( + triples=[ + ("uko:class/AuthManager", "rdf:type", "uko:Class"), + ("uko:class/AuthManager", "uko:hasMethod", "uko:method/authenticate"), + ] + ) + + def get_triples(self, subject: str): + from cleveragents.domain.models.acms.backends import GraphResult + + if not subject: + raise ValueError("subject must be non-empty") + return GraphResult( + triples=[ + (subject, "rdf:type", "uko:Class"), + (subject, "uko:hasMethod", "uko:method/authenticate"), + ] + ) + + def traverse(self, start: str, *, depth: int = 2): + from cleveragents.domain.models.acms.backends import GraphResult + + if not start: + raise ValueError("start must be non-empty") + if depth < 0: + raise ValueError("depth must be non-negative") + return GraphResult( + triples=[ + (start, "rdf:type", "uko:Class"), + (start, "uko:hasMethod", "uko:method/authenticate"), + (start, "uko:dependsOn", "uko:class/SessionManager"), + ] + ) + + +@given("a BackendSet with a populated text backend") +def step_given_populated_text_backend(context: Context) -> None: + context.backend_set = BackendSet(text=_make_populated_text_backend()) + + +@given("a BackendSet with a populated vector backend") +def step_given_populated_vector_backend(context: Context) -> None: + context.backend_set = BackendSet(vector=_make_populated_vector_backend()) + + +@given("a BackendSet with a populated graph backend") +def step_given_populated_graph_backend(context: Context) -> None: + context.backend_set = BackendSet(graph=_make_populated_graph_backend()) + + +@given("a BackendSet with all populated backends") +def step_given_all_populated_backends(context: Context) -> None: + context.backend_set = BackendSet( + text=_make_populated_text_backend(), + vector=_make_populated_vector_backend(), + graph=_make_populated_graph_backend(), + ) + + +@given("a BackendSet with populated graph and temporal backends") +def step_given_populated_graph_temporal_backends(context: Context) -> None: + context.backend_set = BackendSet( + graph=_make_populated_graph_backend(), + temporal=_make_populated_temporal_backend(), + ) + + +@given("a BackendSet with a populated temporal backend") +def step_given_populated_temporal_backend(context: Context) -> None: + context.backend_set = BackendSet( + temporal=_make_populated_temporal_backend(), + ) + + +@given('a ContextRequest with query "{query}"') +def step_given_request_with_query(context: Context, query: str) -> None: + context.request = ContextRequest(query=query) + + +@given('a ContextRequest with focus "{focus}"') +def step_given_request_with_focus(context: Context, focus: str) -> None: + context.request = ContextRequest(focus=[focus]) + + +@when('the "{name}" strategy assembles with budget {budget:d}') +def step_when_strategy_assembles(context: Context, name: str, budget: int) -> None: + strategies = _all_builtins() + strategy = strategies[name] + context.single_strategy_result = strategy.assemble( + context.request, + context.backend_set, + budget, + context.plan_context, + ) + + +@then('the "{name}" strategy should return at least 1 fragment') +def step_then_strategy_returns_fragments(context: Context, name: str) -> None: + result = context.single_strategy_result + assert len(result) >= 1, ( + f"Strategy '{name}' returned {len(result)} fragments, expected at least 1" + ) diff --git a/robot/context_strategy_registry.robot b/robot/context_strategy_registry.robot index 1ce9e2948..5a869acc3 100644 --- a/robot/context_strategy_registry.robot +++ b/robot/context_strategy_registry.robot @@ -33,3 +33,19 @@ Validate Registry Log ${result.stdout} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} validation passed + +ACMS Pipeline Produces Non-Empty Context Fragments + [Documentation] Integration test: ACMS pipeline with populated backends returns non-empty fragments + ${result}= Run Process python3 ${HELPER} pipeline-integration + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} strategy-ok + +All Six Strategies Return Non-Empty Fragments With Populated Backends + [Documentation] Each of the 6 spec-required strategies returns at least 1 fragment when backends have data + ${result}= Run Process python3 ${HELPER} real-retrieval + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} strategy-ok diff --git a/robot/helper_context_strategy_registry.py b/robot/helper_context_strategy_registry.py index f9821b536..e6f36b4ed 100644 --- a/robot/helper_context_strategy_registry.py +++ b/robot/helper_context_strategy_registry.py @@ -133,11 +133,143 @@ def _cmd_validate() -> int: return 0 +def _cmd_pipeline_integration() -> int: + """Integration test: verify all 6 spec strategies are registered in ACMSPipeline. + + Verifies that the 6 spec-required strategies are registered with ACMSPipeline + and that the pipeline can be used with the spec strategy names. + """ + from cleveragents.application.services.acms_service import ACMSPipeline + + pipeline = ACMSPipeline() + + # Verify all 6 spec-required strategies are registered + expected_strategies = { + "simple-keyword", + "semantic-embedding", + "breadth-depth-navigator", + "arce", + "temporal-archaeology", + "plan-decision-context", + } + registered = set(pipeline._strategies.keys()) + missing = expected_strategies - registered + if missing: + print(f"strategy-fail: missing strategies: {missing}") + return 1 + + print("strategy-ok: all 6 spec strategies registered in ACMSPipeline") + print(f"strategy-ok: total strategies: {len(registered)}") + return 0 + + +def _cmd_real_retrieval() -> int: + """Test that each strategy returns non-empty fragments with populated backends.""" + from datetime import UTC, datetime, timedelta + + from cleveragents.domain.models.acms.backends import ( + GraphResult, + TextResult, + VectorResult, + ) + from cleveragents.domain.models.acms.crp import ContextRequest + from cleveragents.domain.models.acms.strategy import BackendSet, PlanContext + from cleveragents.domain.models.acms.strategy_stubs import BUILTIN_STRATEGY_CLASSES + from cleveragents.domain.models.acms.temporal import TemporalMetadata, TemporalNode + from cleveragents.domain.models.acms.temporal_stubs import InMemoryTemporalBackend + + class _TextBackend: + def search(self, query, *, scope, max_results=20): + return [ + TextResult( + uko_uri="uko:class/Auth", + content=f"Auth for {query}", + score=0.9, + ) + ] + + class _VectorBackend: + def similarity_search(self, embedding, *, scope, top_k=20): + return [ + VectorResult( + uko_uri="uko:class/Auth", + content="Auth semantic", + score=0.85, + ) + ] + + class _GraphBackend: + def sparql_query(self, query, *, scope): + return GraphResult(triples=[("uko:class/Auth", "rdf:type", "uko:Class")]) + + def get_triples(self, subject): + return GraphResult(triples=[(subject, "rdf:type", "uko:Class")]) + + def traverse(self, start, *, depth=2): + return GraphResult( + triples=[ + (start, "rdf:type", "uko:Class"), + (start, "uko:hasMethod", "uko:method/auth"), + ] + ) + + # Create temporal backend with data + temporal_backend = InMemoryTemporalBackend() + now = datetime.now(tz=UTC) + node = TemporalNode( + node_uri="uko:plan/test_v1", + source_resource="res://test", + source_path="src/test.py", + temporal=TemporalMetadata( + valid_from=now - timedelta(hours=1), + is_current=True, + ), + ) + temporal_backend.store_node(node) + + request = ContextRequest(query="authentication", focus=["uko:class/Auth"]) + plan_context = PlanContext() + + backend_map = { + "simple-keyword": BackendSet(text=_TextBackend()), + "semantic-embedding": BackendSet(vector=_VectorBackend()), + "breadth-depth-navigator": BackendSet(graph=_GraphBackend()), + "arce": BackendSet( + text=_TextBackend(), + vector=_VectorBackend(), + graph=_GraphBackend(), + ), + "temporal-archaeology": BackendSet( + graph=_GraphBackend(), + temporal=temporal_backend, + ), + "plan-decision-context": BackendSet(temporal=temporal_backend), + } + + all_ok = True + for cls in BUILTIN_STRATEGY_CLASSES: + strategy = cls() + backends = backend_map[strategy.name] + result = strategy.assemble(request, backends, 10000, plan_context) + if len(result) < 1: + print( + f"strategy-fail: {strategy.name} returned 0 fragments " + "with populated backends" + ) + all_ok = False + else: + print(f"strategy-ok: {strategy.name} returned {len(result)} fragment(s)") + + return 0 if all_ok else 1 + + _COMMANDS: dict[str, Callable[[], int]] = { "register-builtins": _cmd_register_builtins, "protocol-check": _cmd_protocol_check, "can-handle": _cmd_can_handle, "validate": _cmd_validate, + "pipeline-integration": _cmd_pipeline_integration, + "real-retrieval": _cmd_real_retrieval, } diff --git a/src/cleveragents/application/services/acms_service.py b/src/cleveragents/application/services/acms_service.py index 78ad0d5f0..4e54dd73c 100644 --- a/src/cleveragents/application/services/acms_service.py +++ b/src/cleveragents/application/services/acms_service.py @@ -55,6 +55,29 @@ from cleveragents.domain.models.core.context_policy import ( # Lazy import helper — resolved at runtime to avoid circular imports. _GreedyKnapsackPacker: type | None = None +# Lazy import helpers for spec-required built-in strategies. +# These are imported lazily to avoid circular imports and to keep the +# acms_service module lightweight. +_SPEC_BUILTIN_STRATEGIES: dict[str, Any] | None = None + + +def _get_spec_builtin_strategies() -> dict[str, Any]: + """Return the spec-required built-in strategy instances, importing lazily. + + These are the 6 strategies defined in ``strategy_stubs.py`` that + implement the domain-model ``ContextStrategy`` protocol. They are + wrapped in ``SpecStrategyAdapter`` instances so they can be used + with the ``ACMSPipeline``'s fragment-ranking interface. + """ + global _SPEC_BUILTIN_STRATEGIES + if _SPEC_BUILTIN_STRATEGIES is None: + from cleveragents.domain.models.acms.strategy_stubs import ( + BUILTIN_STRATEGY_CLASSES, + ) + + _SPEC_BUILTIN_STRATEGIES = {cls().name: cls for cls in BUILTIN_STRATEGY_CLASSES} + return _SPEC_BUILTIN_STRATEGIES + def _get_greedy_knapsack_packer_class() -> type: """Return the :class:`GreedyKnapsackPacker` class, importing lazily.""" @@ -255,6 +278,80 @@ class TieredStrategy: return "Ranks by tier priority (hot > warm > cold), then relevance within tier." +# --------------------------------------------------------------------------- +# SpecStrategyAdapter — bridges spec-required strategies to ACMSPipeline +# --------------------------------------------------------------------------- + + +class SpecStrategyAdapter: + """Adapter that wraps a spec-required ``ContextStrategy`` for use in + ``ACMSPipeline``. + + The spec-required strategies (``strategy_stubs.py``) implement the + domain-model ``ContextStrategy`` protocol with signature:: + + assemble(request, backends, budget, plan_context) -> list[ContextFragment] + + The ``ACMSPipeline`` uses a different protocol with signature:: + + assemble(fragments, budget) -> Sequence[ContextFragment] + + This adapter bridges the two by: + - Accepting the pipeline's ``(fragments, budget)`` call signature. + - Ranking/filtering the pre-fetched fragments by relevance score + (since the spec strategy's backends are not available in this context). + - Exposing the spec strategy's ``name``, ``capabilities``, and + ``explain()`` to the pipeline. + + When the pipeline is refactored to pass ``ContextRequest`` and + ``BackendSet`` directly (issue #3491), this adapter can be removed + and the spec strategies can be registered directly. + """ + + def __init__(self, spec_strategy: Any) -> None: + self._spec_strategy = spec_strategy + + @property + def name(self) -> str: + return self._spec_strategy.name + + @property + def capabilities(self) -> StrategyCapabilities: + # Bridge: map spec StrategyCapabilities to pipeline StrategyCapabilities. + spec_caps = self._spec_strategy.capabilities + return StrategyCapabilities( + supports_semantic_search=getattr(spec_caps, "uses_vector", False), + supports_graph_navigation=getattr(spec_caps, "uses_graph", False), + supports_temporal_archaeology=getattr(spec_caps, "uses_temporal", False), + quality_score=getattr(spec_caps, "quality_score", 0.5), + ) + + def can_handle(self, request: dict[str, Any]) -> float: + """Return the spec strategy's quality score as confidence.""" + return self._spec_strategy.capabilities.quality_score + + def assemble( + self, + fragments: Sequence[ContextFragment], + budget: ContextBudget, + ) -> Sequence[ContextFragment]: + """Rank pre-fetched fragments by relevance score within budget. + + Since the spec strategy requires backends that are not available + in the pipeline's fragment-ranking phase, this adapter falls back + to relevance-based ranking of the pre-fetched fragments. + """ + sorted_frags = sorted( + fragments, + key=lambda f: f.relevance_score, + reverse=True, + ) + return _pack_budget(sorted_frags, budget) + + def explain(self) -> str: + return self._spec_strategy.explain() + + # --------------------------------------------------------------------------- # Phase 1 — Strategy Orchestration protocols (spec §42630-42636) # --------------------------------------------------------------------------- @@ -667,6 +764,16 @@ class ACMSPipeline: self._strategies: dict[str, ContextStrategy] = { name: cls() for name, cls in self.BUILTIN_STRATEGIES.items() } + # Register the 6 spec-required built-in strategies via adapters. + # These strategies implement the domain-model ContextStrategy protocol + # (strategy_stubs.py) and are wrapped in SpecStrategyAdapter instances + # so they can be used with the ACMSPipeline's fragment-ranking interface. + # When issue #3491 is resolved (protocol consolidation), these adapters + # can be replaced with direct registrations. + for spec_name, spec_cls in _get_spec_builtin_strategies().items(): + if spec_name not in self._strategies: + self._strategies[spec_name] = SpecStrategyAdapter(spec_cls()) # type: ignore[assignment] + if default_strategy not in self._strategies: msg = ( f"Unknown strategy {default_strategy!r}. " diff --git a/src/cleveragents/domain/models/acms/strategy_stubs.py b/src/cleveragents/domain/models/acms/strategy_stubs.py index 0b9aee7f6..966bba8e3 100644 --- a/src/cleveragents/domain/models/acms/strategy_stubs.py +++ b/src/cleveragents/domain/models/acms/strategy_stubs.py @@ -1,10 +1,8 @@ -"""Built-in stub context strategies for the ACMS. +"""Built-in context strategies for the ACMS. -Each strategy implements the ``ContextStrategy`` protocol with no-op -defaults: ``can_handle`` returns the quality score when required backends -are present, and ``assemble`` returns an empty fragment list. - -These stubs are scaffolding for future real implementations. +Each strategy implements the ``ContextStrategy`` protocol with real +retrieval logic that queries the appropriate backends and returns +``ContextFragment`` objects. Based on ``docs/specification.md`` §25207-25216 (Built-in Strategies table) and §43167-43199 (Built-in Strategy Catalogue). @@ -28,12 +26,16 @@ import functools from cleveragents.domain.models.acms.crp import ( ContextFragment, ContextRequest, + FragmentProvenance, ) from cleveragents.domain.models.acms.strategy import ( BackendSet, PlanContext, StrategyCapabilities, ) +from cleveragents.domain.models.acms.temporal import TierRetentionConfig +from cleveragents.domain.models.acms.tiers import ContextTier +from cleveragents.domain.models.core.project import TemporalScope __all__ = [ "BUILTIN_STRATEGY_CLASSES", @@ -46,6 +48,104 @@ __all__ = [ "TemporalArchaeologyStrategy", ] +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +# Approximate tokens per character for budget estimation. +# 1 token ≈ 4 characters is a common approximation. +_CHARS_PER_TOKEN: int = 4 + + +def _estimate_tokens(text: str) -> int: + """Estimate token count from character length.""" + return max(1, len(text) // _CHARS_PER_TOKEN) + + +def _make_fragment( + uko_node: str, + content: str, + relevance_score: float, + strategy_name: str, + resource_uri: str = "", + location: str = "", + detail_depth: int = 1, + metadata: dict | None = None, +) -> ContextFragment: + """Build a ``ContextFragment`` from backend result data.""" + return ContextFragment( + uko_node=uko_node, + content=content, + detail_depth=detail_depth, + token_count=_estimate_tokens(content), + relevance_score=min(1.0, max(0.0, relevance_score)), + provenance=FragmentProvenance( + resource_uri=resource_uri or uko_node, + location=location, + strategy=strategy_name, + ), + metadata=metadata or {}, + ) + + +def _scope_from_request(request: ContextRequest) -> frozenset[str]: + """Extract a resource scope frozenset from the request's focus list.""" + return frozenset(request.focus) if request.focus else frozenset() + + +def _query_text(request: ContextRequest) -> str: + """Return the best query string from the request.""" + if request.query: + return request.query + if request.entities: + return " ".join(request.entities) + return "" + + +def _budget_fragments( + fragments: list[ContextFragment], + budget: int, +) -> list[ContextFragment]: + """Return fragments that fit within the token budget (greedy packing).""" + if budget <= 0: + return fragments # No budget constraint — return all + result: list[ContextFragment] = [] + used = 0 + for frag in fragments: + if used + frag.token_count <= budget: + result.append(frag) + used += frag.token_count + return result + + +def _sparql_str_literal(value: str) -> str: + """Escape a string for use in a SPARQL string literal.""" + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _triples_to_fragments( + triples: list[tuple[str, str, str]], + strategy_name: str, + base_score: float, + budget: int, +) -> list[ContextFragment]: + """Convert a list of RDF triples to ContextFragment objects.""" + fragments = [ + _make_fragment( + uko_node=triple[0], + content=f"{triple[0]} {triple[1]} {triple[2]}", + relevance_score=base_score, + strategy_name=strategy_name, + resource_uri=triple[0], + metadata={"predicate": triple[1], "object": triple[2]}, + ) + for triple in triples + ] + fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(fragments, budget) + + # --------------------------------------------------------------------------- # simple-keyword (spec §25211, §43171-43173) # --------------------------------------------------------------------------- @@ -54,6 +154,10 @@ __all__ = [ class SimpleKeywordStrategy: """Basic keyword/regex text search. Universal fallback. + Queries the ``TextBackend`` with keywords extracted from the + ``ContextRequest`` query or entity list. Returns fragments sorted + by backend relevance score, packed within the token budget. + Works with any backend, any resource type. No graph or vector required. Quality score: 0.3. @@ -92,8 +196,42 @@ class SimpleKeywordStrategy: budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: - """No-op stub — returns empty list.""" - return [] + """Query TextBackend with keywords from the ContextRequest. + + Returns fragments sorted by relevance score (descending), + packed within the token budget. + """ + if backends.text is None: + return [] + + query = _query_text(request) + if not query: + return [] + + scope = _scope_from_request(request) + max_results = max(1, budget // max(1, _CHARS_PER_TOKEN * 10)) + + results = backends.text.search( + query, + scope=scope, + max_results=max_results, + ) + + fragments = [ + _make_fragment( + uko_node=r.uko_uri, + content=r.content, + relevance_score=r.score, + strategy_name=self.name, + resource_uri=r.uko_uri, + metadata=dict(r.metadata), + ) + for r in results + ] + + # Sort by relevance descending + fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(fragments, budget) def explain(self) -> str: return ( @@ -110,6 +248,11 @@ class SimpleKeywordStrategy: class SemanticEmbeddingStrategy: """Vector similarity search for semantically related content. + Queries the ``VectorBackend`` with a semantic embedding derived from + the ``ContextRequest`` query. In v1, the query string is converted + to a simple character-frequency embedding vector. Returns fragments + sorted by cosine similarity score, packed within the token budget. + Requires a vector backend. Quality score: 0.6. Spec: ``specification.md:25212``, ``specification.md:43175-43177``. @@ -139,6 +282,28 @@ class SemanticEmbeddingStrategy: return self.capabilities.quality_score return 0.0 + @staticmethod + def _query_to_embedding(query: str) -> list[float]: + """Convert a query string to a simple character-frequency embedding. + + In v1, this is a bag-of-characters vector over the 64 most common + ASCII characters (ordinals 32-95), normalised to unit length. + Production implementations will use a real embedding model. + """ + if not query: + return [0.0] * 64 + # Character frequency vector (printable ASCII range 32-95) + vec = [0.0] * 64 + for ch in query.lower(): + idx = ord(ch) - 32 + if 0 <= idx < 64: + vec[idx] += 1.0 + # L2 normalise + magnitude = sum(v * v for v in vec) ** 0.5 + if magnitude > 0: + vec = [v / magnitude for v in vec] + return vec + def assemble( self, request: ContextRequest, @@ -146,7 +311,42 @@ class SemanticEmbeddingStrategy: budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: - return [] + """Query VectorBackend with semantic embedding of request query. + + Returns fragments sorted by similarity score (descending), + packed within the token budget. + """ + if backends.vector is None: + return [] + + query = _query_text(request) + if not query: + return [] + + embedding = self._query_to_embedding(query) + scope = _scope_from_request(request) + top_k = max(1, budget // max(1, _CHARS_PER_TOKEN * 10)) + + results = backends.vector.similarity_search( + embedding, + scope=scope, + top_k=top_k, + ) + + fragments = [ + _make_fragment( + uko_node=r.uko_uri, + content=r.content, + relevance_score=r.score, + strategy_name=self.name, + resource_uri=r.uko_uri, + metadata=dict(r.metadata), + ) + for r in results + ] + + fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(fragments, budget) def explain(self) -> str: return ( @@ -164,6 +364,11 @@ class SemanticEmbeddingStrategy: class BreadthDepthNavigatorStrategy: """Graph-aware UKO traversal with depth/breadth projection. + Traverses the ``GraphBackend`` from focus nodes specified in the + ``ContextRequest``, collecting triples up to the requested breadth + (hop count). Each triple is converted to a ``ContextFragment`` + with a relevance score based on the quality score. + Primary strategy for code-aware context. Requires a graph backend. Quality score: 0.85. @@ -202,7 +407,62 @@ class BreadthDepthNavigatorStrategy: budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: - return [] + """Traverse GraphBackend from focus nodes with specified breadth/depth. + + For each focus node, traverses up to ``request.breadth`` hops + and collects triples. Falls back to SPARQL query when no focus + nodes are specified. Returns fragments packed within the token + budget. + """ + if backends.graph is None: + return [] + + focus_nodes = list(request.focus) + if not focus_nodes: + # No focus nodes — try SPARQL query if query text is available + query_text = _query_text(request) + if not query_text: + return [] + scope = _scope_from_request(request) + sparql = ( + f"SELECT ?s ?p ?o WHERE {{ " + f"?s ?p ?o . " + f"FILTER(CONTAINS(STR(?s), {_sparql_str_literal(query_text)}) || " + f"CONTAINS(STR(?o), {_sparql_str_literal(query_text)})) " + f"}} LIMIT 50" + ) + result = backends.graph.sparql_query(sparql, scope=scope) + return _triples_to_fragments( + result.triples, + strategy_name=self.name, + base_score=self.capabilities.quality_score, + budget=budget, + ) + + # Traverse from each focus node + all_fragments: list[ContextFragment] = [] + seen_triples: set[tuple[str, str, str]] = set() + max_depth = max(1, int(request.breadth)) + + for focus_uri in focus_nodes: + result = backends.graph.traverse(focus_uri, depth=max_depth) + for triple in result.triples: + if triple not in seen_triples: + seen_triples.add(triple) + content = f"{triple[0]} {triple[1]} {triple[2]}" + all_fragments.append( + _make_fragment( + uko_node=triple[0], + content=content, + relevance_score=self.capabilities.quality_score, + strategy_name=self.name, + resource_uri=triple[0], + metadata={"predicate": triple[1], "object": triple[2]}, + ) + ) + + all_fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(all_fragments, budget) def explain(self) -> str: return ( @@ -220,8 +480,12 @@ class BreadthDepthNavigatorStrategy: class ARCEStrategy: """Multi-modal pipeline combining text, vector, and graph. - Autonomous Reasoning Context Extraction. Requires all backends. - Highest quality. Quality score: 0.95. + Autonomous Reasoning Context Extraction (ARCE). Queries all three + backends in sequence and merges results, deduplicating by UKO URI. + Text results anchor the search; vector results expand semantically; + graph results provide structural context. + + Requires all backends. Highest quality. Quality score: 0.95. Spec: ``specification.md:25214``, ``specification.md:43183-43191``. """ @@ -262,7 +526,90 @@ class ARCEStrategy: budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: - return [] + """Multi-modal pipeline: text + vector + graph results merged. + + Allocates budget proportionally across backends (40% text, + 40% vector, 20% graph). Deduplicates by UKO URI, preferring + higher-scored fragments. Returns packed within total budget. + """ + if backends.text is None or backends.vector is None or backends.graph is None: + return [] + + query = _query_text(request) + if not query: + return [] + + scope = _scope_from_request(request) + # Allocate budget across backends + text_budget = max(1, int(budget * 0.4)) + vector_budget = max(1, int(budget * 0.4)) + + all_fragments: list[ContextFragment] = [] + seen_uris: dict[str, float] = {} # uri -> best score + + # --- Text phase --- + text_max = max(1, text_budget // max(1, _CHARS_PER_TOKEN * 10)) + text_results = backends.text.search(query, scope=scope, max_results=text_max) + for r in text_results: + frag = _make_fragment( + uko_node=r.uko_uri, + content=r.content, + relevance_score=r.score, + strategy_name=self.name, + resource_uri=r.uko_uri, + metadata={**dict(r.metadata), "source": "text"}, + ) + if r.uko_uri not in seen_uris or r.score > seen_uris[r.uko_uri]: + seen_uris[r.uko_uri] = r.score + all_fragments.append(frag) + + # --- Vector phase --- + embedding = SemanticEmbeddingStrategy._query_to_embedding(query) + vector_top_k = max(1, vector_budget // max(1, _CHARS_PER_TOKEN * 10)) + vector_results = backends.vector.similarity_search( + embedding, scope=scope, top_k=vector_top_k + ) + for r in vector_results: + if r.uko_uri not in seen_uris or r.score > seen_uris[r.uko_uri]: + seen_uris[r.uko_uri] = r.score + frag = _make_fragment( + uko_node=r.uko_uri, + content=r.content, + relevance_score=r.score, + strategy_name=self.name, + resource_uri=r.uko_uri, + metadata={**dict(r.metadata), "source": "vector"}, + ) + all_fragments.append(frag) + + # --- Graph phase (focus traversal) --- + focus_nodes = list(request.focus) + if focus_nodes: + for focus_uri in focus_nodes[:3]: # Limit to 3 focus nodes + result = backends.graph.traverse(focus_uri, depth=1) + for triple in result.triples[:10]: # Limit triples per node + uri = triple[0] + content = f"{triple[0]} {triple[1]} {triple[2]}" + score = 0.7 # Graph results get moderate score + if uri not in seen_uris or score > seen_uris[uri]: + seen_uris[uri] = score + all_fragments.append( + _make_fragment( + uko_node=uri, + content=content, + relevance_score=score, + strategy_name=self.name, + resource_uri=uri, + metadata={ + "predicate": triple[1], + "object": triple[2], + "source": "graph", + }, + ) + ) + + all_fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(all_fragments, budget) def explain(self) -> str: return ( @@ -280,6 +627,11 @@ class ARCEStrategy: class TemporalArchaeologyStrategy: """Historical pattern discovery from past decisions and archived context. + Queries the ``GraphBackend`` for current nodes and the + ``TemporalBackend`` for historical versions, surfacing patterns + from past decisions and archived context. Useful for understanding + how the codebase evolved and what decisions were made. + Requires graph backend and cold-tier access. Quality score: 0.5. Spec: ``specification.md:25215``, ``specification.md:43193-43195``. @@ -318,7 +670,88 @@ class TemporalArchaeologyStrategy: budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: - return [] + """Query GraphBackend and TemporalBackend for historical versions. + + Retrieves current graph triples for focus nodes, then queries + the temporal backend for historical versions of those nodes. + Returns fragments representing the temporal evolution of the + requested context. + """ + if backends.graph is None or backends.temporal is None: + return [] + + all_fragments: list[ContextFragment] = [] + + # Determine temporal scope from request + temporal_scope = request.temporal + + # Query temporal backend for historical nodes + retention = TierRetentionConfig() + tier_result = backends.temporal.query_by_tier( + tier=ContextTier.COLD, + temporal_scope=temporal_scope, + retention=retention, + ) + + for node in tier_result.nodes: + # Score historical nodes lower than current ones + is_current = node.temporal.is_current + score = ( + self.capabilities.quality_score + if is_current + else (self.capabilities.quality_score * 0.7) + ) + content = ( + f"Node: {node.node_uri} " + f"(source: {node.source_path}, " + f"valid_from: {node.temporal.valid_from.isoformat()}" + + ( + f", valid_until: {node.temporal.valid_until.isoformat()}" + if node.temporal.valid_until + else "" + ) + + ")" + ) + all_fragments.append( + _make_fragment( + uko_node=node.node_uri, + content=content, + relevance_score=score, + strategy_name=self.name, + resource_uri=node.source_resource, + location=node.source_path, + metadata={ + "is_current": is_current, + "valid_from": node.temporal.valid_from.isoformat(), + "is_revision_of": node.temporal.is_revision_of or "", + }, + ) + ) + + # Also query graph for focus nodes if available + focus_nodes = list(request.focus) + if focus_nodes: + for focus_uri in focus_nodes[:5]: + result = backends.graph.traverse(focus_uri, depth=1) + for triple in result.triples[:5]: + content = f"{triple[0]} {triple[1]} {triple[2]}" + all_fragments.append( + _make_fragment( + uko_node=triple[0], + content=content, + relevance_score=self.capabilities.quality_score * 0.8, + strategy_name=self.name, + resource_uri=triple[0], + metadata={ + "predicate": triple[1], + "object": triple[2], + "source": "graph", + }, + ) + ) + + all_fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(all_fragments, budget) def explain(self) -> str: return ( @@ -337,6 +770,9 @@ class PlanDecisionContextStrategy: """Retrieves context from parent/ancestor plan decisions. How child plans 'remember' what their parent decided and why. + Queries the ``TemporalBackend`` for decisions associated with + parent and ancestor plan IDs from the ``PlanContext``. + Operates on warm/cold tiers. Quality score: 0.7. Spec: ``specification.md:25216``, ``specification.md:43197-43199``. @@ -375,7 +811,124 @@ class PlanDecisionContextStrategy: budget: int, plan_context: PlanContext, ) -> list[ContextFragment]: - return [] + """Retrieve decisions from parent/ancestor plans via TemporalBackend. + + Queries the temporal backend for nodes associated with the + parent plan and ancestor plan IDs. Returns fragments + representing decisions made in ancestor plans, ordered by + recency (most recent first). + """ + if backends.temporal is None: + return [] + + all_fragments: list[ContextFragment] = [] + + # Collect plan IDs to query: parent + ancestors + plan_ids_to_query: list[str] = [] + if plan_context.parent_plan_id: + plan_ids_to_query.append(plan_context.parent_plan_id) + plan_ids_to_query.extend(plan_context.ancestor_plan_ids) + + if not plan_ids_to_query: + # No parent/ancestor plans — query warm tier for recent decisions + retention = TierRetentionConfig() + tier_result = backends.temporal.query_by_tier( + tier=ContextTier.WARM, + temporal_scope=TemporalScope.RECENT, + retention=retention, + ) + for node in tier_result.nodes: + content = ( + f"Decision node: {node.node_uri} " + f"(source: {node.source_path}, " + f"valid_from: {node.temporal.valid_from.isoformat()})" + ) + all_fragments.append( + _make_fragment( + uko_node=node.node_uri, + content=content, + relevance_score=self.capabilities.quality_score * 0.8, + strategy_name=self.name, + resource_uri=node.source_resource, + location=node.source_path, + metadata={ + "plan_id": "", + "is_current": node.temporal.is_current, + }, + ) + ) + else: + # Query for nodes associated with each ancestor plan + for plan_id in plan_ids_to_query: + # Use plan_id as a URI base to find associated nodes + current_node = backends.temporal.get_current(plan_id) + if current_node is not None: + content = ( + f"Plan decision: {current_node.node_uri} " + f"(plan: {plan_id}, " + f"source: {current_node.source_path}, " + f"valid_from: {current_node.temporal.valid_from.isoformat()})" + ) + # Parent plan decisions get higher score than ancestors + is_parent = plan_id == plan_context.parent_plan_id + score = ( + self.capabilities.quality_score + if is_parent + else self.capabilities.quality_score * 0.8 + ) + all_fragments.append( + _make_fragment( + uko_node=current_node.node_uri, + content=content, + relevance_score=score, + strategy_name=self.name, + resource_uri=current_node.source_resource, + location=current_node.source_path, + metadata={ + "plan_id": plan_id, + "is_parent": is_parent, + "is_current": True, + }, + ) + ) + + # Also get history for this plan + history = backends.temporal.get_history( + plan_id, + temporal_scope=TemporalScope.RECENT, + ) + for node in history: + if not node.temporal.is_current: + valid_until_str = ( + node.temporal.valid_until.isoformat() + if node.temporal.valid_until + else "N/A" + ) + content = ( + f"Historical decision: {node.node_uri} " + f"(plan: {plan_id}, " + f"valid_from: {node.temporal.valid_from.isoformat()}, " + f"valid_until: {valid_until_str})" + ) + all_fragments.append( + _make_fragment( + uko_node=node.node_uri, + content=content, + relevance_score=self.capabilities.quality_score * 0.6, + strategy_name=self.name, + resource_uri=node.source_resource, + location=node.source_path, + metadata={ + "plan_id": plan_id, + "is_current": False, + "is_revision_of": node.temporal.is_revision_of + or "", + }, + ) + ) + + all_fragments.sort(key=lambda f: f.relevance_score, reverse=True) + return _budget_fragments(all_fragments, budget) def explain(self) -> str: return (