fix(decomposition): enforce plan.max-child-depth recursion limit to prevent unbounded hierarchy growth #11248
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user