Files
placeholder/features/steps/temporal_data_model_domain_steps.py
hamza.khyari d5f7f15215 feat(acms): implement Temporal Data Model (Revision-Aware RDF) with 3 storage tiers
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
2026-03-11 01:36:06 +00:00

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