diff --git a/features/decomposition_max_child_depth.feature b/features/decomposition_max_child_depth.feature new file mode 100644 index 000000000..262232235 --- /dev/null +++ b/features/decomposition_max_child_depth.feature @@ -0,0 +1,38 @@ +Feature: Decomposition respects max_child_depth limit + As a plan orchestrator + I want the decomposition service to enforce plan.max-child-depth + So that child plans cannot nest deeper than the configured limit + + Background: + Given a decomposition service + + Scenario: Decomposition stops recursing when max_child_depth limit is reached + Given a project with 500 files across 8 directory levels + When I decompose with max_child_depth 2 and max_depth 10 + Then the decomposition result should have max_depth_reached <= 2 + And the decomposition warning log should contain "max-child-depth limit" + + Scenario: Decomposition with default max_child_depth allows standard depth + Given a project with 2000 files across 5 directory levels + When I decompose with default max_child_depth + Then the decomposition result should have max_depth_reached >= 1 + And the decomposition result should have max_depth_reached <= 5 + + Scenario: max_child_depth guard triggers before max_depth when more restrictive + Given a project with 500 files across 8 directory levels + When I decompose with max_child_depth 1 and max_depth 10 + Then the decomposition result should have max_depth_reached <= 1 + And the decomposition warning log should contain "max-child-depth limit" + + Scenario: max_depth can be more restrictive than max_child_depth + Given a project with 500 files across 8 directory levels + When I decompose with max_child_depth 10 and max_depth 2 + Then the decomposition result should have max_depth_reached <= 2 + + Scenario: Default config includes max_child_depth with expected value + Given default decomposition config + Then max_child_depth should be 5 + + Scenario: Invalid max_child_depth value is rejected + When I create a config with max_child_depth 0 + Then a decomposition ValueError should be raised diff --git a/features/large_project_decomposition.feature b/features/large_project_decomposition.feature index e006ce210..ae16f2df7 100644 --- a/features/large_project_decomposition.feature +++ b/features/large_project_decomposition.feature @@ -114,6 +114,7 @@ Feature: Large-project hierarchical decomposition Scenario: Default config has expected values Given default decomposition config Then max_depth should be 4 + And max_child_depth should be 5 And max_files_per_subplan should be 500 And max_tokens_per_subplan should be 100000 And min_files_per_subplan should be 10 diff --git a/features/steps/decomposition_max_child_depth_steps.py b/features/steps/decomposition_max_child_depth_steps.py new file mode 100644 index 000000000..c5bc10933 --- /dev/null +++ b/features/steps/decomposition_max_child_depth_steps.py @@ -0,0 +1,81 @@ +"""Step implementations for decomposition_max_child_depth.feature. + +Tests the enforcement of max_child_depth in DecompositionService._build_hierarchy. +Reuses existing steps from large_project_decomposition_steps.py where applicable. +Only new, unique step patterns are defined here. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from behave import then, when # type: ignore[import-untyped] + +from cleveragents.application.services.decomposition_models import ( + DecompositionConfig, +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DECOMP_SVC_LOGGER = "cleveragents.application.services.decomposition_service.logger" + + +# --------------------------------------------------------------------------- +# Whens +# --------------------------------------------------------------------------- + + +@when("I decompose with max_child_depth {mcd:d} and max_depth {md:d}") +def step_when_decompose_max_child_depth_and_max_depth( + context: Any, + mcd: int, + md: int, +) -> None: + cfg = DecompositionConfig( + max_child_depth=mcd, + max_depth=md, + max_files_per_subplan=30, + ) + with patch(_DECOMP_SVC_LOGGER) as mock_logger: + context.result = context.svc.decompose(context.files, cfg) + context.mock_logger = mock_logger + + +@when("I decompose with default max_child_depth") +def step_when_decompose_default_mcd(context: Any) -> None: + cfg = DecompositionConfig( + max_child_depth=5, + max_files_per_subplan=30, + ) + with patch(_DECOMP_SVC_LOGGER) as mock_logger: + context.result = context.svc.decompose(context.files, cfg) + context.mock_logger = mock_logger + + +@when("I create a config with max_child_depth {n:d}") +def step_when_create_bad_max_child_depth(context: Any, n: int) -> None: + try: + DecompositionConfig(max_child_depth=n) + except ValueError as exc: + context.error = exc + + +# --------------------------------------------------------------------------- +# Thens +# --------------------------------------------------------------------------- + + +@then('the decomposition warning log should contain "{text}"') +def step_then_warning_log_contains(context: Any, text: str) -> None: + assert context.mock_logger is not None, "Mock logger was not set" + assert context.mock_logger.warning.called, "Expected logger.warning to be called" + args_str = str(context.mock_logger.warning.call_args_list) + assert text in args_str, f"Expected '{text}' in warning calls, got: {args_str}" + + +@then("max_child_depth should be {n:d}") +def step_then_cfg_max_child_depth(context: Any, n: int) -> None: + assert context.decomp_config.max_child_depth == n diff --git a/src/cleveragents/application/services/decomposition_models.py b/src/cleveragents/application/services/decomposition_models.py index 6701cf7f7..679f137ab 100644 --- a/src/cleveragents/application/services/decomposition_models.py +++ b/src/cleveragents/application/services/decomposition_models.py @@ -47,6 +47,7 @@ class DecompositionConfig: Attributes: max_depth: Maximum recursion depth (root = 0). + max_child_depth: Maximum allowed child-plan nesting depth. max_files_per_subplan: Upper bound on files in a single leaf. max_tokens_per_subplan: Upper bound on estimated tokens per leaf. min_files_per_subplan: Below this, no further splitting. @@ -55,6 +56,7 @@ class DecompositionConfig: """ max_depth: int = 4 + max_child_depth: int = 5 max_files_per_subplan: int = 500 max_tokens_per_subplan: int = 100_000 min_files_per_subplan: int = 10 @@ -65,6 +67,8 @@ class DecompositionConfig: def __post_init__(self) -> None: if self.max_depth < 1: raise ValueError("max_depth must be >= 1") + if self.max_child_depth < 1: + raise ValueError("max_child_depth must be >= 1") if self.max_files_per_subplan < 1: raise ValueError("max_files_per_subplan must be >= 1") if self.max_tokens_per_subplan < 1: diff --git a/src/cleveragents/application/services/decomposition_service.py b/src/cleveragents/application/services/decomposition_service.py index c67a110fe..a96c88c46 100644 --- a/src/cleveragents/application/services/decomposition_service.py +++ b/src/cleveragents/application/services/decomposition_service.py @@ -209,15 +209,26 @@ class DecompositionService: # Leaf conditions # M6 requires deep hierarchical decomposition (4+ levels) for # large plans. To preserve this behavior, we only stop early when - # we hit max_depth or the workset is trivially small; otherwise we - # keep partitioning even when a cluster is already under the max - # file/token bounds. + # we hit max_depth, max_child_depth, or the workset is trivially + # small; otherwise we keep partitioning even when a cluster is + # already under the max file/token bounds. + reached_child_depth_limit = depth >= config.max_child_depth is_leaf = ( - depth >= config.max_depth or len(files) <= config.min_files_per_subplan + depth >= config.max_depth + or reached_child_depth_limit + or len(files) <= config.min_files_per_subplan ) node_id = _next_node_id() if is_leaf or len(files) <= config.min_files_per_subplan: + if reached_child_depth_limit: + logger.warning( + "Decomposition reached max-child-depth limit (%d) " + "at depth %d. Creating terminal node for %d files.", + config.max_child_depth, + depth, + len(files), + ) node = DecompositionNode( node_id=node_id, parent_id=parent_id,