forked from HAL9000/cleveragents-core
f324575a3b
The @tdd_expected_fail tag was already absent from the tag lines of both features/tdd_context_tier_runtime.feature (bug #821) and features/tdd_plan_apply_yes_flag.feature (bug #932). However, the feature description and step-definition docstring for bug #821 still referenced the @tdd_expected_fail tag and described the bug as unfixed. Updated the feature description in tdd_context_tier_runtime.feature and the module docstring in tdd_context_tier_runtime_steps.py to reflect that bug #821 has been fixed and these tests now serve as permanent regression guards. No changes were needed for bug #932 as its files had no stale references. Permanent tags @tdd_issue / @tdd_issue_821 and @tdd_issue / @tdd_issue_932 remain in place per the CONTRIBUTING.md TDD Issue Test Tags convention. ISSUES CLOSED: #1206 Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""Step definitions for TDD Issue #821 — context tier runtime logic.
|
|
|
|
These steps exercise ``ContextTierService`` and verify that it automatically
|
|
promotes fragments on repeated access, demotes stale fragments, and evicts
|
|
fragments when the hot tier budget overflows.
|
|
|
|
Bug #821 identified that ``ContextTierService`` had data models and manual
|
|
``promote()``/``demote()``/``evict_lru()`` methods but NO automatic runtime
|
|
logic. The bug has been fixed — these tests now serve as a permanent
|
|
regression guard confirming the runtime logic works correctly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.context_tiers import ContextTierService
|
|
from cleveragents.domain.models.acms.tiers import (
|
|
ContextTier,
|
|
TierBudget,
|
|
TieredFragment,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FRAGMENT_ID: str = "frag-tier-runtime-001"
|
|
_STALE_FRAGMENT_ID: str = "frag-stale-hot-001"
|
|
|
|
|
|
def _make_fragment(
|
|
fragment_id: str,
|
|
tier: ContextTier,
|
|
token_count: int = 50,
|
|
content: str = "test content",
|
|
last_accessed: datetime | None = None,
|
|
) -> TieredFragment:
|
|
"""Create a ``TieredFragment`` with the given properties."""
|
|
kwargs: dict[str, object] = {
|
|
"fragment_id": fragment_id,
|
|
"content": content,
|
|
"tier": tier,
|
|
"token_count": token_count,
|
|
"project_name": "test-project",
|
|
}
|
|
if last_accessed is not None:
|
|
kwargs["last_accessed"] = last_accessed
|
|
return TieredFragment(**kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 1: Promotion on repeated access
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a context tier service with default budget")
|
|
def step_service_default_budget(context: Context) -> None:
|
|
"""Create a ContextTierService with the default TierBudget."""
|
|
context.tier_service = ContextTierService(settings=None)
|
|
|
|
|
|
@given("a fragment stored in the cold tier")
|
|
def step_fragment_in_cold(context: Context) -> None:
|
|
"""Store a fragment in the cold tier."""
|
|
frag: TieredFragment = _make_fragment(
|
|
fragment_id=_FRAGMENT_ID,
|
|
tier=ContextTier.COLD,
|
|
token_count=50,
|
|
)
|
|
service: ContextTierService = context.tier_service
|
|
service.store(frag)
|
|
|
|
# Verify it is actually in cold tier
|
|
stored: TieredFragment | None = service.get(_FRAGMENT_ID)
|
|
assert stored is not None, "Fragment was not stored"
|
|
assert stored.tier == ContextTier.COLD, (
|
|
f"Fragment should be in cold tier, got {stored.tier}"
|
|
)
|
|
context.fragment_id = _FRAGMENT_ID
|
|
|
|
|
|
@when("I access the fragment {n:d} times via get")
|
|
def step_access_n_times(context: Context, n: int) -> None:
|
|
"""Access the fragment N times through the get() method."""
|
|
service: ContextTierService = context.tier_service
|
|
frag_id: str = context.fragment_id
|
|
for _ in range(n):
|
|
result: TieredFragment | None = service.get(frag_id)
|
|
assert result is not None, "Fragment disappeared during access"
|
|
context.accessed_fragment = service.get(frag_id)
|
|
|
|
|
|
@then("the fragment should have been promoted to warm or hot tier")
|
|
def step_fragment_promoted(context: Context) -> None:
|
|
"""Assert the fragment was automatically promoted after repeated access.
|
|
|
|
Bug #821: ``get()`` increments ``access_count`` and updates
|
|
``last_accessed`` but never calls ``promote()`` or any equivalent
|
|
runtime tier-transition logic. The fragment remains in the cold
|
|
tier regardless of how many times it is accessed.
|
|
"""
|
|
service: ContextTierService = context.tier_service
|
|
frag: TieredFragment | None = service.get(context.fragment_id)
|
|
assert frag is not None, "Fragment not found"
|
|
|
|
# The fragment should have been auto-promoted out of cold tier
|
|
assert frag.tier != ContextTier.COLD, (
|
|
f"Fragment is still in COLD tier after repeated access "
|
|
f"(access_count={frag.access_count}). ContextTierService.get() "
|
|
f"updates access metadata but does not auto-promote fragments "
|
|
f"based on access patterns (bug #821). Expected tier to be "
|
|
f"WARM or HOT."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 2: Demotion on staleness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a fragment stored in the hot tier with a stale last_accessed timestamp")
|
|
def step_stale_hot_fragment(context: Context) -> None:
|
|
"""Store a fragment in the hot tier with a very old last_accessed."""
|
|
stale_time: datetime = datetime.now(tz=UTC) - timedelta(hours=24)
|
|
frag: TieredFragment = _make_fragment(
|
|
fragment_id=_STALE_FRAGMENT_ID,
|
|
tier=ContextTier.HOT,
|
|
token_count=100,
|
|
last_accessed=stale_time,
|
|
)
|
|
service: ContextTierService = context.tier_service
|
|
service.store(frag)
|
|
context.stale_fragment_id = _STALE_FRAGMENT_ID
|
|
|
|
|
|
@when("I invoke the staleness enforcement runtime")
|
|
def step_invoke_staleness(context: Context) -> None:
|
|
"""Attempt to invoke the staleness enforcement method.
|
|
|
|
Bug #821: There is no method on ContextTierService that enforces
|
|
staleness-based demotion. Methods like ``enforce_staleness()``,
|
|
``apply_tier_policy()``, or ``tick()`` do not exist. We attempt
|
|
to call the most likely candidate names; if none exist, we record
|
|
the failure.
|
|
"""
|
|
service: ContextTierService = context.tier_service
|
|
context.staleness_invoked = False
|
|
|
|
# Try plausible method names for staleness enforcement
|
|
for method_name in (
|
|
"enforce_staleness",
|
|
"apply_tier_policy",
|
|
"tick",
|
|
"enforce_demotion_policy",
|
|
"run_lifecycle",
|
|
"apply_lifecycle",
|
|
):
|
|
method = getattr(service, method_name, None)
|
|
if callable(method):
|
|
method()
|
|
context.staleness_invoked = True
|
|
return
|
|
|
|
# No runtime method found — record this for the assertion step
|
|
context.staleness_invoked = False
|
|
|
|
|
|
@then("the fragment should have been demoted to warm or cold tier")
|
|
def step_fragment_demoted(context: Context) -> None:
|
|
"""Assert the stale fragment was automatically demoted.
|
|
|
|
Bug #821: ContextTierService has no staleness enforcement runtime.
|
|
There is no method that inspects ``last_accessed`` timestamps and
|
|
auto-demotes stale hot-tier fragments. The fragment stays in the
|
|
hot tier indefinitely.
|
|
"""
|
|
service: ContextTierService = context.tier_service
|
|
|
|
# First check: was there even a method to invoke?
|
|
assert context.staleness_invoked, (
|
|
"No staleness enforcement method found on ContextTierService. "
|
|
"Tried: enforce_staleness(), apply_tier_policy(), tick(), "
|
|
"enforce_demotion_policy(), run_lifecycle(), apply_lifecycle(). "
|
|
"ContextTierService has no runtime logic for automatic demotion "
|
|
"of stale fragments (bug #821)."
|
|
)
|
|
|
|
# Second check: did the fragment actually move?
|
|
frag: TieredFragment | None = service.get(context.stale_fragment_id)
|
|
assert frag is not None, "Stale fragment not found"
|
|
assert frag.tier != ContextTier.HOT, (
|
|
f"Stale fragment is still in HOT tier "
|
|
f"(last_accessed={frag.last_accessed}). "
|
|
f"ContextTierService has no runtime staleness enforcement — "
|
|
f"stale fragments are never automatically demoted (bug #821). "
|
|
f"Expected tier to be WARM or COLD."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 3: Eviction on budget overflow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a context tier service with a small hot tier budget of {n:d} tokens")
|
|
def step_service_small_budget(context: Context, n: int) -> None:
|
|
"""Create a ContextTierService with a constrained hot tier budget."""
|
|
# ContextTierService accepts Settings; we pass None and override budget
|
|
service: ContextTierService = ContextTierService(settings=None)
|
|
service._budget = TierBudget(
|
|
max_tokens_hot=n,
|
|
max_decisions_warm=500,
|
|
max_decisions_cold=5000,
|
|
)
|
|
context.tier_service = service
|
|
context.hot_budget_tokens = n
|
|
|
|
|
|
@given("the hot tier is filled to its token budget limit")
|
|
def step_fill_hot_tier(context: Context) -> None:
|
|
"""Fill the hot tier to exactly the token budget limit."""
|
|
service: ContextTierService = context.tier_service
|
|
budget_tokens: int = context.hot_budget_tokens
|
|
|
|
# Store fragments totalling exactly the budget limit
|
|
# Use 50-token fragments so 100 tokens = 2 fragments
|
|
frag_size: int = 50
|
|
num_frags: int = budget_tokens // frag_size
|
|
context.initial_hot_fragment_ids = []
|
|
|
|
for i in range(num_frags):
|
|
frag_id: str = f"frag-budget-{i:03d}"
|
|
frag: TieredFragment = _make_fragment(
|
|
fragment_id=frag_id,
|
|
tier=ContextTier.HOT,
|
|
token_count=frag_size,
|
|
content="x" * frag_size,
|
|
last_accessed=datetime.now(tz=UTC) - timedelta(minutes=num_frags - i),
|
|
)
|
|
service.store(frag)
|
|
context.initial_hot_fragment_ids.append(frag_id)
|
|
|
|
# Record the oldest fragment (lowest last_accessed)
|
|
context.oldest_fragment_id = context.initial_hot_fragment_ids[0]
|
|
|
|
metrics = service.get_metrics()
|
|
assert metrics.hot_count == num_frags, (
|
|
f"Expected {num_frags} fragments in hot tier, got {metrics.hot_count}"
|
|
)
|
|
|
|
|
|
@when("I store one more fragment in the hot tier")
|
|
def step_store_overflow(context: Context) -> None:
|
|
"""Store one more fragment that would exceed the hot tier budget."""
|
|
service: ContextTierService = context.tier_service
|
|
overflow_frag: TieredFragment = _make_fragment(
|
|
fragment_id="frag-overflow-001",
|
|
tier=ContextTier.HOT,
|
|
token_count=50,
|
|
content="overflow content",
|
|
)
|
|
service.store(overflow_frag)
|
|
context.overflow_fragment_id = "frag-overflow-001"
|
|
|
|
|
|
@then("the oldest fragment should have been evicted from the hot tier")
|
|
def step_oldest_evicted(context: Context) -> None:
|
|
"""Assert the oldest hot-tier fragment was automatically evicted.
|
|
|
|
Bug #821: ``store()`` does not check ``TierBudget.max_tokens_hot``
|
|
and does not trigger ``evict_lru()`` when the budget is exceeded.
|
|
The hot tier grows without bound.
|
|
"""
|
|
service: ContextTierService = context.tier_service
|
|
budget_tokens: int = context.hot_budget_tokens
|
|
|
|
# Calculate total tokens now in hot tier
|
|
hot_total_tokens: int = sum(f.token_count for f in service._hot.values())
|
|
|
|
# The hot tier should not exceed its budget
|
|
assert hot_total_tokens <= budget_tokens, (
|
|
f"Hot tier has {hot_total_tokens} tokens but budget is "
|
|
f"{budget_tokens} tokens. store() does not enforce "
|
|
f"TierBudget.max_tokens_hot or trigger automatic LRU "
|
|
f"eviction when the budget is exceeded (bug #821)."
|
|
)
|
|
|
|
# The oldest fragment should have been evicted
|
|
oldest_id: str = context.oldest_fragment_id
|
|
oldest_frag: TieredFragment | None = service.get(oldest_id)
|
|
assert oldest_frag is None or oldest_frag.tier != ContextTier.HOT, (
|
|
f"Oldest fragment '{oldest_id}' is still in the hot tier after "
|
|
f"budget overflow. ContextTierService.store() does not auto-evict "
|
|
f"the least-recently-used fragment when hot tier budget is "
|
|
f"exceeded (bug #821)."
|
|
)
|