fix(decomposition): enforce plan.max-child-depth recursion limit to prevent unbounded hierarchy growth #11248

Merged
CoreRasurae merged 1 commits from fix/enforce-max-child-depth into master 2026-05-20 09:52:37 +00:00
5 changed files with 139 additions and 4 deletions
@@ -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,