af9db5ea28
CI / lint (pull_request) Successful in 32s
CI / quality (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 46s
CI / build (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 35s
CI / security (pull_request) Successful in 1m19s
CI / unit_tests (pull_request) Failing after 6m45s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 15m36s
CI / coverage (pull_request) Successful in 10m43s
CI / integration_tests (pull_request) Failing after 23m29s
CI / status-check (pull_request) Failing after 3s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m58s
1238 lines
41 KiB
Python
1238 lines
41 KiB
Python
"""Behave step implementations for context strategy registry feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import MappingProxyType
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from pydantic import ValidationError
|
|
|
|
from cleveragents.application.services.strategy_registry import (
|
|
StrategyNotFoundError,
|
|
StrategyRegistrationError,
|
|
StrategyRegistry,
|
|
)
|
|
from cleveragents.domain.models.acms.crp import (
|
|
ContextFragment,
|
|
ContextRequest,
|
|
FragmentProvenance,
|
|
)
|
|
from cleveragents.domain.models.acms.strategy import (
|
|
BackendSet,
|
|
ContextStrategy,
|
|
ContextStrategyResult,
|
|
PlanContext,
|
|
StrategyCapabilities,
|
|
StrategyConfig,
|
|
)
|
|
from cleveragents.domain.models.acms.strategy_stubs import (
|
|
BUILTIN_STRATEGY_CLASSES,
|
|
DEFAULT_ENABLED_STRATEGIES,
|
|
)
|
|
from cleveragents.domain.models.acms.stubs import (
|
|
InMemoryGraphBackend,
|
|
InMemoryTextBackend,
|
|
InMemoryVectorBackend,
|
|
)
|
|
from cleveragents.domain.models.acms.temporal_stubs import InMemoryTemporalBackend
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _all_builtins() -> dict[str, ContextStrategy]:
|
|
"""Instantiate all built-in strategies keyed by name."""
|
|
instances: dict[str, ContextStrategy] = {}
|
|
for cls in BUILTIN_STRATEGY_CLASSES:
|
|
inst = cls()
|
|
instances[inst.name] = inst
|
|
return instances
|
|
|
|
|
|
def _register_all_builtins(
|
|
registry: StrategyRegistry,
|
|
*,
|
|
use_default_enabled: bool = False,
|
|
) -> None:
|
|
"""Register all built-in strategies in the registry."""
|
|
for cls in BUILTIN_STRATEGY_CLASSES:
|
|
inst = cls()
|
|
enabled = (
|
|
inst.name in DEFAULT_ENABLED_STRATEGIES if use_default_enabled else True
|
|
)
|
|
registry.register(
|
|
inst,
|
|
config=StrategyConfig(enabled=enabled),
|
|
is_builtin=True,
|
|
)
|
|
if use_default_enabled:
|
|
registry.set_enabled(list(DEFAULT_ENABLED_STRATEGIES))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("all built-in strategies are instantiated")
|
|
def step_given_all_builtins(context: Context) -> None:
|
|
context.strategies = _all_builtins()
|
|
|
|
|
|
@given("an empty strategy registry")
|
|
def step_given_empty_registry(context: Context) -> None:
|
|
context.registry = StrategyRegistry()
|
|
|
|
|
|
@given('I register the "{name}" built-in strategy')
|
|
def step_given_register_builtin(context: Context, name: str) -> None:
|
|
strategies = _all_builtins()
|
|
context.registry.register(strategies[name], is_builtin=True)
|
|
|
|
|
|
@given("a registry with all built-in strategies")
|
|
def step_given_registry_all(context: Context) -> None:
|
|
context.registry = StrategyRegistry()
|
|
_register_all_builtins(context.registry)
|
|
|
|
|
|
@given("a registry with all built-in strategies using default enabled list")
|
|
def step_given_registry_default_enabled(context: Context) -> None:
|
|
context.registry = StrategyRegistry()
|
|
_register_all_builtins(context.registry, use_default_enabled=True)
|
|
|
|
|
|
@given("a BackendSet with text backend only")
|
|
def step_given_backends_text(context: Context) -> None:
|
|
context.backend_set = BackendSet(text=InMemoryTextBackend())
|
|
|
|
|
|
@given("a BackendSet with vector backend only")
|
|
def step_given_backends_vector(context: Context) -> None:
|
|
context.backend_set = BackendSet(vector=InMemoryVectorBackend())
|
|
|
|
|
|
@given("a BackendSet with graph backend only")
|
|
def step_given_backends_graph(context: Context) -> None:
|
|
context.backend_set = BackendSet(graph=InMemoryGraphBackend())
|
|
|
|
|
|
@given("a BackendSet with all backends")
|
|
def step_given_backends_all(context: Context) -> None:
|
|
context.backend_set = BackendSet(
|
|
text=InMemoryTextBackend(),
|
|
vector=InMemoryVectorBackend(),
|
|
graph=InMemoryGraphBackend(),
|
|
)
|
|
|
|
|
|
@given("a BackendSet with no backends")
|
|
def step_given_backends_none(context: Context) -> None:
|
|
context.backend_set = BackendSet()
|
|
|
|
|
|
@given("a default ContextRequest")
|
|
def step_given_default_request(context: Context) -> None:
|
|
context.request = ContextRequest(query="test query")
|
|
|
|
|
|
@given("a default PlanContext")
|
|
def step_given_default_plan_context(context: Context) -> None:
|
|
context.plan_context = PlanContext()
|
|
|
|
|
|
@given("a ContextStrategyResult with unordered fragments")
|
|
def step_given_unordered_result(context: Context) -> None:
|
|
prov = FragmentProvenance(resource_uri="res://test")
|
|
context.result = ContextStrategyResult(
|
|
strategy_name="test",
|
|
fragments=(
|
|
ContextFragment(
|
|
uko_node="uko:Bravo",
|
|
content="b",
|
|
detail_depth=1,
|
|
token_count=10,
|
|
relevance_score=0.5,
|
|
provenance=prov,
|
|
),
|
|
ContextFragment(
|
|
uko_node="uko:Alpha",
|
|
content="a",
|
|
detail_depth=1,
|
|
token_count=10,
|
|
relevance_score=0.9,
|
|
provenance=prov,
|
|
),
|
|
ContextFragment(
|
|
uko_node="uko:Alpha",
|
|
content="a2",
|
|
detail_depth=1,
|
|
token_count=10,
|
|
relevance_score=0.5,
|
|
provenance=prov,
|
|
),
|
|
),
|
|
total_fragments=3,
|
|
tokens_used=30,
|
|
)
|
|
|
|
|
|
@given("a ContextStrategyResult with no fragments")
|
|
def step_given_empty_result(context: Context) -> None:
|
|
context.result = ContextStrategyResult(strategy_name="empty")
|
|
|
|
|
|
@given("a registry with a stale enabled list entry")
|
|
def step_given_stale_enabled(context: Context) -> None:
|
|
"""Create a registry where the enabled list references a name not registered."""
|
|
context.registry = StrategyRegistry()
|
|
_register_all_builtins(context.registry)
|
|
# Use the public test helper to inject a stale entry
|
|
context.registry.inject_stale_enabled_entry("ghost-strategy")
|
|
|
|
|
|
@given("a registry with a strategy that declares no resource types")
|
|
def step_given_no_resource_types(context: Context) -> None:
|
|
context.registry = StrategyRegistry()
|
|
|
|
class _NoResourceTypes:
|
|
@property
|
|
def name(self) -> str:
|
|
return "no-resource-types"
|
|
|
|
@property
|
|
def capabilities(self) -> StrategyCapabilities:
|
|
return StrategyCapabilities(
|
|
uses_text=True,
|
|
resource_types=(),
|
|
quality_score=0.3,
|
|
)
|
|
|
|
def can_handle(self, request: ContextRequest, backends: BackendSet) -> float:
|
|
return 0.3
|
|
|
|
def assemble(
|
|
self,
|
|
request: ContextRequest,
|
|
backends: BackendSet,
|
|
budget: int,
|
|
plan_context: PlanContext,
|
|
) -> list[ContextFragment]:
|
|
return []
|
|
|
|
def explain(self) -> str:
|
|
return "test"
|
|
|
|
context.registry.register(_NoResourceTypes())
|
|
|
|
|
|
@given("a registry with a strategy that declares no backend capabilities")
|
|
def step_given_no_capabilities(context: Context) -> None:
|
|
context.registry = StrategyRegistry()
|
|
|
|
class _NoBackend:
|
|
@property
|
|
def name(self) -> str:
|
|
return "no-backends"
|
|
|
|
@property
|
|
def capabilities(self) -> StrategyCapabilities:
|
|
return StrategyCapabilities(
|
|
uses_text=False,
|
|
uses_vector=False,
|
|
uses_graph=False,
|
|
uses_temporal=False,
|
|
resource_types=("*",),
|
|
quality_score=0.1,
|
|
)
|
|
|
|
def can_handle(self, request: ContextRequest, backends: BackendSet) -> float:
|
|
return 0.1
|
|
|
|
def assemble(
|
|
self,
|
|
request: ContextRequest,
|
|
backends: BackendSet,
|
|
budget: int,
|
|
plan_context: PlanContext,
|
|
) -> list[ContextFragment]:
|
|
return []
|
|
|
|
def explain(self) -> str:
|
|
return "test"
|
|
|
|
context.registry.register(_NoBackend())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I register the "{name}" built-in strategy')
|
|
def step_when_register_builtin(context: Context, name: str) -> None:
|
|
strategies = _all_builtins()
|
|
context.registry.register(strategies[name], is_builtin=True)
|
|
|
|
|
|
@when("I register all 6 built-in strategies")
|
|
def step_when_register_all(context: Context) -> None:
|
|
_register_all_builtins(context.registry)
|
|
|
|
|
|
@when('I attempt to register "{name}" again')
|
|
def step_when_register_duplicate(context: Context, name: str) -> None:
|
|
strategies = _all_builtins()
|
|
try:
|
|
context.registry.register(strategies[name], is_builtin=True)
|
|
context.error = None
|
|
except StrategyRegistrationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I attempt to register a strategy with an empty name")
|
|
def step_when_register_empty_name(context: Context) -> None:
|
|
class _Empty:
|
|
@property
|
|
def name(self) -> str:
|
|
return ""
|
|
|
|
@property
|
|
def capabilities(self) -> StrategyCapabilities:
|
|
return StrategyCapabilities(uses_text=True, resource_types=("*",))
|
|
|
|
def can_handle(self, request: ContextRequest, backends: BackendSet) -> float:
|
|
return 0.0
|
|
|
|
def assemble(
|
|
self,
|
|
request: ContextRequest,
|
|
backends: BackendSet,
|
|
budget: int,
|
|
plan_context: PlanContext,
|
|
) -> list[ContextFragment]:
|
|
return []
|
|
|
|
def explain(self) -> str:
|
|
return ""
|
|
|
|
try:
|
|
context.registry.register(_Empty())
|
|
context.error = None
|
|
except StrategyRegistrationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I get the strategy "{name}"')
|
|
def step_when_get_strategy(context: Context, name: str) -> None:
|
|
context.fetched_strategy = context.registry.get(name)
|
|
|
|
|
|
@when('I attempt to get the strategy "{name}"')
|
|
def step_when_get_nonexistent(context: Context, name: str) -> None:
|
|
try:
|
|
context.registry.get(name)
|
|
context.error = None
|
|
except StrategyNotFoundError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I set the enabled list to "{names}"')
|
|
def step_when_set_enabled(context: Context, names: str) -> None:
|
|
name_list = [n.strip().strip('"') for n in names.split(",")]
|
|
context.registry.set_enabled(name_list)
|
|
|
|
|
|
@when('I attempt to set the enabled list to "{names}"')
|
|
def step_when_set_enabled_bad(context: Context, names: str) -> None:
|
|
name_list = [n.strip().strip('"') for n in names.split(",")]
|
|
try:
|
|
context.registry.set_enabled(name_list)
|
|
context.error = None
|
|
except StrategyNotFoundError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I disable the strategy "{name}"')
|
|
def step_when_disable(context: Context, name: str) -> None:
|
|
context.registry.update_config(name, enabled=False)
|
|
|
|
|
|
@when('I get the config for "{name}"')
|
|
def step_when_get_config(context: Context, name: str) -> None:
|
|
context.strategy_config = context.registry.get_config(name)
|
|
|
|
|
|
@when('I update config for "{name}" with timeout_seconds {timeout:d}')
|
|
def step_when_update_config(context: Context, name: str, timeout: int) -> None:
|
|
context.registry.update_config(name, timeout_seconds=timeout)
|
|
|
|
|
|
@when("each strategy assembles with budget {budget:d}")
|
|
def step_when_assemble_all(context: Context, budget: int) -> None:
|
|
context.assemble_results = {}
|
|
for name, strategy in context.strategies.items():
|
|
result = strategy.assemble(
|
|
context.request,
|
|
context.backend_set,
|
|
budget,
|
|
context.plan_context,
|
|
)
|
|
context.assemble_results[name] = result
|
|
|
|
|
|
@when('I register a strategy from module "{module_path}"')
|
|
def step_when_register_from_module(context: Context, module_path: str) -> None:
|
|
# Use "simple-keyword" as the name since the scenario asserts on it.
|
|
context.registry.register_from_module(
|
|
name="simple-keyword",
|
|
module_path=module_path,
|
|
)
|
|
|
|
|
|
@when('I attempt to register a strategy from module "{module_path}"')
|
|
def step_when_register_from_module_fail(context: Context, module_path: str) -> None:
|
|
try:
|
|
context.registry.register_from_module(name="test", module_path=module_path)
|
|
context.error = None
|
|
except StrategyRegistrationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I get the entry for "{name}"')
|
|
def step_when_get_entry(context: Context, name: str) -> None:
|
|
context.fetched_entry = context.registry.get_entry(name)
|
|
|
|
|
|
@when('I attempt to get the entry for "{name}"')
|
|
def step_when_get_entry_fail(context: Context, name: str) -> None:
|
|
try:
|
|
context.registry.get_entry(name)
|
|
context.error = None
|
|
except StrategyNotFoundError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I attempt to unregister "{name}"')
|
|
def step_when_unregister_fail(context: Context, name: str) -> None:
|
|
try:
|
|
context.registry.unregister(name)
|
|
context.error = None
|
|
except StrategyNotFoundError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I unregister "{name}"')
|
|
def step_when_unregister(context: Context, name: str) -> None:
|
|
context.registry.unregister(name)
|
|
|
|
|
|
@when("I clear the registry")
|
|
def step_when_clear(context: Context) -> None:
|
|
context.registry.clear()
|
|
|
|
|
|
@when("I validate the registry")
|
|
def step_when_validate(context: Context) -> None:
|
|
context.validation_warnings = context.registry.validate_registry()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("every strategy should satisfy the ContextStrategy protocol")
|
|
def step_then_protocol(context: Context) -> None:
|
|
for name, strategy in context.strategies.items():
|
|
assert isinstance(strategy, ContextStrategy), (
|
|
f"Strategy '{name}' does not satisfy ContextStrategy protocol"
|
|
)
|
|
|
|
|
|
@then("every strategy should have a non-empty name")
|
|
def step_then_nonempty_name(context: Context) -> None:
|
|
for name, strategy in context.strategies.items():
|
|
assert strategy.name, f"Strategy has empty name (key={name})"
|
|
|
|
|
|
@then("every strategy should have a capabilities object")
|
|
def step_then_has_capabilities(context: Context) -> None:
|
|
for name, strategy in context.strategies.items():
|
|
caps = strategy.capabilities
|
|
assert isinstance(caps, StrategyCapabilities), (
|
|
f"Strategy '{name}' capabilities is not StrategyCapabilities"
|
|
)
|
|
|
|
|
|
@then('the strategy "{name}" should have quality score {score:g}')
|
|
def step_then_quality_score(context: Context, name: str, score: float) -> None:
|
|
strategy = context.strategies[name]
|
|
assert strategy.capabilities.quality_score == score, (
|
|
f"Expected {score}, got {strategy.capabilities.quality_score}"
|
|
)
|
|
|
|
|
|
@then('the strategy registry should contain "{name}"')
|
|
def step_then_contains(context: Context, name: str) -> None:
|
|
assert context.registry.is_registered(name), (
|
|
f"'{name}' not in registry: {context.registry.list_all()}"
|
|
)
|
|
|
|
|
|
@then('the strategy registry should not contain "{name}"')
|
|
def step_then_not_contains(context: Context, name: str) -> None:
|
|
assert not context.registry.is_registered(name), (
|
|
f"'{name}' unexpectedly in registry"
|
|
)
|
|
|
|
|
|
@then("the registry size should be {size:d}")
|
|
def step_then_size(context: Context, size: int) -> None:
|
|
assert len(context.registry) == size, (
|
|
f"Expected {size}, got {len(context.registry)}"
|
|
)
|
|
|
|
|
|
@then("the registry should contain {count:d} strategies")
|
|
def step_then_count(context: Context, count: int) -> None:
|
|
assert len(context.registry) == count, (
|
|
f"Expected {count}, got {len(context.registry)}"
|
|
)
|
|
|
|
|
|
@then("a StrategyRegistrationError should be raised")
|
|
def step_then_reg_error(context: Context) -> None:
|
|
assert isinstance(context.error, StrategyRegistrationError), (
|
|
f"Expected StrategyRegistrationError, got {type(context.error)}"
|
|
)
|
|
assert str(context.error), "Error message should not be empty"
|
|
|
|
|
|
@then("a StrategyNotFoundError should be raised")
|
|
def step_then_not_found_error(context: Context) -> None:
|
|
assert isinstance(context.error, StrategyNotFoundError), (
|
|
f"Expected StrategyNotFoundError, got {type(context.error)}"
|
|
)
|
|
assert str(context.error), "Error message should not be empty"
|
|
|
|
|
|
@then('the strategy name should be "{name}"')
|
|
def step_then_strategy_name(context: Context, name: str) -> None:
|
|
assert context.fetched_strategy.name == name
|
|
|
|
|
|
@then("the registry should list {count:d} strategies")
|
|
def step_then_list_count(context: Context, count: int) -> None:
|
|
all_names = context.registry.list_all()
|
|
assert len(all_names) == count, (
|
|
f"Expected {count}, got {len(all_names)}: {all_names}"
|
|
)
|
|
|
|
|
|
@then("the list should be sorted alphabetically")
|
|
def step_then_sorted(context: Context) -> None:
|
|
names = context.registry.list_all()
|
|
assert names == sorted(names), f"Names not sorted: {names}"
|
|
|
|
|
|
@then('the enabled strategies should be "{names}"')
|
|
def step_then_enabled(context: Context, names: str) -> None:
|
|
expected = [n.strip().strip('"') for n in names.split(",")]
|
|
actual = context.registry.list_enabled()
|
|
assert actual == expected, f"Expected {expected}, got {actual}"
|
|
|
|
|
|
@then('the enabled strategies should not include "{name}"')
|
|
def step_then_not_enabled(context: Context, name: str) -> None:
|
|
enabled = context.registry.list_enabled()
|
|
assert name not in enabled, f"'{name}' unexpectedly in enabled list: {enabled}"
|
|
|
|
|
|
@then("the timeout_seconds should be {val:d}")
|
|
def step_then_timeout(context: Context, val: int) -> None:
|
|
assert context.strategy_config.timeout_seconds == val
|
|
|
|
|
|
@then("the max_fragments should be {val:d}")
|
|
def step_then_max_fragments(context: Context, val: int) -> None:
|
|
assert context.strategy_config.max_fragments == val
|
|
|
|
|
|
@then("the circuit_breaker_threshold should be {val:d}")
|
|
def step_then_cb(context: Context, val: int) -> None:
|
|
assert context.strategy_config.circuit_breaker_threshold == val
|
|
|
|
|
|
@then('the config for "{name}" should have timeout_seconds {val:d}')
|
|
def step_then_config_timeout(context: Context, name: str, val: int) -> None:
|
|
config = context.registry.get_config(name)
|
|
assert config.timeout_seconds == val, (
|
|
f"Expected {val}, got {config.timeout_seconds}"
|
|
)
|
|
|
|
|
|
@then('"{name}" can_handle should return {score:g}')
|
|
def step_then_can_handle(context: Context, name: str, score: float) -> None:
|
|
strategies = _all_builtins()
|
|
strategy = strategies[name]
|
|
result = strategy.can_handle(context.request, context.backend_set)
|
|
assert abs(result - score) < 1e-6, f"Expected {score}, got {result} for '{name}'"
|
|
|
|
|
|
@then("every strategy should return an empty fragment list")
|
|
def step_then_empty_assemble(context: Context) -> None:
|
|
for name, result in context.assemble_results.items():
|
|
assert result == [], f"Strategy '{name}' returned non-empty: {result}"
|
|
|
|
|
|
@then("the fragments should be sorted by relevance_score DESC then uko_node ASC")
|
|
def step_then_sorted_fragments(context: Context) -> None:
|
|
frags = context.result.fragments
|
|
assert len(frags) == 3
|
|
# relevance DESC: 0.9 first, then 0.5s; within 0.5: Alpha < Bravo
|
|
assert frags[0].relevance_score == 0.9
|
|
assert frags[0].uko_node == "uko:Alpha"
|
|
assert frags[1].relevance_score == 0.5
|
|
assert frags[1].uko_node == "uko:Alpha"
|
|
assert frags[2].relevance_score == 0.5
|
|
assert frags[2].uko_node == "uko:Bravo"
|
|
|
|
|
|
@then("the total_fragments should be {val:d}")
|
|
def step_then_total_fragments(context: Context, val: int) -> None:
|
|
assert context.result.total_fragments == val
|
|
|
|
|
|
@then("the tokens_used should be {val:d}")
|
|
def step_then_tokens_used(context: Context, val: int) -> None:
|
|
assert context.result.tokens_used == val
|
|
|
|
|
|
@then('validation warnings should mention "{text}"')
|
|
def step_then_validation_mentions(context: Context, text: str) -> None:
|
|
combined = " ".join(context.validation_warnings)
|
|
assert text in combined, (
|
|
f"Expected '{text}' in warnings: {context.validation_warnings}"
|
|
)
|
|
|
|
|
|
@then('the entry name should be "{name}"')
|
|
def step_then_entry_name(context: Context, name: str) -> None:
|
|
assert context.fetched_entry.name == name
|
|
|
|
|
|
@then("the entry should be marked as builtin")
|
|
def step_then_entry_builtin(context: Context) -> None:
|
|
assert context.fetched_entry.is_builtin, "Expected entry to be builtin"
|
|
|
|
|
|
@then("the builtin list should contain {count:d} strategies")
|
|
def step_then_builtin_count(context: Context, count: int) -> None:
|
|
builtins = context.registry.list_builtin()
|
|
assert len(builtins) == count, (
|
|
f"Expected {count} builtins, got {len(builtins)}: {builtins}"
|
|
)
|
|
|
|
|
|
@then('"{name}" should be in the registry via contains')
|
|
def step_then_contains_in(context: Context, name: str) -> None:
|
|
assert name in context.registry, f"'{name}' not in registry via __contains__"
|
|
|
|
|
|
@then('"{name}" should not be in the registry via contains')
|
|
def step_then_not_contains_in(context: Context, name: str) -> None:
|
|
assert name not in context.registry, (
|
|
f"'{name}' unexpectedly in registry via __contains__"
|
|
)
|
|
|
|
|
|
@then("there should be no validation warnings")
|
|
def step_then_no_warnings(context: Context) -> None:
|
|
assert context.validation_warnings == [], (
|
|
f"Expected no warnings, got: {context.validation_warnings}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Boundary test steps (TEST-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I attempt to create a StrategyConfig with {field} set to {value:d}")
|
|
def step_when_invalid_config(context: Context, field: str, value: int) -> None:
|
|
try:
|
|
StrategyConfig.model_validate({field: value})
|
|
context.error = None
|
|
except ValidationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I attempt to create a StrategyCapabilities with quality_score {value:g}")
|
|
def step_when_invalid_quality(context: Context, value: float) -> None:
|
|
try:
|
|
StrategyCapabilities(uses_text=True, quality_score=value)
|
|
context.error = None
|
|
except ValidationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I create a StrategyCapabilities with quality_score {value:g}")
|
|
def step_when_valid_quality(context: Context, value: float) -> None:
|
|
context.created_capabilities = StrategyCapabilities(
|
|
uses_text=True,
|
|
quality_score=value,
|
|
)
|
|
|
|
|
|
@then("a Pydantic ValidationError should be raised")
|
|
def step_then_pydantic_validation_error(context: Context) -> None:
|
|
assert context.error is not None, (
|
|
"Expected Pydantic ValidationError but none raised"
|
|
)
|
|
assert isinstance(context.error, ValidationError), (
|
|
f"Expected pydantic.ValidationError, got {type(context.error).__name__}"
|
|
)
|
|
|
|
|
|
@then("the quality_score should be {value:g}")
|
|
def step_then_quality_value(context: Context, value: float) -> None:
|
|
assert abs(context.created_capabilities.quality_score - value) < 1e-9, (
|
|
f"Expected {value}, got {context.created_capabilities.quality_score}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bug regression steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when(
|
|
'I update config for "{name}" with max_workers {mw:d}'
|
|
" and circuit_breaker_threshold {cb:d}"
|
|
)
|
|
def step_when_update_mw_cb(context: Context, name: str, mw: int, cb: int) -> None:
|
|
context.registry.update_config(name, max_workers=mw, circuit_breaker_threshold=cb)
|
|
|
|
|
|
@then('the config for "{name}" should have max_workers {val:d}')
|
|
def step_then_config_max_workers(context: Context, name: str, val: int) -> None:
|
|
config = context.registry.get_config(name)
|
|
assert config.max_workers == val, f"Expected {val}, got {config.max_workers}"
|
|
|
|
|
|
@then('the config for "{name}" should have circuit_breaker_threshold {val:d}')
|
|
def step_then_config_cb(context: Context, name: str, val: int) -> None:
|
|
config = context.registry.get_config(name)
|
|
assert config.circuit_breaker_threshold == val, (
|
|
f"Expected {val}, got {config.circuit_breaker_threshold}"
|
|
)
|
|
|
|
|
|
@when("I attempt to register a non-protocol object")
|
|
def step_when_register_non_protocol(context: Context) -> None:
|
|
try:
|
|
# Pass name explicitly so register() reaches the isinstance check
|
|
context.registry.register("not-a-strategy", name="fake") # type: ignore[arg-type]
|
|
context.error = None
|
|
except StrategyRegistrationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I attempt to register a non-protocol object without a name")
|
|
def step_when_register_non_protocol_no_name(context: Context) -> None:
|
|
try:
|
|
# No name= override — verifies guard ordering: isinstance check
|
|
# must happen before strategy.name access (bug 1.1 fix).
|
|
context.registry.register(object()) # type: ignore[arg-type]
|
|
context.error = None
|
|
except StrategyRegistrationError as exc:
|
|
context.error = exc
|
|
except AttributeError as exc: # pragma: no cover
|
|
# This would indicate the bug is still present
|
|
context.error = exc
|
|
|
|
|
|
@then("every strategy should return a non-empty explain string")
|
|
def step_then_explain_nonempty(context: Context) -> None:
|
|
for name, strategy in context.strategies.items():
|
|
explanation = strategy.explain()
|
|
assert isinstance(explanation, str), (
|
|
f"Strategy '{name}' explain() returned {type(explanation)}, expected str"
|
|
)
|
|
assert explanation.strip(), f"Strategy '{name}' returned empty explain string"
|
|
|
|
|
|
@when('I register the "{name}" built-in strategy with enabled False')
|
|
def step_when_register_builtin_disabled(context: Context, name: str) -> None:
|
|
strategies = _all_builtins()
|
|
context.registry.register(
|
|
strategies[name],
|
|
config=StrategyConfig(enabled=False),
|
|
is_builtin=True,
|
|
)
|
|
|
|
|
|
@when('I update config for "{name}" with enabled True')
|
|
def step_when_update_enabled_true(context: Context, name: str) -> None:
|
|
context.registry.update_config(name, enabled=True)
|
|
|
|
|
|
@when('I update config for "{name}" with enabled False')
|
|
def step_when_update_enabled_false(context: Context, name: str) -> None:
|
|
context.registry.update_config(name, enabled=False)
|
|
|
|
|
|
@then('the enabled strategies should be ""')
|
|
def step_then_enabled_empty(context: Context) -> None:
|
|
actual = context.registry.list_enabled()
|
|
assert actual == [], f"Expected empty enabled list, got {actual}"
|
|
|
|
|
|
@then('the enabled strategies should include "{name}"')
|
|
def step_then_enabled_includes(context: Context, name: str) -> None:
|
|
enabled = context.registry.list_enabled()
|
|
assert name in enabled, f"'{name}' not in enabled list: {enabled}"
|
|
|
|
|
|
@when('I register a strategy named "{name}" from module "{module_path}"')
|
|
def step_when_register_from_module_with_name(
|
|
context: Context, name: str, module_path: str
|
|
) -> None:
|
|
context.registry.register_from_module(name=name, module_path=module_path)
|
|
|
|
|
|
@when('I attempt to update config for "{name}" with timeout_seconds {value:d}')
|
|
def step_when_update_config_bad_timeout(
|
|
context: Context, name: str, value: int
|
|
) -> None:
|
|
try:
|
|
context.registry.update_config(name, timeout_seconds=value)
|
|
context.error = None
|
|
except ValidationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I attempt to update config for "{name}" with max_fragments {value:d}')
|
|
def step_when_update_config_bad_fragments(
|
|
context: Context, name: str, value: int
|
|
) -> None:
|
|
try:
|
|
context.registry.update_config(name, max_fragments=value)
|
|
context.error = None
|
|
except ValidationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thread safety steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("10 threads concurrently register and query the registry")
|
|
def step_when_concurrent_register_query(context: Context) -> None:
|
|
"""Exercise the registry from multiple threads simultaneously."""
|
|
import threading as _threading
|
|
|
|
errors: list[Exception] = []
|
|
barrier = _threading.Barrier(10)
|
|
|
|
def _worker(idx: int) -> None:
|
|
try:
|
|
barrier.wait(timeout=5)
|
|
# Mix of reads and writes
|
|
if idx % 3 == 0:
|
|
# Write: register a unique strategy
|
|
name = f"thread-strategy-{idx}"
|
|
|
|
class _Stub:
|
|
@property
|
|
def name(self) -> str:
|
|
return name
|
|
|
|
@property
|
|
def capabilities(self) -> StrategyCapabilities:
|
|
return StrategyCapabilities(
|
|
uses_text=True, resource_types=("*",), quality_score=0.1
|
|
)
|
|
|
|
def can_handle(
|
|
self, request: ContextRequest, backends: BackendSet
|
|
) -> float:
|
|
return 0.1
|
|
|
|
def assemble(
|
|
self,
|
|
request: ContextRequest,
|
|
backends: BackendSet,
|
|
budget: int,
|
|
plan_context: PlanContext,
|
|
) -> list[ContextFragment]:
|
|
return []
|
|
|
|
def explain(self) -> str:
|
|
return f"thread stub {idx}"
|
|
|
|
context.registry.register(_Stub())
|
|
elif idx % 3 == 1:
|
|
# Read: list enabled
|
|
context.registry.list_enabled()
|
|
else:
|
|
# Read: list all
|
|
context.registry.list_all()
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
|
|
threads = [_threading.Thread(target=_worker, args=(i,)) for i in range(10)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
context.thread_errors = errors
|
|
|
|
|
|
@then("no thread should have raised an exception")
|
|
def step_then_no_thread_errors(context: Context) -> None:
|
|
assert not context.thread_errors, f"Thread errors: {context.thread_errors}"
|
|
|
|
|
|
@then("the registry should contain at least {count:d} strategies")
|
|
def step_then_at_least_count(context: Context, count: int) -> None:
|
|
actual = len(context.registry)
|
|
assert actual >= count, f"Expected at least {count}, got {actual}"
|
|
|
|
|
|
@then("every thread-registered strategy should be present")
|
|
def step_then_thread_strategies_present(context: Context) -> None:
|
|
for idx in [0, 3, 6, 9]:
|
|
name = f"thread-strategy-{idx}"
|
|
assert context.registry.is_registered(name), (
|
|
f"Thread-registered '{name}' missing from registry"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Temporal/cold-tier backend steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_config: resource_types and extra steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I update config for "{name}" with resource_types "{rt1}", "{rt2}"')
|
|
def step_when_update_resource_types(
|
|
context: Context, name: str, rt1: str, rt2: str
|
|
) -> None:
|
|
context.registry.update_config(name, resource_types=(rt1, rt2))
|
|
|
|
|
|
@then('the config for "{name}" should have resource_types "{rt1}", "{rt2}"')
|
|
def step_then_resource_types(context: Context, name: str, rt1: str, rt2: str) -> None:
|
|
cfg = context.registry.get_config(name)
|
|
assert cfg.resource_types == (rt1, rt2), (
|
|
f"Expected ({rt1!r}, {rt2!r}), got {cfg.resource_types}"
|
|
)
|
|
|
|
|
|
@when('I update config for "{name}" with extra key "{key}" value "{val}"')
|
|
def step_when_update_extra(context: Context, name: str, key: str, val: str) -> None:
|
|
context.registry.update_config(name, extra={key: val})
|
|
|
|
|
|
@then('the config for "{name}" extra should contain key "{key}"')
|
|
def step_then_extra_contains(context: Context, name: str, key: str) -> None:
|
|
cfg = context.registry.get_config(name)
|
|
assert key in cfg.extra, f"Key '{key}' not in extra: {cfg.extra}"
|
|
|
|
|
|
@then('the config for "{name}" extra should be immutable')
|
|
def step_then_extra_immutable(context: Context, name: str) -> None:
|
|
cfg = context.registry.get_config(name)
|
|
assert isinstance(cfg.extra, MappingProxyType), (
|
|
f"Expected MappingProxyType, got {type(cfg.extra).__name__}"
|
|
)
|
|
try:
|
|
cfg.extra["hack"] = "should fail" # type: ignore[index]
|
|
raise AssertionError("MappingProxyType should reject item assignment")
|
|
except TypeError:
|
|
pass # Expected — MappingProxyType is immutable
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MappingProxyType coercion validator steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a StrategyConfig with extra as a plain dict")
|
|
def step_when_config_plain_dict_extra(context: Context) -> None:
|
|
context.created_config = StrategyConfig(extra={"key": "val"}) # type: ignore[arg-type]
|
|
|
|
|
|
@then("the extra field should be a MappingProxyType")
|
|
def step_then_extra_is_mapping_proxy(context: Context) -> None:
|
|
assert isinstance(context.created_config.extra, MappingProxyType), (
|
|
f"Expected MappingProxyType, got {type(context.created_config.extra).__name__}"
|
|
)
|
|
|
|
|
|
@when("I attempt to create a StrategyConfig with extra as an integer")
|
|
def step_when_config_int_extra(context: Context) -> None:
|
|
try:
|
|
StrategyConfig(extra=42) # type: ignore[arg-type]
|
|
context.error = None
|
|
except ValidationError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I create a ContextStrategyResult with stats as a plain dict")
|
|
def step_when_result_plain_dict_stats(context: Context) -> None:
|
|
context.created_result = ContextStrategyResult(
|
|
strategy_name="test",
|
|
stats={"ops": 10}, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
@then("the stats field should be a MappingProxyType")
|
|
def step_then_stats_is_mapping_proxy(context: Context) -> None:
|
|
assert isinstance(context.created_result.stats, MappingProxyType), (
|
|
f"Expected MappingProxyType, got {type(context.created_result.stats).__name__}"
|
|
)
|
|
|
|
|
|
@given("a BackendSet with graph and temporal backends")
|
|
def step_given_backends_graph_temporal(context: Context) -> None:
|
|
context.backend_set = BackendSet(
|
|
graph=InMemoryGraphBackend(),
|
|
temporal=InMemoryTemporalBackend(),
|
|
)
|
|
|
|
|
|
@given("a BackendSet with temporal backend only")
|
|
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"
|
|
)
|