feat(estimation): build historical plan statistics query service for estimation context assembly #1295
@@ -0,0 +1,157 @@
|
||||
Feature: Historical Plan Statistics
|
||||
As a developer
|
||||
I want to query historical plan statistics for an action
|
||||
So that the estimation actor can make data-driven cost and risk estimates
|
||||
|
||||
# HistoricalPlanStats domain model
|
||||
|
||||
Scenario: HistoricalPlanStats can be created with all fields
|
||||
Given I create historical plan stats with all fields populated
|
||||
Then the historical stats should have all expected values
|
||||
And the historical stats sample_size should be 5
|
||||
|
||||
Scenario: HistoricalPlanStats empty factory returns valid zero stats
|
||||
Given I create empty historical plan stats for action "local/refactor"
|
||||
Then the historical stats sample_size should be 0
|
||||
And the historical stats mean_cost_usd should be 0.0
|
||||
And the historical stats success_rate should be 0.0
|
||||
|
||||
Scenario: HistoricalPlanStats is frozen (immutable)
|
||||
Given I create empty historical plan stats for action "local/refactor"
|
||||
When I try to modify the historical stats mean_cost_usd
|
||||
Then a validation error should be raised for frozen historical stats
|
||||
|
||||
Scenario: HistoricalPlanStats as_context_dict includes non-zero metrics
|
||||
Given I create historical plan stats with all fields populated
|
||||
When I call as_context_dict on the historical stats
|
||||
Then the context dict should include action_name
|
||||
And the context dict should include sample_size
|
||||
And the context dict should include mean_cost_usd
|
||||
And the context dict should include success_rate
|
||||
|
||||
Scenario: HistoricalPlanStats as_context_dict excludes zero metrics
|
||||
Given I create empty historical plan stats for action "local/new-action"
|
||||
When I call as_context_dict on the historical stats
|
||||
Then the context dict should include action_name
|
||||
And the context dict should include sample_size
|
||||
And the context dict should not include mean_cost_usd
|
||||
|
||||
Scenario: HistoricalPlanStats rejects negative cost
|
||||
When I try to create historical stats with negative mean_cost_usd
|
||||
Then a validation error should be raised for negative cost
|
||||
|
||||
Scenario: HistoricalPlanStats rejects success_rate above 1.0
|
||||
When I try to create historical stats with success_rate 1.5
|
||||
Then a validation error should be raised for success_rate bounds
|
||||
|
||||
# HistoricalPlanStatsService
|
||||
|
||||
Scenario: Service returns empty stats when no completed plans exist
|
||||
Given I have a historical plan stats service with no completed plans
|
||||
When I query stats for action "local/refactor"
|
||||
Then the returned stats sample_size should be 0
|
||||
And the returned stats action_name should be "local/refactor"
|
||||
|
||||
Scenario: Service computes stats from completed plans
|
||||
Given I have a historical plan stats service with 3 completed plans
|
||||
When I query stats for action "local/refactor"
|
||||
Then the returned stats sample_size should be 3
|
||||
And the returned stats mean_cost_usd should be greater than 0
|
||||
And the returned stats success_rate should be greater than 0
|
||||
|
||||
Scenario: Service handles empty action name
|
||||
Given I have a historical plan stats service with no completed plans
|
||||
When I query stats for an empty action name
|
||||
Then the returned stats action_name should be "<unknown>"
|
||||
And the returned stats sample_size should be 0
|
||||
|
||||
Scenario: Service handles limit of zero
|
||||
Given I have a historical plan stats service with 3 completed plans
|
||||
When I query stats for action "local/refactor" with limit 0
|
||||
Then the returned stats sample_size should be 0
|
||||
|
||||
Scenario: Service computes correct median for even number of plans
|
||||
Given I have a historical plan stats service with 4 completed plans with varied costs
|
||||
When I query stats for action "local/refactor"
|
||||
Then the returned stats median_cost_usd should be correctly computed
|
||||
|
||||
Scenario: Service computes p90 cost correctly
|
||||
Given I have a historical plan stats service with 10 completed plans
|
||||
When I query stats for action "local/refactor"
|
||||
Then the returned stats p90_cost_usd should be greater than median_cost_usd
|
||||
|
||||
Scenario: Service handles plans with no cost metadata
|
||||
Given I have a historical plan stats service with plans missing cost data
|
||||
When I query stats for action "local/refactor"
|
||||
Then the returned stats sample_size should be 2
|
||||
And the returned stats mean_cost_usd should be 0.0
|
||||
|
||||
Scenario: Service handles plans with no timestamps
|
||||
Given I have a historical plan stats service with plans missing timestamps
|
||||
When I query stats for action "local/refactor"
|
||||
Then the returned stats sample_size should be 1
|
||||
And the returned stats mean_duration_seconds should be 0.0
|
||||
|
||||
# EstimationHistoricalStatsProvider (ACMS integration)
|
||||
|
||||
Scenario: Provider returns 0.0 confidence when no action name is set
|
||||
Given I have an estimation historical stats provider with no action name
|
||||
When I check the provider confidence for a request
|
||||
Then the provider confidence should be 0.0
|
||||
|
||||
Scenario: Provider returns 0.8 confidence when action name is set
|
||||
Given I have an estimation historical stats provider with action "local/refactor"
|
||||
When I check the provider confidence for a request
|
||||
Then the provider confidence should be 0.8
|
||||
|
||||
Scenario: Provider produces a context fragment with stats
|
||||
Given I have an estimation historical stats provider with action "local/refactor"
|
||||
And the provider has 3 completed plans available
|
||||
When I assemble fragments with the provider
|
||||
Then the assembled fragments should include a historical stats fragment
|
||||
|
||||
Scenario: Provider returns unchanged fragments when no history exists
|
||||
Given I have an estimation historical stats provider with action "local/new-action"
|
||||
And the provider has no completed plans available
|
||||
When I assemble fragments with the provider
|
||||
Then the assembled fragments should be unchanged
|
||||
|
||||
Scenario: Provider explain returns meaningful description
|
||||
Given I have an estimation historical stats provider with action "local/refactor"
|
||||
Then the provider explain should mention historical plan statistics
|
||||
|
||||
Scenario: Provider name returns the expected strategy name
|
||||
Given I have an estimation historical stats provider with action "local/refactor"
|
||||
Then the provider name should be "estimation-historical-stats"
|
||||
|
||||
Scenario: Provider capabilities include temporal archaeology
|
||||
Given I have an estimation historical stats provider with action "local/refactor"
|
||||
Then the provider capabilities should support temporal archaeology
|
||||
|
||||
Scenario: Provider set_action_name updates the action for querying
|
||||
Given I have an estimation historical stats provider with no action name
|
||||
When I set the provider action name to "local/updated-action"
|
||||
Then the provider confidence should be 0.8 for a request
|
||||
|
||||
Scenario: Provider handles exception from stats service gracefully
|
||||
Given I have an estimation historical stats provider with a failing service
|
||||
When I assemble fragments with the failing provider
|
||||
Then the assembled fragments from failing provider should be unchanged
|
||||
|
||||
Scenario: Provider skips fragment when it exceeds budget
|
||||
Given I have an estimation historical stats provider with action "local/refactor"
|
||||
And the provider has 3 completed plans available
|
||||
When I assemble fragments with the provider using tiny budget
|
||||
Then the assembled fragments with tiny budget should be unchanged
|
||||
|
||||
# Repository method
|
||||
|
||||
Scenario: Repository get_completed_plans_by_action returns empty for unknown action
|
||||
Given I have a plan repository with some plans
|
||||
When I query completed plans for action "local/nonexistent"
|
||||
Then the returned plan list should be empty
|
||||
|
||||
Scenario: Repository get_completed_plans_by_action returns only terminal plans
|
||||
Given I have a plan repository with mixed state plans
|
||||
When I query completed plans for action "local/test-action"
|
||||
Then the returned plans should only have terminal states
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Step definitions for Historical Plan Statistics feature tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from cleveragents.application.services.estimation_context_provider import (
|
||||
EstimationHistoricalStatsProvider,
|
||||
)
|
||||
from cleveragents.application.services.historical_plan_stats_service import (
|
||||
HistoricalPlanStatsService,
|
||||
)
|
||||
from cleveragents.domain.models.core.context_fragment import (
|
||||
ContextBudget,
|
||||
)
|
||||
from cleveragents.domain.models.core.historical_plan_stats import HistoricalPlanStats
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HistoricalPlanStats domain model steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I create historical plan stats with all fields populated")
|
||||
def step_create_stats_all_fields(context: Context) -> None:
|
||||
"""Create a HistoricalPlanStats with all fields populated."""
|
||||
context.historical_stats = HistoricalPlanStats(
|
||||
action_name="local/refactor",
|
||||
sample_size=5,
|
||||
mean_cost_usd=0.125,
|
||||
median_cost_usd=0.10,
|
||||
p90_cost_usd=0.25,
|
||||
mean_duration_seconds=120.5,
|
||||
median_duration_seconds=100.0,
|
||||
avg_step_count=8.4,
|
||||
avg_child_plan_count=2.6,
|
||||
success_rate=0.8,
|
||||
)
|
||||
|
||||
|
||||
@given('I create empty historical plan stats for action "{action_name}"')
|
||||
def step_create_empty_stats(context: Context, action_name: str) -> None:
|
||||
"""Create empty stats via the factory method."""
|
||||
context.historical_stats = HistoricalPlanStats.empty(action_name=action_name)
|
||||
|
||||
|
||||
@then("the historical stats should have all expected values")
|
||||
def step_check_stats_all_values(context: Context) -> None:
|
||||
"""Verify all fields on the historical stats."""
|
||||
stats: HistoricalPlanStats = context.historical_stats
|
||||
assert stats.action_name == "local/refactor"
|
||||
assert stats.mean_cost_usd == 0.125
|
||||
assert stats.median_cost_usd == 0.10
|
||||
assert stats.p90_cost_usd == 0.25
|
||||
assert stats.mean_duration_seconds == 120.5
|
||||
assert stats.median_duration_seconds == 100.0
|
||||
assert stats.avg_step_count == 8.4
|
||||
assert stats.avg_child_plan_count == 2.6
|
||||
assert stats.success_rate == 0.8
|
||||
|
||||
|
||||
@then("the historical stats sample_size should be {expected:d}")
|
||||
def step_check_sample_size(context: Context, expected: int) -> None:
|
||||
"""Verify sample_size."""
|
||||
stats: HistoricalPlanStats = context.historical_stats
|
||||
assert stats.sample_size == expected, (
|
||||
f"Expected {expected}, got {stats.sample_size}"
|
||||
)
|
||||
|
||||
|
||||
@then("the historical stats mean_cost_usd should be {expected:f}")
|
||||
def step_check_mean_cost(context: Context, expected: float) -> None:
|
||||
"""Verify mean_cost_usd."""
|
||||
stats: HistoricalPlanStats = context.historical_stats
|
||||
assert abs(stats.mean_cost_usd - expected) < 1e-6, (
|
||||
f"Expected {expected}, got {stats.mean_cost_usd}"
|
||||
)
|
||||
|
||||
|
||||
@then("the historical stats success_rate should be {expected:f}")
|
||||
def step_check_success_rate(context: Context, expected: float) -> None:
|
||||
"""Verify success_rate."""
|
||||
stats: HistoricalPlanStats = context.historical_stats
|
||||
assert abs(stats.success_rate - expected) < 1e-6, (
|
||||
f"Expected {expected}, got {stats.success_rate}"
|
||||
)
|
||||
|
||||
|
||||
@when("I try to modify the historical stats mean_cost_usd")
|
||||
def step_try_modify_stats(context: Context) -> None:
|
||||
"""Try to modify a frozen field."""
|
||||
try:
|
||||
context.historical_stats.mean_cost_usd = 999.0 # type: ignore[misc]
|
||||
context.modify_error = None
|
||||
except (PydanticValidationError, AttributeError) as exc:
|
||||
context.modify_error = exc
|
||||
|
||||
|
||||
@then("a validation error should be raised for frozen historical stats")
|
||||
def step_check_frozen_error(context: Context) -> None:
|
||||
"""Verify that modification raised an error."""
|
||||
assert context.modify_error is not None, "Expected error but none was raised"
|
||||
|
||||
|
||||
@when("I call as_context_dict on the historical stats")
|
||||
def step_call_context_dict(context: Context) -> None:
|
||||
"""Call as_context_dict."""
|
||||
context.context_dict = context.historical_stats.as_context_dict()
|
||||
|
||||
|
||||
@then("the context dict should include action_name")
|
||||
def step_context_dict_has_action_name(context: Context) -> None:
|
||||
"""Verify context dict includes action_name."""
|
||||
assert "action_name" in context.context_dict
|
||||
|
||||
|
||||
@then("the context dict should include sample_size")
|
||||
def step_context_dict_has_sample_size(context: Context) -> None:
|
||||
"""Verify context dict includes sample_size."""
|
||||
assert "sample_size" in context.context_dict
|
||||
|
||||
|
||||
@then("the context dict should include mean_cost_usd")
|
||||
def step_context_dict_has_mean_cost(context: Context) -> None:
|
||||
"""Verify context dict includes mean_cost_usd."""
|
||||
assert "mean_cost_usd" in context.context_dict
|
||||
|
||||
|
||||
@then("the context dict should include success_rate")
|
||||
def step_context_dict_has_success_rate(context: Context) -> None:
|
||||
"""Verify context dict includes success_rate."""
|
||||
assert "success_rate" in context.context_dict
|
||||
|
||||
|
||||
@then("the context dict should not include mean_cost_usd")
|
||||
def step_context_dict_excludes_mean_cost(context: Context) -> None:
|
||||
"""Verify context dict excludes mean_cost_usd (zero value)."""
|
||||
assert "mean_cost_usd" not in context.context_dict
|
||||
|
||||
|
||||
@when("I try to create historical stats with negative mean_cost_usd")
|
||||
def step_create_negative_cost(context: Context) -> None:
|
||||
"""Try to create stats with negative cost."""
|
||||
try:
|
||||
HistoricalPlanStats(action_name="test", mean_cost_usd=-1.0)
|
||||
context.validation_error = None
|
||||
except PydanticValidationError as exc:
|
||||
context.validation_error = exc
|
||||
|
||||
|
||||
@then("a validation error should be raised for negative cost")
|
||||
def step_check_negative_cost_error(context: Context) -> None:
|
||||
"""Verify validation error for negative cost."""
|
||||
assert context.validation_error is not None, (
|
||||
"Expected validation error but none was raised"
|
||||
)
|
||||
|
||||
|
||||
@when("I try to create historical stats with success_rate {rate}")
|
||||
def step_create_bad_success_rate(context: Context, rate: str) -> None:
|
||||
"""Try to create stats with invalid success_rate."""
|
||||
try:
|
||||
HistoricalPlanStats(action_name="test", success_rate=float(rate))
|
||||
context.validation_error = None
|
||||
except PydanticValidationError as exc:
|
||||
context.validation_error = exc
|
||||
|
||||
|
||||
@then("a validation error should be raised for success_rate bounds")
|
||||
def step_check_success_rate_error(context: Context) -> None:
|
||||
"""Verify validation error for success_rate bounds."""
|
||||
assert context.validation_error is not None, (
|
||||
"Expected validation error but none was raised"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock plan factory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_plan(
|
||||
*,
|
||||
action_name: str = "local/refactor",
|
||||
processing_state: str = "applied",
|
||||
cost: float = 0.10,
|
||||
duration_seconds: float = 60.0,
|
||||
step_count: int = 5,
|
||||
child_plans: int = 1,
|
||||
) -> Any:
|
||||
"""Create a mock plan domain object for testing."""
|
||||
plan = MagicMock()
|
||||
plan.action_name = action_name
|
||||
|
||||
# Processing state
|
||||
state_mock = MagicMock()
|
||||
state_mock.value = processing_state
|
||||
plan.processing_state = state_mock
|
||||
|
||||
# Cost metadata
|
||||
cost_meta = MagicMock()
|
||||
cost_meta.total_cost = cost
|
||||
plan.cost_metadata = cost_meta
|
||||
|
||||
# Timestamps
|
||||
created = datetime.now() - timedelta(seconds=duration_seconds)
|
||||
timestamps = MagicMock()
|
||||
timestamps.created_at = created
|
||||
timestamps.applied_at = datetime.now() if processing_state == "applied" else None
|
||||
timestamps.execute_completed_at = (
|
||||
datetime.now() if processing_state == "complete" else None
|
||||
)
|
||||
plan.timestamps = timestamps
|
||||
|
||||
# Step count
|
||||
plan.last_completed_step = step_count - 1 # 0-indexed
|
||||
|
||||
# Child plans
|
||||
plan.subplan_statuses = [MagicMock() for _ in range(child_plans)]
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HistoricalPlanStatsService steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have a historical plan stats service with no completed plans")
|
||||
def step_service_no_plans(context: Context) -> None:
|
||||
"""Set up service with empty repository."""
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=[])
|
||||
context.stats_service = HistoricalPlanStatsService(repo)
|
||||
|
||||
|
||||
@given("I have a historical plan stats service with plans missing cost data")
|
||||
def step_service_no_cost_data(context: Context) -> None:
|
||||
"""Set up service with plans that have no cost metadata."""
|
||||
plan1 = MagicMock()
|
||||
plan1.action_name = "local/refactor"
|
||||
state1 = MagicMock()
|
||||
state1.value = "applied"
|
||||
plan1.processing_state = state1
|
||||
plan1.cost_metadata = None
|
||||
timestamps1 = MagicMock()
|
||||
timestamps1.created_at = datetime.now() - timedelta(seconds=60)
|
||||
timestamps1.applied_at = datetime.now()
|
||||
timestamps1.execute_completed_at = None
|
||||
plan1.timestamps = timestamps1
|
||||
plan1.last_completed_step = 2
|
||||
plan1.subplan_statuses = []
|
||||
|
||||
plan2 = MagicMock()
|
||||
plan2.action_name = "local/refactor"
|
||||
state2 = MagicMock()
|
||||
state2.value = "complete"
|
||||
plan2.processing_state = state2
|
||||
plan2.cost_metadata = MagicMock()
|
||||
plan2.cost_metadata.total_cost = 0.0 # Zero cost
|
||||
timestamps2 = MagicMock()
|
||||
timestamps2.created_at = datetime.now() - timedelta(seconds=120)
|
||||
timestamps2.applied_at = None
|
||||
timestamps2.execute_completed_at = datetime.now()
|
||||
plan2.timestamps = timestamps2
|
||||
plan2.last_completed_step = 4
|
||||
plan2.subplan_statuses = []
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=[plan1, plan2])
|
||||
context.stats_service = HistoricalPlanStatsService(repo)
|
||||
|
||||
|
||||
@given("I have a historical plan stats service with plans missing timestamps")
|
||||
def step_service_no_timestamps(context: Context) -> None:
|
||||
"""Set up service with a plan missing timestamp data."""
|
||||
plan = MagicMock()
|
||||
plan.action_name = "local/refactor"
|
||||
state = MagicMock()
|
||||
state.value = "applied"
|
||||
plan.processing_state = state
|
||||
plan.cost_metadata = None
|
||||
plan.timestamps = None # No timestamps at all
|
||||
plan.last_completed_step = -1
|
||||
plan.subplan_statuses = []
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=[plan])
|
||||
context.stats_service = HistoricalPlanStatsService(repo)
|
||||
|
||||
|
||||
@given("I have a historical plan stats service with {count:d} completed plans")
|
||||
def step_service_with_plans(context: Context, count: int) -> None:
|
||||
"""Set up service with mock completed plans."""
|
||||
plans = [
|
||||
_make_mock_plan(
|
||||
cost=0.10 * (i + 1),
|
||||
duration_seconds=60.0 * (i + 1),
|
||||
step_count=3 + i,
|
||||
child_plans=i,
|
||||
processing_state="applied" if i % 2 == 0 else "complete",
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=plans)
|
||||
context.stats_service = HistoricalPlanStatsService(repo)
|
||||
|
||||
|
||||
@given(
|
||||
"I have a historical plan stats service with {count:d} completed plans with varied costs"
|
||||
)
|
||||
def step_service_with_varied_plans(context: Context, count: int) -> None:
|
||||
"""Set up service with plans having specific costs for median testing."""
|
||||
costs = [0.05, 0.10, 0.20, 0.50][:count]
|
||||
plans = [_make_mock_plan(cost=c, processing_state="applied") for c in costs]
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=plans)
|
||||
context.stats_service = HistoricalPlanStatsService(repo)
|
||||
|
||||
|
||||
@when('I query stats for action "{action_name}"')
|
||||
def step_query_stats(context: Context, action_name: str) -> None:
|
||||
"""Query stats for the given action."""
|
||||
context.returned_stats = context.stats_service.get_stats_for_action(action_name)
|
||||
|
||||
|
||||
@when("I query stats for an empty action name")
|
||||
def step_query_stats_empty(context: Context) -> None:
|
||||
"""Query stats with an empty action name."""
|
||||
context.returned_stats = context.stats_service.get_stats_for_action("")
|
||||
|
||||
|
||||
@when('I query stats for action "{action_name}" with limit {limit:d}')
|
||||
def step_query_stats_with_limit(context: Context, action_name: str, limit: int) -> None:
|
||||
"""Query stats with an explicit limit."""
|
||||
context.returned_stats = context.stats_service.get_stats_for_action(
|
||||
action_name, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@then("the returned stats sample_size should be {expected:d}")
|
||||
def step_check_returned_sample_size(context: Context, expected: int) -> None:
|
||||
"""Verify returned stats sample_size."""
|
||||
assert context.returned_stats.sample_size == expected, (
|
||||
f"Expected {expected}, got {context.returned_stats.sample_size}"
|
||||
)
|
||||
|
||||
|
||||
@then('the returned stats action_name should be "{expected}"')
|
||||
def step_check_returned_action_name(context: Context, expected: str) -> None:
|
||||
"""Verify returned stats action_name."""
|
||||
assert context.returned_stats.action_name == expected, (
|
||||
f"Expected {expected!r}, got {context.returned_stats.action_name!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the returned stats mean_cost_usd should be {expected:f}")
|
||||
def step_check_returned_mean_cost_exact(context: Context, expected: float) -> None:
|
||||
"""Verify returned stats mean_cost_usd matches exactly."""
|
||||
assert abs(context.returned_stats.mean_cost_usd - expected) < 1e-6, (
|
||||
f"Expected {expected}, got {context.returned_stats.mean_cost_usd}"
|
||||
)
|
||||
|
||||
|
||||
@then("the returned stats mean_duration_seconds should be {expected:f}")
|
||||
def step_check_returned_mean_duration_exact(context: Context, expected: float) -> None:
|
||||
"""Verify returned stats mean_duration_seconds."""
|
||||
assert abs(context.returned_stats.mean_duration_seconds - expected) < 1e-6, (
|
||||
f"Expected {expected}, got {context.returned_stats.mean_duration_seconds}"
|
||||
)
|
||||
|
||||
|
||||
@then("the returned stats mean_cost_usd should be greater than {threshold:d}")
|
||||
def step_check_mean_cost_positive(context: Context, threshold: int) -> None:
|
||||
"""Verify mean_cost_usd is above threshold."""
|
||||
assert context.returned_stats.mean_cost_usd > threshold, (
|
||||
f"Expected mean_cost > {threshold}, got {context.returned_stats.mean_cost_usd}"
|
||||
)
|
||||
|
||||
|
||||
@then("the returned stats success_rate should be greater than {threshold:d}")
|
||||
def step_check_success_rate_positive(context: Context, threshold: int) -> None:
|
||||
"""Verify success_rate is above threshold."""
|
||||
assert context.returned_stats.success_rate > threshold, (
|
||||
f"Expected success_rate > {threshold}, got {context.returned_stats.success_rate}"
|
||||
)
|
||||
|
||||
|
||||
@then("the returned stats median_cost_usd should be correctly computed")
|
||||
def step_check_median_cost_computed(context: Context) -> None:
|
||||
"""Verify median is computed (between min and max of costs)."""
|
||||
stats = context.returned_stats
|
||||
# For 4 costs [0.05, 0.10, 0.20, 0.50], median = (0.10 + 0.20) / 2 = 0.15
|
||||
assert stats.median_cost_usd > 0.0, "Median should be > 0"
|
||||
assert stats.median_cost_usd <= stats.p90_cost_usd, "Median should be <= p90"
|
||||
|
||||
|
||||
@then("the returned stats p90_cost_usd should be greater than median_cost_usd")
|
||||
def step_check_p90_above_median(context: Context) -> None:
|
||||
"""Verify p90 >= median."""
|
||||
stats = context.returned_stats
|
||||
assert stats.p90_cost_usd >= stats.median_cost_usd, (
|
||||
f"p90 ({stats.p90_cost_usd}) should be >= median ({stats.median_cost_usd})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EstimationHistoricalStatsProvider steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have an estimation historical stats provider with no action name")
|
||||
def step_provider_no_action(context: Context) -> None:
|
||||
"""Create provider without action name."""
|
||||
mock_service = MagicMock(spec=HistoricalPlanStatsService)
|
||||
context.stats_provider = EstimationHistoricalStatsProvider(mock_service)
|
||||
|
||||
|
||||
@given('I have an estimation historical stats provider with action "{action_name}"')
|
||||
def step_provider_with_action(context: Context, action_name: str) -> None:
|
||||
"""Create provider with action name."""
|
||||
mock_service = MagicMock(spec=HistoricalPlanStatsService)
|
||||
context.stats_provider = EstimationHistoricalStatsProvider(
|
||||
mock_service, default_action_name=action_name
|
||||
)
|
||||
context.mock_stats_service = mock_service
|
||||
|
||||
|
||||
@given("the provider has {count:d} completed plans available")
|
||||
def step_provider_has_plans(context: Context, count: int) -> None:
|
||||
"""Configure mock service to return stats with data."""
|
||||
mock_stats = HistoricalPlanStats(
|
||||
action_name="local/refactor",
|
||||
sample_size=count,
|
||||
mean_cost_usd=0.15,
|
||||
median_cost_usd=0.12,
|
||||
p90_cost_usd=0.30,
|
||||
mean_duration_seconds=90.0,
|
||||
median_duration_seconds=80.0,
|
||||
avg_step_count=6.0,
|
||||
avg_child_plan_count=1.5,
|
||||
success_rate=0.75,
|
||||
)
|
||||
context.mock_stats_service.get_stats_for_action.return_value = mock_stats
|
||||
|
||||
|
||||
@given("the provider has no completed plans available")
|
||||
def step_provider_no_plans(context: Context) -> None:
|
||||
"""Configure mock service to return empty stats."""
|
||||
empty_stats = HistoricalPlanStats.empty(action_name="local/new-action")
|
||||
context.mock_stats_service.get_stats_for_action.return_value = empty_stats
|
||||
|
||||
|
||||
@when("I check the provider confidence for a request")
|
||||
def step_check_provider_confidence(context: Context) -> None:
|
||||
"""Check can_handle confidence."""
|
||||
context.provider_confidence = context.stats_provider.can_handle({})
|
||||
|
||||
|
||||
@then("the provider confidence should be {expected:f}")
|
||||
def step_verify_provider_confidence(context: Context, expected: float) -> None:
|
||||
"""Verify provider confidence value."""
|
||||
assert abs(context.provider_confidence - expected) < 1e-6, (
|
||||
f"Expected {expected}, got {context.provider_confidence}"
|
||||
)
|
||||
|
||||
|
||||
@when("I assemble fragments with the provider")
|
||||
def step_assemble_with_provider(context: Context) -> None:
|
||||
"""Run assemble with the provider."""
|
||||
budget = ContextBudget(max_tokens=4096, reserved_tokens=0)
|
||||
context.assembled_fragments = list(context.stats_provider.assemble([], budget))
|
||||
|
||||
|
||||
@then("the assembled fragments should include a historical stats fragment")
|
||||
def step_check_has_stats_fragment(context: Context) -> None:
|
||||
"""Verify the assembled fragments contain a historical stats fragment."""
|
||||
stats_frags = [
|
||||
f
|
||||
for f in context.assembled_fragments
|
||||
if f.metadata.get("type") == "historical_plan_stats"
|
||||
]
|
||||
assert len(stats_frags) == 1, (
|
||||
f"Expected 1 historical stats fragment, got {len(stats_frags)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the assembled fragments should be unchanged")
|
||||
def step_check_fragments_unchanged(context: Context) -> None:
|
||||
"""Verify no additional fragments were added."""
|
||||
assert len(context.assembled_fragments) == 0, (
|
||||
f"Expected 0 fragments, got {len(context.assembled_fragments)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the provider explain should mention historical plan statistics")
|
||||
def step_check_provider_explain(context: Context) -> None:
|
||||
"""Verify explain() returns meaningful description."""
|
||||
explanation = context.stats_provider.explain()
|
||||
assert "historical" in explanation.lower()
|
||||
assert "plan" in explanation.lower()
|
||||
|
||||
|
||||
@then('the provider name should be "{expected}"')
|
||||
def step_check_provider_name(context: Context, expected: str) -> None:
|
||||
"""Verify the provider name."""
|
||||
assert context.stats_provider.name == expected, (
|
||||
f"Expected {expected!r}, got {context.stats_provider.name!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the provider capabilities should support temporal archaeology")
|
||||
def step_check_provider_capabilities(context: Context) -> None:
|
||||
"""Verify provider capabilities."""
|
||||
caps = context.stats_provider.capabilities
|
||||
assert caps.supports_temporal_archaeology is True
|
||||
|
||||
|
||||
@when('I set the provider action name to "{action_name}"')
|
||||
def step_set_provider_action(context: Context, action_name: str) -> None:
|
||||
"""Set the action name on the provider."""
|
||||
context.stats_provider.set_action_name(action_name)
|
||||
|
||||
|
||||
@then("the provider confidence should be {expected:f} for a request")
|
||||
def step_verify_provider_confidence_for_request(
|
||||
context: Context, expected: float
|
||||
) -> None:
|
||||
"""Verify provider confidence after set_action_name."""
|
||||
confidence = context.stats_provider.can_handle({})
|
||||
assert abs(confidence - expected) < 1e-6, f"Expected {expected}, got {confidence}"
|
||||
|
||||
|
||||
@given("I have an estimation historical stats provider with a failing service")
|
||||
def step_provider_failing_service(context: Context) -> None:
|
||||
"""Create provider with a service that raises an exception."""
|
||||
mock_service = MagicMock(spec=HistoricalPlanStatsService)
|
||||
mock_service.get_stats_for_action.side_effect = RuntimeError("DB error")
|
||||
context.failing_provider = EstimationHistoricalStatsProvider(
|
||||
mock_service, default_action_name="local/refactor"
|
||||
)
|
||||
|
||||
|
||||
@when("I assemble fragments with the failing provider")
|
||||
def step_assemble_with_failing_provider(context: Context) -> None:
|
||||
"""Assemble with a provider that has a failing stats service."""
|
||||
budget = ContextBudget(max_tokens=4096, reserved_tokens=0)
|
||||
context.failing_fragments = list(context.failing_provider.assemble([], budget))
|
||||
|
||||
|
||||
@then("the assembled fragments from failing provider should be unchanged")
|
||||
def step_check_failing_fragments_unchanged(context: Context) -> None:
|
||||
"""Verify no fragments were added when service fails."""
|
||||
assert len(context.failing_fragments) == 0, (
|
||||
f"Expected 0 fragments, got {len(context.failing_fragments)}"
|
||||
)
|
||||
|
||||
|
||||
@when("I assemble fragments with the provider using tiny budget")
|
||||
def step_assemble_with_tiny_budget(context: Context) -> None:
|
||||
"""Assemble with a very small budget that can't fit the stats fragment."""
|
||||
budget = ContextBudget(max_tokens=1, reserved_tokens=0)
|
||||
context.tiny_budget_fragments = list(context.stats_provider.assemble([], budget))
|
||||
|
||||
|
||||
@then("the assembled fragments with tiny budget should be unchanged")
|
||||
def step_check_tiny_budget_unchanged(context: Context) -> None:
|
||||
"""Verify no fragments added with tiny budget."""
|
||||
assert len(context.tiny_budget_fragments) == 0, (
|
||||
f"Expected 0 fragments, got {len(context.tiny_budget_fragments)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repository method steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have a plan repository with some plans")
|
||||
def step_repo_with_plans(context: Context) -> None:
|
||||
"""Set up mock repository returning empty for unknown actions."""
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=[])
|
||||
context.plan_repo = repo
|
||||
|
||||
|
||||
@given("I have a plan repository with mixed state plans")
|
||||
def step_repo_mixed_states(context: Context) -> None:
|
||||
"""Set up repository with mixed-state plans (only terminal ones returned)."""
|
||||
terminal_plans = [
|
||||
_make_mock_plan(processing_state="applied"),
|
||||
_make_mock_plan(processing_state="complete"),
|
||||
_make_mock_plan(processing_state="errored"),
|
||||
]
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=terminal_plans)
|
||||
context.plan_repo = repo
|
||||
|
||||
|
||||
@when('I query completed plans for action "{action_name}"')
|
||||
def step_query_completed_plans(context: Context, action_name: str) -> None:
|
||||
"""Query the repository for completed plans."""
|
||||
context.returned_plans = context.plan_repo.get_completed_plans_by_action(
|
||||
action_name
|
||||
)
|
||||
|
||||
|
||||
@then("the returned plan list should be empty")
|
||||
def step_check_plans_empty(context: Context) -> None:
|
||||
"""Verify empty plan list."""
|
||||
assert len(context.returned_plans) == 0
|
||||
|
||||
|
||||
@then("the returned plans should only have terminal states")
|
||||
def step_check_terminal_states(context: Context) -> None:
|
||||
"""Verify all returned plans have terminal states."""
|
||||
terminal = {"applied", "complete", "errored", "cancelled"}
|
||||
for plan in context.returned_plans:
|
||||
state = plan.processing_state.value
|
||||
assert state in terminal, f"Plan has non-terminal state: {state}"
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Robot Framework helper for historical plan statistics integration tests.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke historical plan
|
||||
statistics operations and verify the results.
|
||||
|
||||
Usage:
|
||||
python robot/helper_historical_plan_stats.py empty-stats
|
||||
python robot/helper_historical_plan_stats.py populated-stats
|
||||
python robot/helper_historical_plan_stats.py service-mock
|
||||
python robot/helper_historical_plan_stats.py service-empty
|
||||
python robot/helper_historical_plan_stats.py provider-integration
|
||||
python robot/helper_historical_plan_stats.py context-dict
|
||||
python robot/helper_historical_plan_stats.py percentile-calc
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC in sys.path:
|
||||
sys.path.remove(_SRC)
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.estimation_context_provider import ( # noqa: E402
|
||||
EstimationHistoricalStatsProvider,
|
||||
)
|
||||
from cleveragents.application.services.historical_plan_stats_service import ( # noqa: E402
|
||||
HistoricalPlanStatsService,
|
||||
_percentile,
|
||||
)
|
||||
from cleveragents.domain.models.core.context_fragment import ( # noqa: E402
|
||||
ContextBudget,
|
||||
)
|
||||
from cleveragents.domain.models.core.historical_plan_stats import ( # noqa: E402
|
||||
HistoricalPlanStats,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_plan(
|
||||
*,
|
||||
cost: float = 0.10,
|
||||
duration_seconds: float = 60.0,
|
||||
step_count: int = 5,
|
||||
child_plans: int = 1,
|
||||
processing_state: str = "applied",
|
||||
) -> MagicMock:
|
||||
"""Create a mock plan for testing."""
|
||||
plan = MagicMock()
|
||||
plan.action_name = "local/refactor"
|
||||
|
||||
state_mock = MagicMock()
|
||||
state_mock.value = processing_state
|
||||
plan.processing_state = state_mock
|
||||
|
||||
cost_meta = MagicMock()
|
||||
cost_meta.total_cost = cost
|
||||
plan.cost_metadata = cost_meta
|
||||
|
||||
created = datetime.now() - timedelta(seconds=duration_seconds)
|
||||
timestamps = MagicMock()
|
||||
timestamps.created_at = created
|
||||
timestamps.applied_at = datetime.now() if processing_state == "applied" else None
|
||||
timestamps.execute_completed_at = (
|
||||
datetime.now() if processing_state == "complete" else None
|
||||
)
|
||||
plan.timestamps = timestamps
|
||||
|
||||
plan.last_completed_step = step_count - 1
|
||||
plan.subplan_statuses = [MagicMock() for _ in range(child_plans)]
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def _cmd_empty_stats() -> int:
|
||||
"""Test creating empty stats."""
|
||||
stats = HistoricalPlanStats.empty(action_name="local/test")
|
||||
if stats.sample_size != 0:
|
||||
print(f"fail: expected sample_size=0, got {stats.sample_size}")
|
||||
return 1
|
||||
if stats.mean_cost_usd != 0.0:
|
||||
print(f"fail: expected mean_cost_usd=0.0, got {stats.mean_cost_usd}")
|
||||
return 1
|
||||
if stats.action_name != "local/test":
|
||||
print(f"fail: expected action_name='local/test', got {stats.action_name}")
|
||||
return 1
|
||||
print("empty-stats-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_populated_stats() -> int:
|
||||
"""Test creating populated stats."""
|
||||
stats = HistoricalPlanStats(
|
||||
action_name="local/refactor",
|
||||
sample_size=10,
|
||||
mean_cost_usd=0.50,
|
||||
median_cost_usd=0.40,
|
||||
p90_cost_usd=0.90,
|
||||
mean_duration_seconds=180.0,
|
||||
median_duration_seconds=150.0,
|
||||
avg_step_count=12.5,
|
||||
avg_child_plan_count=3.0,
|
||||
success_rate=0.85,
|
||||
)
|
||||
if stats.sample_size != 10:
|
||||
print(f"fail: expected sample_size=10, got {stats.sample_size}")
|
||||
return 1
|
||||
if abs(stats.success_rate - 0.85) > 1e-6:
|
||||
print(f"fail: expected success_rate=0.85, got {stats.success_rate}")
|
||||
return 1
|
||||
print("populated-stats-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_service_mock() -> int:
|
||||
"""Test the service with mock plans."""
|
||||
plans = [
|
||||
_make_mock_plan(cost=0.10, duration_seconds=30.0, step_count=3, child_plans=0),
|
||||
_make_mock_plan(cost=0.20, duration_seconds=60.0, step_count=5, child_plans=1),
|
||||
_make_mock_plan(cost=0.30, duration_seconds=90.0, step_count=8, child_plans=2),
|
||||
]
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=plans)
|
||||
service = HistoricalPlanStatsService(repo)
|
||||
stats = service.get_stats_for_action("local/refactor")
|
||||
|
||||
if stats.sample_size != 3:
|
||||
print(f"fail: expected sample_size=3, got {stats.sample_size}")
|
||||
return 1
|
||||
if stats.mean_cost_usd <= 0:
|
||||
print(f"fail: expected mean_cost > 0, got {stats.mean_cost_usd}")
|
||||
return 1
|
||||
if stats.success_rate <= 0:
|
||||
print(f"fail: expected success_rate > 0, got {stats.success_rate}")
|
||||
return 1
|
||||
print(f"service-mock-ok: mean_cost={stats.mean_cost_usd:.4f}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_service_empty() -> int:
|
||||
"""Test the service with empty history."""
|
||||
repo = MagicMock()
|
||||
repo.get_completed_plans_by_action = MagicMock(return_value=[])
|
||||
service = HistoricalPlanStatsService(repo)
|
||||
stats = service.get_stats_for_action("local/refactor")
|
||||
|
||||
if stats.sample_size != 0:
|
||||
print(f"fail: expected sample_size=0, got {stats.sample_size}")
|
||||
return 1
|
||||
if stats.mean_cost_usd != 0.0:
|
||||
print(f"fail: expected mean_cost=0.0, got {stats.mean_cost_usd}")
|
||||
return 1
|
||||
print("service-empty-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_provider_integration() -> int:
|
||||
"""Test the ACMS context provider integration."""
|
||||
mock_stats = HistoricalPlanStats(
|
||||
action_name="local/refactor",
|
||||
sample_size=5,
|
||||
mean_cost_usd=0.15,
|
||||
median_cost_usd=0.12,
|
||||
p90_cost_usd=0.30,
|
||||
mean_duration_seconds=90.0,
|
||||
median_duration_seconds=80.0,
|
||||
avg_step_count=6.0,
|
||||
avg_child_plan_count=1.5,
|
||||
success_rate=0.75,
|
||||
)
|
||||
mock_service = MagicMock(spec=HistoricalPlanStatsService)
|
||||
mock_service.get_stats_for_action.return_value = mock_stats
|
||||
|
||||
provider = EstimationHistoricalStatsProvider(
|
||||
mock_service, default_action_name="local/refactor"
|
||||
)
|
||||
|
||||
# Check confidence
|
||||
confidence = provider.can_handle({})
|
||||
if abs(confidence - 0.8) > 1e-6:
|
||||
print(f"fail: expected confidence=0.8, got {confidence}")
|
||||
return 1
|
||||
|
||||
# Assemble fragments
|
||||
budget = ContextBudget(max_tokens=4096, reserved_tokens=0)
|
||||
fragments = list(provider.assemble([], budget))
|
||||
stats_frags = [
|
||||
f for f in fragments if f.metadata.get("type") == "historical_plan_stats"
|
||||
]
|
||||
if len(stats_frags) != 1:
|
||||
print(f"fail: expected 1 stats fragment, got {len(stats_frags)}")
|
||||
return 1
|
||||
|
||||
# Verify explain
|
||||
explanation = provider.explain()
|
||||
if "historical" not in explanation.lower():
|
||||
print(f"fail: explain doesn't mention 'historical': {explanation}")
|
||||
return 1
|
||||
|
||||
print("provider-integration-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_dict() -> int:
|
||||
"""Test as_context_dict serialization."""
|
||||
stats = HistoricalPlanStats(
|
||||
action_name="local/refactor",
|
||||
sample_size=5,
|
||||
mean_cost_usd=0.15,
|
||||
median_cost_usd=0.0,
|
||||
p90_cost_usd=0.30,
|
||||
mean_duration_seconds=90.0,
|
||||
median_duration_seconds=0.0,
|
||||
avg_step_count=6.0,
|
||||
avg_child_plan_count=0.0,
|
||||
success_rate=0.75,
|
||||
)
|
||||
d = stats.as_context_dict()
|
||||
if "action_name" not in d:
|
||||
print("fail: context dict missing action_name")
|
||||
return 1
|
||||
if "mean_cost_usd" not in d:
|
||||
print("fail: context dict missing mean_cost_usd")
|
||||
return 1
|
||||
# Zero values should be excluded
|
||||
if "median_cost_usd" in d:
|
||||
print("fail: context dict should not include zero median_cost_usd")
|
||||
return 1
|
||||
if "avg_child_plan_count" in d:
|
||||
print("fail: context dict should not include zero avg_child_plan_count")
|
||||
return 1
|
||||
print("context-dict-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_percentile_calc() -> int:
|
||||
"""Test the _percentile helper."""
|
||||
# Empty list
|
||||
if _percentile([], 90) != 0.0:
|
||||
print("fail: percentile of empty list should be 0.0")
|
||||
return 1
|
||||
# Single value
|
||||
if _percentile([5.0], 90) != 5.0:
|
||||
print("fail: percentile of single value should be that value")
|
||||
return 1
|
||||
# Known values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], p90
|
||||
values = sorted([float(i) for i in range(1, 11)])
|
||||
p90 = _percentile(values, 90)
|
||||
# p90 of [1..10] = 1 + 0.9 * 9 = 9.1
|
||||
if abs(p90 - 9.1) > 0.01:
|
||||
print(f"fail: expected p90~9.1, got {p90}")
|
||||
return 1
|
||||
print(f"percentile-ok: p90={p90}")
|
||||
return 0
|
||||
|
||||
|
||||
COMMANDS: dict[str, Callable[[], int]] = {
|
||||
"empty-stats": _cmd_empty_stats,
|
||||
"populated-stats": _cmd_populated_stats,
|
||||
"service-mock": _cmd_service_mock,
|
||||
"service-empty": _cmd_service_empty,
|
||||
"provider-integration": _cmd_provider_integration,
|
||||
"context-dict": _cmd_context_dict,
|
||||
"percentile-calc": _cmd_percentile_calc,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point."""
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(COMMANDS)}>")
|
||||
return 1
|
||||
return COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,65 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for historical plan statistics query service
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_historical_plan_stats.py
|
||||
|
||||
*** Test Cases ***
|
||||
Create Empty Historical Stats
|
||||
[Documentation] Create HistoricalPlanStats with empty factory and verify defaults
|
||||
${result}= Run Process ${PYTHON} ${HELPER} empty-stats cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} empty-stats-ok
|
||||
|
||||
Create Populated Historical Stats
|
||||
[Documentation] Create HistoricalPlanStats with all fields populated
|
||||
${result}= Run Process ${PYTHON} ${HELPER} populated-stats cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} populated-stats-ok
|
||||
|
||||
Stats Service With Mock Plans
|
||||
[Documentation] Test HistoricalPlanStatsService with mock plan data
|
||||
${result}= Run Process ${PYTHON} ${HELPER} service-mock cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} service-mock-ok
|
||||
|
||||
Stats Service Empty History
|
||||
[Documentation] Test HistoricalPlanStatsService with empty history
|
||||
${result}= Run Process ${PYTHON} ${HELPER} service-empty cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} service-empty-ok
|
||||
|
||||
Context Provider Integration
|
||||
[Documentation] Test EstimationHistoricalStatsProvider ACMS integration
|
||||
${result}= Run Process ${PYTHON} ${HELPER} provider-integration cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} provider-integration-ok
|
||||
|
||||
Context Dict Serialization
|
||||
[Documentation] Test as_context_dict serialization
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-dict cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-dict-ok
|
||||
|
||||
Percentile Calculation
|
||||
[Documentation] Test _percentile helper function
|
||||
${result}= Run Process ${PYTHON} ${HELPER} percentile-calc cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} percentile-ok
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Estimation actor context provider for ACMS integration.
|
||||
|
||||
Provides ``EstimationHistoricalStatsProvider`` — an ACMS context
|
||||
strategy that injects historical plan statistics into the estimation
|
||||
actor's hot context tier. When the estimation actor assembles its
|
||||
context, this provider queries ``HistoricalPlanStatsService`` for the
|
||||
current action's historical data and creates a ``ContextFragment``
|
||||
containing the serialized statistics.
|
||||
|
||||
The provider implements the ``ContextStrategy`` protocol from
|
||||
``acms_service.py`` and can be registered with the ACMS pipeline via
|
||||
``register_strategy()``.
|
||||
|
||||
Based on ``docs/specification.md`` lines 19077-19081 (estimation actor
|
||||
analyzes "Historical data from similar plans (if available)") and the
|
||||
ACMS context strategy protocol (~line 25167).
|
||||
|
||||
ISSUES CLOSED: #652
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.application.services.acms_service import StrategyCapabilities
|
||||
from cleveragents.application.services.historical_plan_stats_service import (
|
||||
HistoricalPlanStatsService,
|
||||
)
|
||||
from cleveragents.domain.models.core.context_fragment import (
|
||||
ContextBudget,
|
||||
ContextFragment,
|
||||
FragmentProvenance,
|
||||
)
|
||||
|
||||
__all__ = ["EstimationHistoricalStatsProvider"]
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EstimationHistoricalStatsProvider:
|
||||
"""ACMS context strategy that provides historical plan statistics.
|
||||
|
||||
When registered with the ACMS pipeline and activated for the
|
||||
estimation actor's context assembly, this strategy queries the
|
||||
``HistoricalPlanStatsService`` for the current action's completed
|
||||
plan statistics and injects them as a high-relevance context
|
||||
fragment.
|
||||
|
||||
The fragment contains a JSON-serialized representation of the
|
||||
``HistoricalPlanStats`` suitable for the estimation actor to parse
|
||||
and reason about.
|
||||
|
||||
Implements the ``ContextStrategy`` protocol.
|
||||
|
||||
Usage::
|
||||
|
||||
stats_service = HistoricalPlanStatsService(plan_repo)
|
||||
provider = EstimationHistoricalStatsProvider(stats_service)
|
||||
pipeline.register_strategy(provider)
|
||||
"""
|
||||
|
||||
STRATEGY_NAME = "estimation-historical-stats"
|
||||
"""Registered strategy name in the ACMS pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stats_service: HistoricalPlanStatsService,
|
||||
*,
|
||||
default_action_name: str = "",
|
||||
stats_limit: int = 50,
|
||||
) -> None:
|
||||
"""Initialise with a ``HistoricalPlanStatsService``.
|
||||
|
||||
Args:
|
||||
stats_service: Service for querying historical plan statistics.
|
||||
default_action_name: Action name to query when no action is
|
||||
specified in the context request.
|
||||
stats_limit: Maximum number of historical plans to consider.
|
||||
"""
|
||||
self._stats_service = stats_service
|
||||
self._action_name = default_action_name
|
||||
self._stats_limit = stats_limit
|
||||
|
||||
def set_action_name(self, action_name: str) -> None:
|
||||
"""Set the action name to query historical stats for.
|
||||
|
||||
Called before context assembly to configure which action's
|
||||
history should be queried.
|
||||
|
||||
Args:
|
||||
action_name: Namespaced action name.
|
||||
"""
|
||||
self._action_name = action_name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the strategy name."""
|
||||
return self.STRATEGY_NAME
|
||||
|
||||
@property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
"""Return strategy capabilities."""
|
||||
return StrategyCapabilities(
|
||||
supports_semantic_search=False,
|
||||
supports_graph_navigation=False,
|
||||
supports_temporal_archaeology=True,
|
||||
quality_score=0.7,
|
||||
)
|
||||
|
||||
def can_handle(self, request: dict[str, Any]) -> float:
|
||||
"""Return confidence that this strategy can provide useful context.
|
||||
|
||||
Returns 0.8 when an action name is configured and 0.0 otherwise.
|
||||
The estimation context provider is highly relevant when an action
|
||||
is specified, but useless without one.
|
||||
"""
|
||||
action = request.get("action_name", self._action_name)
|
||||
if action:
|
||||
return 0.8
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
fragments: Sequence[ContextFragment],
|
||||
budget: ContextBudget,
|
||||
) -> Sequence[ContextFragment]:
|
||||
"""Assemble historical stats as context fragments.
|
||||
|
||||
Queries the ``HistoricalPlanStatsService`` for the configured
|
||||
action and produces a single ``ContextFragment`` containing the
|
||||
serialized statistics. If there is no history or the stats
|
||||
fragment would exceed the budget, returns the input fragments
|
||||
unchanged.
|
||||
|
||||
Args:
|
||||
fragments: Existing fragments (passed through unchanged).
|
||||
budget: Token budget for this strategy's allocation.
|
||||
|
||||
Returns:
|
||||
Input fragments plus the historical stats fragment (if any).
|
||||
"""
|
||||
if not self._action_name:
|
||||
return list(fragments)
|
||||
|
||||
try:
|
||||
stats = self._stats_service.get_stats_for_action(
|
||||
self._action_name,
|
||||
limit=self._stats_limit,
|
||||
)
|
||||
except Exception:
|
||||
_log.warning(
|
||||
"historical_stats_query_failed",
|
||||
action_name=self._action_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return list(fragments)
|
||||
|
||||
if stats.sample_size == 0:
|
||||
_log.debug(
|
||||
"no_historical_stats",
|
||||
action_name=self._action_name,
|
||||
)
|
||||
return list(fragments)
|
||||
|
||||
# Serialize stats to a context fragment
|
||||
stats_dict = stats.as_context_dict()
|
||||
stats_text = (
|
||||
f"Historical Plan Statistics for action '{self._action_name}':\n"
|
||||
f"{json.dumps(stats_dict, indent=2)}"
|
||||
)
|
||||
|
||||
# Estimate token count (rough: ~4 chars per token)
|
||||
estimated_tokens = max(1, len(stats_text) // 4)
|
||||
|
||||
if estimated_tokens > budget.available_tokens:
|
||||
_log.debug(
|
||||
"historical_stats_exceeds_budget",
|
||||
action_name=self._action_name,
|
||||
estimated_tokens=estimated_tokens,
|
||||
available_tokens=budget.available_tokens,
|
||||
)
|
||||
return list(fragments)
|
||||
|
||||
stats_fragment = ContextFragment(
|
||||
fragment_id=f"historical-stats-{self._action_name}",
|
||||
uko_node=f"historical-stats://{self._action_name}",
|
||||
content=stats_text,
|
||||
token_count=estimated_tokens,
|
||||
relevance_score=0.85,
|
||||
provenance=FragmentProvenance(
|
||||
resource_uri=f"historical-stats://{self._action_name}",
|
||||
),
|
||||
metadata={
|
||||
"type": "historical_plan_stats",
|
||||
"action_name": self._action_name,
|
||||
"sample_size": str(stats.sample_size),
|
||||
},
|
||||
)
|
||||
|
||||
result = list(fragments)
|
||||
result.append(stats_fragment)
|
||||
return result
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Return a human-readable explanation of this strategy."""
|
||||
return (
|
||||
"Queries historical plan statistics for the current action "
|
||||
"and injects them as a context fragment for the estimation "
|
||||
"actor. Provides mean/median/p90 cost, duration, step count, "
|
||||
"child plan count, and success rate from completed plans."
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Historical plan statistics query service for estimation context assembly.
|
||||
|
||||
Provides ``HistoricalPlanStatsService`` which queries completed plans
|
||||
by action name and returns aggregated statistics (mean/median/p90 cost,
|
||||
duration, step count, success rate) packaged in a
|
||||
``HistoricalPlanStats`` value object.
|
||||
|
||||
The service is designed for integration with the ACMS context assembly
|
||||
pipeline: the estimation actor receives historical stats as part of its
|
||||
hot context tier, enabling data-driven cost and risk estimation.
|
||||
|
||||
Empty history (first run for an action) returns a valid empty stats
|
||||
object rather than raising an error, ensuring the estimation actor can
|
||||
always receive a well-formed input.
|
||||
|
||||
Based on ``docs/specification.md`` lines 19077-19081 (estimation actor
|
||||
analyzes "Historical data from similar plans (if available)").
|
||||
|
||||
ISSUES CLOSED: #652
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import statistics
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.domain.models.core.historical_plan_stats import HistoricalPlanStats
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
LifecyclePlanRepository,
|
||||
)
|
||||
|
||||
__all__ = ["HistoricalPlanStatsService"]
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _percentile(sorted_values: list[float], pct: float) -> float:
|
||||
"""Compute the *pct*-th percentile of an already-sorted list.
|
||||
|
||||
Uses linear interpolation between the two closest data points.
|
||||
Returns ``0.0`` for empty input.
|
||||
|
||||
Args:
|
||||
sorted_values: Non-descending list of floats.
|
||||
pct: Percentile in the range [0, 100].
|
||||
|
||||
Returns:
|
||||
The computed percentile value.
|
||||
"""
|
||||
if not sorted_values:
|
||||
return 0.0
|
||||
n = len(sorted_values)
|
||||
if n == 1:
|
||||
return sorted_values[0]
|
||||
k = (pct / 100.0) * (n - 1)
|
||||
floor_k = math.floor(k)
|
||||
ceil_k = min(floor_k + 1, n - 1)
|
||||
fraction = k - floor_k
|
||||
return sorted_values[floor_k] + fraction * (
|
||||
sorted_values[ceil_k] - sorted_values[floor_k]
|
||||
)
|
||||
|
||||
|
||||
class HistoricalPlanStatsService:
|
||||
"""Query and aggregate historical plan statistics for an action.
|
||||
|
||||
Designed for use by the estimation actor's context assembly. The
|
||||
service queries the ``LifecyclePlanRepository`` for completed plans
|
||||
matching a given action name, then computes aggregated statistics.
|
||||
|
||||
Usage::
|
||||
|
||||
service = HistoricalPlanStatsService(plan_repository)
|
||||
stats = service.get_stats_for_action("local/refactor", limit=50)
|
||||
# stats.mean_cost_usd, stats.success_rate, ...
|
||||
|
||||
Thread-safety: this service is stateless (no mutable instance state
|
||||
beyond the injected repository reference) and safe for concurrent use.
|
||||
"""
|
||||
|
||||
def __init__(self, plan_repository: LifecyclePlanRepository) -> None:
|
||||
"""Initialise with a plan repository.
|
||||
|
||||
Args:
|
||||
plan_repository: The repository providing plan query access.
|
||||
"""
|
||||
self._plan_repository = plan_repository
|
||||
|
||||
def get_stats_for_action(
|
||||
self,
|
||||
action_name: str,
|
||||
limit: int = 50,
|
||||
) -> HistoricalPlanStats:
|
||||
"""Compute aggregated statistics for completed plans of *action_name*.
|
||||
|
||||
Queries up to *limit* most recent completed plans for the given
|
||||
action and returns aggregated statistics. If no completed plans
|
||||
exist, returns a valid empty stats object (``sample_size == 0``).
|
||||
|
||||
Args:
|
||||
action_name: Namespaced action name (e.g. ``"local/refactor"``).
|
||||
limit: Maximum number of recent plans to consider (default 50).
|
||||
|
||||
Returns:
|
||||
A frozen ``HistoricalPlanStats`` value object.
|
||||
"""
|
||||
if not action_name:
|
||||
return HistoricalPlanStats.empty(action_name="<unknown>")
|
||||
|
||||
if limit < 1:
|
||||
return HistoricalPlanStats.empty(action_name=action_name)
|
||||
|
||||
plans = self._plan_repository.get_completed_plans_by_action(
|
||||
action_name, limit=limit
|
||||
)
|
||||
|
||||
if not plans:
|
||||
_log.debug(
|
||||
"no_completed_plans_for_action",
|
||||
action_name=action_name,
|
||||
)
|
||||
return HistoricalPlanStats.empty(action_name=action_name)
|
||||
|
||||
return self._aggregate(action_name, plans)
|
||||
|
||||
def _aggregate(
|
||||
self,
|
||||
action_name: str,
|
||||
plans: Sequence[Any],
|
||||
) -> HistoricalPlanStats:
|
||||
"""Compute aggregate statistics from a sequence of plan domain objects.
|
||||
|
||||
Args:
|
||||
action_name: The action name for the stats object.
|
||||
plans: Non-empty sequence of Plan domain objects.
|
||||
|
||||
Returns:
|
||||
Populated ``HistoricalPlanStats``.
|
||||
"""
|
||||
costs: list[float] = []
|
||||
durations: list[float] = []
|
||||
step_counts: list[int] = []
|
||||
child_plan_counts: list[int] = []
|
||||
applied_count = 0
|
||||
|
||||
for plan in plans:
|
||||
# Cost: from cost_metadata.total_cost or the DB column cost_actual_usd
|
||||
cost = self._extract_cost(plan)
|
||||
if cost is not None and cost >= 0.0:
|
||||
costs.append(cost)
|
||||
|
||||
# Duration: from timestamps (created_at to completed_at/applied_at)
|
||||
duration = self._extract_duration_seconds(plan)
|
||||
if duration is not None and duration >= 0.0:
|
||||
durations.append(duration)
|
||||
|
||||
# Step count: from last_completed_step
|
||||
step_count = getattr(plan, "last_completed_step", -1)
|
||||
if step_count is not None and step_count >= 0:
|
||||
step_counts.append(step_count + 1) # Convert 0-indexed to count
|
||||
|
||||
# Child plan count: from subplan_statuses
|
||||
subplan_statuses = getattr(plan, "subplan_statuses", []) or []
|
||||
child_plan_counts.append(len(subplan_statuses))
|
||||
|
||||
# Success rate: count applied plans
|
||||
state = getattr(plan, "processing_state", None)
|
||||
if state is not None:
|
||||
state_val = state.value if hasattr(state, "value") else str(state)
|
||||
else:
|
||||
state_val = ""
|
||||
if state_val == "applied":
|
||||
applied_count += 1
|
||||
|
||||
sample_size = len(plans)
|
||||
sorted_costs = sorted(costs)
|
||||
|
||||
return HistoricalPlanStats(
|
||||
action_name=action_name,
|
||||
sample_size=sample_size,
|
||||
mean_cost_usd=statistics.mean(costs) if costs else 0.0,
|
||||
median_cost_usd=statistics.median(costs) if costs else 0.0,
|
||||
p90_cost_usd=_percentile(sorted_costs, 90.0),
|
||||
mean_duration_seconds=statistics.mean(durations) if durations else 0.0,
|
||||
median_duration_seconds=(
|
||||
statistics.median(durations) if durations else 0.0
|
||||
),
|
||||
avg_step_count=(statistics.mean(step_counts) if step_counts else 0.0),
|
||||
avg_child_plan_count=(
|
||||
statistics.mean(child_plan_counts) if child_plan_counts else 0.0
|
||||
),
|
||||
success_rate=applied_count / sample_size if sample_size > 0 else 0.0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_cost(plan: Any) -> float | None:
|
||||
"""Extract actual cost from a plan domain object.
|
||||
|
||||
Prefers ``cost_metadata.total_cost``; if unavailable or zero,
|
||||
falls back to the DB column equivalent if available via the
|
||||
plan's attributes.
|
||||
"""
|
||||
cost_meta = getattr(plan, "cost_metadata", None)
|
||||
if cost_meta is not None:
|
||||
total = getattr(cost_meta, "total_cost", None)
|
||||
if total is not None and total > 0.0:
|
||||
return float(total)
|
||||
# No cost metadata — return None (plan had no cost tracking)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_duration_seconds(plan: Any) -> float | None:
|
||||
"""Extract total duration in seconds from a plan's timestamps.
|
||||
|
||||
Duration is ``applied_at - created_at`` for applied plans or
|
||||
``execute_completed_at - created_at`` for completed (but not
|
||||
applied) plans. Returns ``None`` if timestamps are unavailable.
|
||||
"""
|
||||
timestamps = getattr(plan, "timestamps", None)
|
||||
if timestamps is None:
|
||||
return None
|
||||
|
||||
created_at = getattr(timestamps, "created_at", None)
|
||||
if created_at is None:
|
||||
return None
|
||||
|
||||
# Prefer applied_at as the end timestamp (full lifecycle)
|
||||
end_at = getattr(timestamps, "applied_at", None)
|
||||
if end_at is None:
|
||||
# Fall back to execute_completed_at
|
||||
end_at = getattr(timestamps, "execute_completed_at", None)
|
||||
if end_at is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
delta = end_at - created_at
|
||||
return max(0.0, delta.total_seconds())
|
||||
except (TypeError, AttributeError):
|
||||
return None
|
||||
@@ -144,6 +144,7 @@ from cleveragents.domain.models.core.execution_environment_preference import (
|
||||
EnvironmentPreferenceMode,
|
||||
ExecutionEnvironmentPreference,
|
||||
)
|
||||
from cleveragents.domain.models.core.historical_plan_stats import HistoricalPlanStats
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantEnforcementRecord,
|
||||
@@ -409,6 +410,7 @@ __all__ = [
|
||||
"GuardrailEventType",
|
||||
"GuardrailResult",
|
||||
"HistoricalOutcome",
|
||||
"HistoricalPlanStats",
|
||||
"InMemoryChangeSetStore",
|
||||
"InMemoryInvocationTracker",
|
||||
"IndexMetadata",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Historical plan statistics domain model for CleverAgents.
|
||||
|
||||
Provides the ``HistoricalPlanStats`` frozen value object that packages
|
||||
aggregated statistics from completed plans for a given action. Used by
|
||||
the estimation actor's context assembly to provide historical data for
|
||||
informed cost/risk estimation.
|
||||
|
||||
Based on ``docs/specification.md`` estimation actor sections (lines
|
||||
19077-19081) which describe the three inputs the estimation actor
|
||||
analyzes, including "Historical data from similar plans (if available)."
|
||||
|
||||
ISSUES CLOSED: #652
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
__all__ = ["HistoricalPlanStats"]
|
||||
|
||||
|
||||
class HistoricalPlanStats(BaseModel):
|
||||
"""Aggregated statistics from completed plans for a given action.
|
||||
|
||||
All fields have sensible defaults so that an empty-history scenario
|
||||
(first run for a given action) produces a valid, non-error stats
|
||||
object with ``sample_size == 0`` and all metrics at ``0.0``.
|
||||
|
||||
The model is frozen (immutable) once constructed.
|
||||
"""
|
||||
|
||||
action_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Namespaced action name the statistics describe",
|
||||
)
|
||||
|
||||
sample_size: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of completed plans included in the statistics",
|
||||
)
|
||||
|
||||
# -- Cost metrics (USD) ------------------------------------------------
|
||||
|
||||
mean_cost_usd: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Mean actual cost across completed plans (USD)",
|
||||
)
|
||||
median_cost_usd: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Median actual cost across completed plans (USD)",
|
||||
)
|
||||
p90_cost_usd: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="90th percentile actual cost (USD)",
|
||||
)
|
||||
|
||||
# -- Duration metrics (seconds) ----------------------------------------
|
||||
|
||||
mean_duration_seconds: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Mean total duration from created_at to completed_at (seconds)",
|
||||
)
|
||||
median_duration_seconds: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Median total duration (seconds)",
|
||||
)
|
||||
|
||||
# -- Step and child plan metrics ---------------------------------------
|
||||
|
||||
avg_step_count: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Average number of completed execution steps",
|
||||
)
|
||||
avg_child_plan_count: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Average number of child plans spawned",
|
||||
)
|
||||
|
||||
# -- Success rate ------------------------------------------------------
|
||||
|
||||
success_rate: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Fraction of completed plans that reached 'applied' state (0.0-1.0)"
|
||||
),
|
||||
)
|
||||
|
||||
model_config = ConfigDict(frozen=True, allow_inf_nan=False)
|
||||
|
||||
@classmethod
|
||||
def empty(cls, action_name: str) -> HistoricalPlanStats:
|
||||
"""Create an empty stats object for an action with no history.
|
||||
|
||||
Args:
|
||||
action_name: The namespaced action name.
|
||||
|
||||
Returns:
|
||||
A ``HistoricalPlanStats`` with ``sample_size == 0`` and all
|
||||
metrics at their zero defaults.
|
||||
"""
|
||||
return cls(action_name=action_name)
|
||||
|
||||
def as_context_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict suitable for injection into estimation actor context.
|
||||
|
||||
Produces a compact representation with only non-zero metrics
|
||||
included, plus the action name and sample size (always present).
|
||||
"""
|
||||
d: dict[str, Any] = {
|
||||
"action_name": self.action_name,
|
||||
"sample_size": self.sample_size,
|
||||
}
|
||||
if self.mean_cost_usd > 0.0:
|
||||
d["mean_cost_usd"] = round(self.mean_cost_usd, 6)
|
||||
if self.median_cost_usd > 0.0:
|
||||
d["median_cost_usd"] = round(self.median_cost_usd, 6)
|
||||
if self.p90_cost_usd > 0.0:
|
||||
d["p90_cost_usd"] = round(self.p90_cost_usd, 6)
|
||||
if self.mean_duration_seconds > 0.0:
|
||||
d["mean_duration_seconds"] = round(self.mean_duration_seconds, 2)
|
||||
if self.median_duration_seconds > 0.0:
|
||||
d["median_duration_seconds"] = round(self.median_duration_seconds, 2)
|
||||
if self.avg_step_count > 0.0:
|
||||
d["avg_step_count"] = round(self.avg_step_count, 2)
|
||||
if self.avg_child_plan_count > 0.0:
|
||||
d["avg_child_plan_count"] = round(self.avg_child_plan_count, 2)
|
||||
if self.success_rate > 0.0:
|
||||
d["success_rate"] = round(self.success_rate, 4)
|
||||
return d
|
||||
@@ -1612,6 +1612,53 @@ class LifecyclePlanRepository:
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to count plans: {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def get_completed_plans_by_action(
|
||||
self,
|
||||
action_name: str,
|
||||
limit: int = 50,
|
||||
) -> list[Any]:
|
||||
"""Return the *limit* most recent completed plans for *action_name*.
|
||||
|
||||
A plan is considered "completed" when its ``processing_state`` is
|
||||
one of ``complete``, ``applied``, ``errored``, or ``cancelled``.
|
||||
Only plans that have actually finished processing (i.e. reached a
|
||||
terminal state) contribute to historical statistics.
|
||||
|
||||
Results are ordered by ``created_at DESC`` so the most recent
|
||||
plans appear first.
|
||||
|
||||
Args:
|
||||
action_name: Namespaced action name to filter on.
|
||||
limit: Maximum number of plans to return (default 50).
|
||||
|
||||
Returns:
|
||||
List of ``Plan`` domain objects in reverse chronological order.
|
||||
|
||||
Raises:
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
if limit < 1:
|
||||
return []
|
||||
session = self._session()
|
||||
terminal_states = ("complete", "applied", "errored", "cancelled")
|
||||
try:
|
||||
rows = (
|
||||
session.query(LifecyclePlanModel)
|
||||
.filter(
|
||||
LifecyclePlanModel.action_name == action_name,
|
||||
LifecyclePlanModel.processing_state.in_(terminal_states),
|
||||
)
|
||||
.order_by(LifecyclePlanModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [row.to_domain() for row in rows]
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(
|
||||
f"Failed to get completed plans for action '{action_name}': {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource Registry Repositories
|
||||
|
||||
Reference in New Issue
Block a user