Files
temp/features/steps/temporal_data_model_backend_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

500 lines
15 KiB
Python

"""Step definitions for Temporal Data Model — backend steps."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from behave import given, then, when
from cleveragents.domain.models.acms.temporal import (
TemporalMetadata,
TemporalNode,
TierRetentionConfig,
)
from cleveragents.domain.models.acms.temporal_stubs import InMemoryTemporalBackend
from cleveragents.domain.models.acms.tiers import ContextTier
from cleveragents.domain.models.core.project import TemporalScope
__all__: list[str] = []
# ---- InMemoryTemporalBackend steps ----
@given("an InMemoryTemporalBackend instance")
def step_given_inmemory_temporal_backend(context: Any) -> None:
context.temporal_backend = InMemoryTemporalBackend()
@given('a stored temporal node "{uri}" for resource "{resource}" at "{path}"')
def step_given_stored_temporal_node(
context: Any,
uri: str,
resource: str,
path: str,
) -> None:
now = datetime.now(tz=UTC)
node = TemporalNode(
node_uri=uri,
source_resource=resource,
source_path=path,
temporal=TemporalMetadata(
valid_from=now - timedelta(days=5),
is_current=True,
),
)
context.temporal_backend.store_node(node)
context.last_stored_node = node
@when('I get the current node for base "{base}"')
def step_when_get_current(context: Any, base: str) -> None:
context.current_node = context.temporal_backend.get_current(base)
@then('the current node uri should be "{expected}"')
def step_then_current_node_uri(context: Any, expected: str) -> None:
assert context.current_node is not None
assert context.current_node.node_uri == expected
@then("the current node should be None")
def step_then_current_node_none(context: Any) -> None:
assert context.current_node is None
@given('I create a revision from "{old}" to "{new}"')
@when('I create a revision from "{old}" to "{new}"')
def step_when_create_revision(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_current=True,
is_revision_of=old,
),
)
context.new_revision = context.temporal_backend.create_revision(
old,
new_node,
now,
)
@then('the old node "{uri}" should not be current')
def step_then_old_node_not_current(context: Any, uri: str) -> None:
node = context.temporal_backend._nodes[uri]
assert node.temporal.is_current is False
@then('the old node "{uri}" should have valid_until set')
def step_then_old_node_valid_until(context: Any, uri: str) -> None:
node = context.temporal_backend._nodes[uri]
assert node.temporal.valid_until is not None
@then('the new node "{uri}" should be current')
def step_then_new_node_current(context: Any, uri: str) -> None:
node = context.temporal_backend._nodes[uri]
assert node.temporal.is_current is True
@then('the new node "{uri}" should have is_revision_of "{expected}"')
def step_then_new_node_revision_of(
context: Any,
uri: str,
expected: str,
) -> None:
node = context.temporal_backend._nodes[uri]
assert node.temporal.is_revision_of == expected
@then('creating a revision from nonexistent "{uri}" should raise KeyError')
def step_then_revision_nonexistent(context: Any, uri: str) -> None:
now = datetime.now(tz=UTC)
new_node = TemporalNode(
node_uri="uko:new",
source_resource="RES",
source_path="src/a.py",
temporal=TemporalMetadata(valid_from=now, is_current=True),
)
try:
context.temporal_backend.create_revision(uri, new_node, now)
msg = "Expected KeyError"
raise AssertionError(msg)
except KeyError:
pass
@then("creating a revision from blank URI should raise ValueError")
def step_then_revision_blank_uri(context: Any) -> None:
now = datetime.now(tz=UTC)
new_node = TemporalNode(
node_uri="uko:new",
source_resource="RES",
source_path="src/a.py",
temporal=TemporalMetadata(valid_from=now, is_current=True),
)
try:
context.temporal_backend.create_revision("", new_node, now)
msg = "Expected ValueError"
raise AssertionError(msg)
except ValueError:
pass
@then('creating a revision from "{uri}" to itself should raise ValueError')
def step_then_revision_same_uri(context: Any, uri: str) -> None:
now = datetime.now(tz=UTC)
same_uri_node = TemporalNode(
node_uri=uri,
source_resource="RES",
source_path="src/a.py",
temporal=TemporalMetadata(valid_from=now, is_current=True),
)
try:
context.temporal_backend.create_revision(uri, same_uri_node, now)
msg = "Expected ValueError for same-URI revision"
raise AssertionError(msg)
except ValueError:
pass
@then('storing a duplicate node "{uri}" should raise ValueError')
def step_then_store_duplicate_raises(context: Any, uri: str) -> None:
now = datetime.now(tz=UTC)
node = TemporalNode(
node_uri=uri,
source_resource="RES_DUP",
source_path="src/dup.py",
temporal=TemporalMetadata(valid_from=now, is_current=True),
)
try:
context.temporal_backend.store_node(node)
msg = "Expected ValueError for duplicate store_node"
raise AssertionError(msg)
except ValueError:
pass
# ---- get_history steps ----
@when('I get the history for base "{base}" with scope ALL')
def step_when_get_history_all(context: Any, base: str) -> None:
context.history = context.temporal_backend.get_history(
base,
TemporalScope.ALL,
)
@when('I get the history for base "{base}" with scope CURRENT')
def step_when_get_history_current(context: Any, base: str) -> None:
context.history = context.temporal_backend.get_history(
base,
TemporalScope.CURRENT,
)
@then("the history should have {count:d} nodes")
def step_then_history_count(context: Any, count: int) -> None:
assert len(context.history) == count
@then("the history should be ordered newest first")
def step_then_history_ordered(context: Any) -> None:
for i in range(len(context.history) - 1):
assert (
context.history[i].temporal.valid_from
>= context.history[i + 1].temporal.valid_from
)
@then("getting history for blank base URI should raise ValueError")
def step_then_history_blank_uri(context: Any) -> None:
try:
context.temporal_backend.get_history("", TemporalScope.ALL)
msg = "Expected ValueError"
raise AssertionError(msg)
except ValueError:
pass
# ---- revision chain steps ----
@when('I get the revision chain for "{uri}"')
def step_when_get_chain(context: Any, uri: str) -> None:
context.chain = context.temporal_backend.get_revision_chain(uri)
@then("the revision chain should have depth {expected:d}")
def step_then_chain_depth_backend(context: Any, expected: int) -> None:
assert context.chain.depth == expected
@then('the revision chain current should be "{expected}"')
def step_then_chain_current(context: Any, expected: str) -> None:
assert context.chain.current_uri == expected
@then('the revision chain predecessors should include "{uri}"')
def step_then_chain_preds_include(context: Any, uri: str) -> None:
assert uri in context.chain.predecessors
@then("getting revision chain for nonexistent URI should raise KeyError")
def step_then_chain_nonexistent(context: Any) -> None:
try:
context.temporal_backend.get_revision_chain("uko:nonexistent")
msg = "Expected KeyError"
raise AssertionError(msg)
except KeyError:
pass
# ---- query by tier steps ----
@when("I query the HOT tier")
def step_when_query_hot(context: Any) -> None:
retention = TierRetentionConfig()
context.tier_query = context.temporal_backend.query_by_tier(
ContextTier.HOT,
TemporalScope.CURRENT,
retention,
)
@when("I query the COLD tier")
def step_when_query_cold(context: Any) -> None:
retention = TierRetentionConfig()
context.tier_query = context.temporal_backend.query_by_tier(
ContextTier.COLD,
TemporalScope.ALL,
retention,
)
@then("the tier query should return {count:d} node")
def step_then_tier_query_count_singular(context: Any, count: int) -> None:
assert len(context.tier_query.nodes) == count
@then("the tier query should return {count:d} nodes")
def step_then_tier_query_count(context: Any, count: int) -> None:
assert len(context.tier_query.nodes) == count
@then("the tier query node should be current")
def step_then_tier_query_node_current(context: Any) -> None:
for node in context.tier_query.nodes:
assert node.temporal.is_current is True
# ---- mark historical steps ----
@given('I mark "{uri}" as historical')
@when('I mark "{uri}" as historical')
def step_when_mark_historical(context: Any, uri: str) -> None:
now = datetime.now(tz=UTC)
context.marked_node = context.temporal_backend.mark_historical(
uri,
now,
)
@then("the marked node should not be current")
def step_then_marked_not_current(context: Any) -> None:
assert context.marked_node.temporal.is_current is False
@then("the marked node should have valid_until set")
def step_then_marked_valid_until(context: Any) -> None:
assert context.marked_node.temporal.valid_until is not None
@then("marking blank URI as historical should raise ValueError")
def step_then_mark_blank_uri(context: Any) -> None:
try:
context.temporal_backend.mark_historical(
"",
datetime.now(tz=UTC),
)
msg = "Expected ValueError"
raise AssertionError(msg)
except ValueError:
pass
@then("marking nonexistent URI as historical should raise KeyError")
def step_then_mark_nonexistent(context: Any) -> None:
try:
context.temporal_backend.mark_historical(
"uko:nonexistent",
datetime.now(tz=UTC),
)
msg = "Expected KeyError"
raise AssertionError(msg)
except KeyError:
pass
# ---- Edge-case guards (Round 3 bug-hunt fixes) ----
@then(
'marking already-historical "{uri}" should raise ValueError',
)
def step_then_mark_already_historical(context: Any, uri: str) -> None:
try:
context.temporal_backend.mark_historical(uri, datetime.now(tz=UTC))
msg = "Expected ValueError for already-historical node"
raise AssertionError(msg)
except ValueError:
pass
@then(
'creating a revision from historical "{current}" '
'to "{new_uri}" should raise ValueError',
)
def step_then_revise_historical_raises(
context: Any,
current: str,
new_uri: str,
) -> None:
now = datetime.now(tz=UTC)
new_node = TemporalNode(
node_uri=new_uri,
source_resource="RES001",
source_path="src/auth.py",
temporal=TemporalMetadata(
valid_from=now,
is_current=True,
is_revision_of=current,
),
)
try:
context.temporal_backend.create_revision(current, new_node, now)
msg = "Expected ValueError for revising historical node"
raise AssertionError(msg)
except ValueError:
pass
@then('the revision chain current_uri should be "{expected}"')
def step_then_chain_current_uri(context: Any, expected: str) -> None:
assert context.chain.current_uri == expected
@then('the revision chain predecessors should contain "{expected}"')
def step_then_chain_predecessors_contain(context: Any, expected: str) -> None:
assert expected in context.chain.predecessors
@given("a stored temporal node with branching revision chain")
def step_given_branching_chain(context: Any) -> None:
"""Create A → B and A → C (sibling branches) via _nodes."""
now = datetime.now(tz=UTC)
base = "uko-py:class/Branch"
nodes = context.temporal_backend._nodes
for suffix, meta_kw in [
(
"_A",
{
"valid_from": now - timedelta(hours=2),
"valid_until": now - timedelta(hours=1),
"is_current": False,
},
),
(
"_B",
{
"valid_from": now - timedelta(hours=1),
"is_current": True,
"is_revision_of": f"{base}_A",
},
),
("_C", {"valid_from": now, "is_current": True, "is_revision_of": f"{base}_A"}),
]:
uri = f"{base}{suffix}"
nodes[uri] = TemporalNode(
node_uri=uri,
source_resource="RES001",
source_path="src/a.py",
temporal=TemporalMetadata(**meta_kw),
)
@then('the revision chain should not contain "{uri}"')
def step_then_chain_not_contain(context: Any, uri: str) -> None:
assert uri not in context.chain.all_uris, (
f"{uri} should not be in chain {context.chain.all_uris}"
)
# ---- Diff-coverage steps (RECENT scope, cycle guard, WARM tier) ----
@when('I get the history for base "{base}" with scope RECENT')
def step_when_get_history_recent(context: Any, base: str) -> None:
context.history = context.temporal_backend.get_history(
base,
TemporalScope.RECENT,
)
@given("a stored temporal node with cyclic revision chain")
def step_given_cyclic_revision_chain(context: Any) -> None:
"""Create two nodes whose is_revision_of pointers form a cycle."""
now = datetime.now(tz=UTC)
for uri, meta_kw in [
(
"uko-py:class/Cycle_A",
{
"valid_from": now - timedelta(days=2),
"valid_until": now - timedelta(days=1),
"is_current": False,
"is_revision_of": "uko-py:class/Cycle_B",
},
),
(
"uko-py:class/Cycle_B",
{
"valid_from": now,
"is_current": True,
"is_revision_of": "uko-py:class/Cycle_A",
},
),
]:
context.temporal_backend.store_node(
TemporalNode(
node_uri=uri,
source_resource="RES001",
source_path="src/cycle.py",
temporal=TemporalMetadata(**meta_kw),
)
)
@given("a stored temporal node with dangling predecessor")
def step_given_dangling_predecessor(context: Any) -> None:
context.temporal_backend.store_node(
TemporalNode(
node_uri="uko-py:class/Dangling_B",
source_resource="RES001",
source_path="src/dangling.py",
temporal=TemporalMetadata(
valid_from=datetime.now(tz=UTC),
is_current=True,
is_revision_of="uko-py:class/Dangling_A",
),
)
)