forked from HAL9000/cleveragents-core
d5f7f15215
Temporal metadata fields (validFrom, validUntil, isCurrent, isRevisionOf) on UKO InformationUnit nodes enable revision chain tracking: when code changes, old nodes are marked historical and new revision nodes are created with back-links. Three storage tiers (hot/warm/cold) filter nodes by temporal scope (current/recent/all) with configurable retention (warm_retention_hours default 24h, cold_retention_days default 90d). Includes TemporalMetadata, TemporalNode, RevisionChain, TierQueryResult, TierRetentionConfig frozen domain models, TemporalBackend protocol, InMemoryTemporalBackend stub, TemporalService with structlog and DI, BackendSet.temporal typing upgrade from object|None to TemporalBackend|None. 67 Behave scenarios, 8 Robot Framework tests, ASV benchmarks, and reference documentation. ISSUES CLOSED: #577
482 lines
15 KiB
Python
482 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import MappingProxyType
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from pydantic import ValidationError
|
|
|
|
from cleveragents.domain.models.acms.strategy import BackendSet, StrategyConfig
|
|
from cleveragents.domain.models.acms.temporal import (
|
|
RevisionChain,
|
|
TemporalBackend,
|
|
TemporalMetadata,
|
|
TemporalNode,
|
|
TierQueryResult,
|
|
TierRetentionConfig,
|
|
)
|
|
from cleveragents.domain.models.acms.tiers import ContextTier
|
|
from cleveragents.domain.models.core.project import TemporalScope
|
|
|
|
__all__: list[str] = []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RevisionChain steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a RevisionChain with only current_uri "{uri}"')
|
|
def step_given_revision_chain(context: Any, uri: str) -> None:
|
|
context.revision_chain = RevisionChain(current_uri=uri)
|
|
|
|
|
|
@given('a RevisionChain with current_uri "{uri}" and predecessors "{pred_str}"')
|
|
def step_given_revision_chain_with_preds(
|
|
context: Any,
|
|
uri: str,
|
|
pred_str: str,
|
|
) -> None:
|
|
preds = tuple(p.strip() for p in pred_str.split(","))
|
|
context.revision_chain = RevisionChain(
|
|
current_uri=uri,
|
|
predecessors=preds,
|
|
)
|
|
|
|
|
|
@then("the revision chain depth should be {expected:d}")
|
|
def step_then_chain_depth(context: Any, expected: int) -> None:
|
|
assert context.revision_chain.depth == expected
|
|
|
|
|
|
@then("the revision chain predecessors should be empty")
|
|
def step_then_chain_preds_empty(context: Any) -> None:
|
|
assert context.revision_chain.predecessors == ()
|
|
|
|
|
|
@then("the revision chain predecessors should have {count:d} entries")
|
|
def step_then_chain_preds_count(context: Any, count: int) -> None:
|
|
assert len(context.revision_chain.predecessors) == count
|
|
|
|
|
|
@then('the revision chain all_uris should contain "{uri}"')
|
|
def step_then_chain_all_uris_contains(context: Any, uri: str) -> None:
|
|
assert uri in context.revision_chain.all_uris
|
|
|
|
|
|
@then("the revision chain all_uris should have {count:d} entries")
|
|
def step_then_chain_all_uris_count(context: Any, count: int) -> None:
|
|
assert len(context.revision_chain.all_uris) == count
|
|
|
|
|
|
@then("modifying the revision chain current_uri should raise an error")
|
|
def step_then_chain_frozen(context: Any) -> None:
|
|
try:
|
|
context.revision_chain.current_uri = "changed" # type: ignore[misc]
|
|
msg = "Expected frozen model to reject mutation"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then("creating a RevisionChain with empty current_uri should raise ValueError")
|
|
def step_then_chain_empty_uri(context: Any) -> None:
|
|
try:
|
|
RevisionChain(current_uri="")
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then("creating a RevisionChain with whitespace current_uri should raise ValueError")
|
|
def step_then_chain_whitespace_uri(context: Any) -> None:
|
|
try:
|
|
RevisionChain(current_uri=" ")
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then("creating a RevisionChain with empty predecessor should raise ValueError")
|
|
def step_then_chain_empty_pred(context: Any) -> None:
|
|
try:
|
|
RevisionChain(
|
|
current_uri="uko:test",
|
|
predecessors=("uko:v1", ""),
|
|
)
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then(
|
|
"creating a RevisionChain with current_uri in predecessors should raise ValueError",
|
|
)
|
|
def step_then_chain_current_in_preds(context: Any) -> None:
|
|
try:
|
|
RevisionChain(
|
|
current_uri="uko:v2",
|
|
predecessors=("uko:v1", "uko:v2"),
|
|
)
|
|
msg = "Expected ValueError for current_uri in predecessors"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then(
|
|
"creating a RevisionChain with duplicate predecessors should raise ValueError",
|
|
)
|
|
def step_then_chain_duplicate_preds(context: Any) -> None:
|
|
try:
|
|
RevisionChain(
|
|
current_uri="uko:v3",
|
|
predecessors=("uko:v1", "uko:v1"),
|
|
)
|
|
msg = "Expected ValueError for duplicate predecessors"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TierRetentionConfig steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a TierRetentionConfig with defaults")
|
|
def step_given_tier_retention_defaults(context: Any) -> None:
|
|
context.retention = TierRetentionConfig()
|
|
|
|
|
|
@then("the warm retention hours should be {expected:d}")
|
|
def step_then_retention_warm(context: Any, expected: int) -> None:
|
|
assert context.retention.warm_retention_hours == expected
|
|
|
|
|
|
@then("the cold retention days should be {expected:d}")
|
|
def step_then_retention_cold(context: Any, expected: int) -> None:
|
|
assert context.retention.cold_retention_days == expected
|
|
|
|
|
|
@then("modifying the retention warm hours should raise an error")
|
|
def step_then_retention_frozen(context: Any) -> None:
|
|
try:
|
|
context.retention.warm_retention_hours = 48 # type: ignore[misc]
|
|
msg = "Expected frozen model to reject mutation"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then(
|
|
"creating a TierRetentionConfig with warm_retention_hours 0 should raise ValueError"
|
|
)
|
|
def step_then_retention_zero_warm(context: Any) -> None:
|
|
try:
|
|
TierRetentionConfig(warm_retention_hours=0)
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then(
|
|
"creating a TierRetentionConfig with cold_retention_days 0 should raise ValueError"
|
|
)
|
|
def step_then_retention_zero_cold(context: Any) -> None:
|
|
try:
|
|
TierRetentionConfig(cold_retention_days=0)
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
@then('creating a revision from "{old}" to existing "{new}" should raise ValueError')
|
|
def step_then_create_revision_existing_uri_raises(
|
|
context: Any,
|
|
old: str,
|
|
new: str,
|
|
) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri=new,
|
|
source_resource="RES001",
|
|
source_path="src/auth.py",
|
|
temporal=TemporalMetadata(
|
|
valid_from=now,
|
|
is_revision_of=old,
|
|
),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision(old, new_node, now)
|
|
msg = "Expected ValueError for duplicate new_node URI"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TierQueryResult steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a TierQueryResult for HOT tier with CURRENT scope")
|
|
def step_given_tier_query_result(context: Any) -> None:
|
|
context.tier_query_result = TierQueryResult(
|
|
tier=ContextTier.HOT,
|
|
temporal_scope=TemporalScope.CURRENT,
|
|
)
|
|
|
|
|
|
@then("the tier query result nodes should be empty")
|
|
def step_then_tier_query_nodes_empty(context: Any) -> None:
|
|
assert context.tier_query_result.nodes == ()
|
|
|
|
|
|
@then("the tier query result tier should be HOT")
|
|
def step_then_tier_query_tier_hot(context: Any) -> None:
|
|
assert context.tier_query_result.tier == ContextTier.HOT
|
|
|
|
|
|
@then("the tier query result temporal_scope should be CURRENT")
|
|
def step_then_tier_query_scope_current(context: Any) -> None:
|
|
assert context.tier_query_result.temporal_scope == TemporalScope.CURRENT
|
|
|
|
|
|
@then("modifying the tier query result tier should raise an error")
|
|
def step_then_tier_query_frozen(context: Any) -> None:
|
|
try:
|
|
context.tier_query_result.tier = ContextTier.WARM # type: ignore[misc]
|
|
msg = "Expected frozen model to reject mutation"
|
|
raise AssertionError(msg)
|
|
except ValidationError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Structural assertion steps (Phase 3.1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('TemporalMetadata should have field "{field}"')
|
|
def step_then_temporal_metadata_has_field(
|
|
context: Any,
|
|
field: str,
|
|
) -> None:
|
|
assert field in TemporalMetadata.model_fields
|
|
|
|
|
|
@then('TemporalNode should have field "{field}"')
|
|
def step_then_temporal_node_has_field(
|
|
context: Any,
|
|
field: str,
|
|
) -> None:
|
|
assert field in TemporalNode.model_fields
|
|
|
|
|
|
@then('RevisionChain should have field "{field}"')
|
|
def step_then_revision_chain_has_field(
|
|
context: Any,
|
|
field: str,
|
|
) -> None:
|
|
assert field in RevisionChain.model_fields
|
|
|
|
|
|
@then('TierRetentionConfig should have field "{field}"')
|
|
def step_then_retention_has_field(
|
|
context: Any,
|
|
field: str,
|
|
) -> None:
|
|
assert field in TierRetentionConfig.model_fields
|
|
|
|
|
|
@then('TierQueryResult should have field "{field}"')
|
|
def step_then_tier_query_has_field(
|
|
context: Any,
|
|
field: str,
|
|
) -> None:
|
|
assert field in TierQueryResult.model_fields
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# StrategyConfig deep-freeze steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a StrategyConfig with nested extra dict")
|
|
def step_given_strategy_config_nested(context: Any) -> None:
|
|
context.strategy_config = StrategyConfig(
|
|
extra=MappingProxyType({"outer": {"inner": "value"}}),
|
|
)
|
|
|
|
|
|
@then("mutating the nested extra dict should raise TypeError")
|
|
def step_then_mutate_nested_extra_raises(context: Any) -> None:
|
|
try:
|
|
context.strategy_config.extra["outer"]["inner"] = "mutated"
|
|
msg = "Expected TypeError when mutating frozen nested dict"
|
|
raise AssertionError(msg)
|
|
except TypeError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BackendSet steps (moved from backend_steps to stay under 500 lines)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a BackendSet with the temporal backend")
|
|
def step_when_backendset_with_temporal(context: Any) -> None:
|
|
context.backend_set = BackendSet(
|
|
temporal=context.temporal_backend,
|
|
)
|
|
|
|
|
|
@then("the BackendSet temporal field should not be None")
|
|
def step_then_backendset_temporal_not_none(context: Any) -> None:
|
|
assert context.backend_set.temporal is not None
|
|
|
|
|
|
@then("it should satisfy the TemporalBackend protocol")
|
|
def step_then_satisfies_temporal_protocol(context: Any) -> None:
|
|
assert isinstance(context.temporal_backend, TemporalBackend)
|
|
|
|
|
|
# ---- Diff-coverage tier query steps (moved from backend_steps) ----
|
|
|
|
|
|
@when("I query the WARM tier")
|
|
def step_when_query_warm(context: Any) -> None:
|
|
retention = TierRetentionConfig()
|
|
context.tier_query = context.temporal_backend.query_by_tier(
|
|
ContextTier.WARM,
|
|
TemporalScope.ALL,
|
|
retention,
|
|
)
|
|
|
|
|
|
@when("I query the HOT tier with scope RECENT")
|
|
def step_when_query_hot_recent(context: Any) -> None:
|
|
retention = TierRetentionConfig()
|
|
context.tier_query = context.temporal_backend.query_by_tier(
|
|
ContextTier.HOT,
|
|
TemporalScope.RECENT,
|
|
retention,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round 4: fully-historical chain raises ValueError (NEW-2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a stored temporal node with all nodes historical")
|
|
def step_given_all_historical(context: Any) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
v1_meta = TemporalMetadata(
|
|
valid_from=now - timedelta(days=2),
|
|
valid_until=now - timedelta(days=1),
|
|
is_current=False,
|
|
)
|
|
v2_meta = TemporalMetadata(
|
|
valid_from=now - timedelta(days=1),
|
|
valid_until=now,
|
|
is_current=False,
|
|
is_revision_of="uko-py:class/Dead_v1",
|
|
)
|
|
for uri, meta in [
|
|
("uko-py:class/Dead_v1", v1_meta),
|
|
("uko-py:class/Dead_v2", v2_meta),
|
|
]:
|
|
context.temporal_backend.store_node(
|
|
TemporalNode(
|
|
node_uri=uri,
|
|
source_resource="RES001",
|
|
source_path="src/dead.py",
|
|
temporal=meta,
|
|
)
|
|
)
|
|
|
|
|
|
@then("getting revision chain for fully-historical node should raise ValueError")
|
|
def step_then_historical_chain_raises(context: Any) -> None:
|
|
try:
|
|
context.temporal_backend.get_revision_chain("uko-py:class/Dead_v2")
|
|
msg = "Expected ValueError for fully-historical chain"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round 4: deep-freeze list → tuple (NEW-4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a StrategyConfig with nested list in extra")
|
|
def step_given_strategy_config_list(context: Any) -> None:
|
|
context.strategy_config = StrategyConfig(
|
|
extra=MappingProxyType({"items": [1, 2, 3]}),
|
|
)
|
|
|
|
|
|
@then("the nested list should be frozen as a tuple")
|
|
def step_then_list_frozen_as_tuple(context: Any) -> None:
|
|
val = context.strategy_config.extra["items"]
|
|
assert isinstance(val, tuple), f"Expected tuple, got {type(val).__name__}"
|
|
assert val == (1, 2, 3)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round 4: create_revision input validation (#14, #15)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then(
|
|
'creating a revision from "{uri}" with wrong is_revision_of should raise ValueError'
|
|
)
|
|
def step_then_create_revision_wrong_revision_of(context: Any, uri: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri="uko-py:class/Auth_v99",
|
|
source_resource="RES001",
|
|
source_path="src/auth.py",
|
|
temporal=TemporalMetadata(
|
|
valid_from=now,
|
|
is_revision_of="uko-py:class/WRONG",
|
|
),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision(uri, new_node, now)
|
|
msg = "Expected ValueError for wrong is_revision_of"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then('creating a revision from "{uri}" with is_current false should raise ValueError')
|
|
def step_then_create_revision_not_current(context: Any, uri: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri="uko-py:class/Auth_v99",
|
|
source_resource="RES001",
|
|
source_path="src/auth.py",
|
|
temporal=TemporalMetadata(
|
|
valid_from=now,
|
|
valid_until=now + timedelta(days=1),
|
|
is_current=False,
|
|
is_revision_of=uri,
|
|
),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision(uri, new_node, now)
|
|
msg = "Expected ValueError for is_current=False"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|