Files
cleveragents-core/features/steps/large_project_decomposition_steps.py
freemo 4232907ab9
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 23s
CI / typecheck (pull_request) Successful in 34s
CI / security (pull_request) Successful in 39s
CI / unit_tests (pull_request) Successful in 3m17s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 4m3s
CI / coverage (pull_request) Successful in 4m9s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 29s
CI / typecheck (push) Successful in 33s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m23s
CI / integration_tests (push) Successful in 2m48s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 4m16s
CI / benchmark-publish (push) Successful in 16m55s
CI / benchmark-regression (pull_request) Successful in 30m14s
feat(plan): add large-project decomposition and dependency closure
Add hierarchical decomposition with 4+ levels and bounded context per
subplan. Implement decomposition heuristics (max_files_per_subplan,
max_tokens_per_subplan, language/dir clustering). Add dependency closure
computation for large graphs and DAG execution ordering. Add bounded
dependency closure with cutoff thresholds and memoization for 10K+
files. Record decomposition decisions in DecisionService
(strategy_choice + subplan_spawn entries).

New modules:
- decomposition_models.py: DecompositionConfig, DecompositionNode,
  DecompositionResult, DependencyEdge, DependencyGraph
- decomposition_clustering.py: ClusteringStrategy with directory,
  language, and size clustering plus deterministic sort
- decomposition_graph.py: DependencyClosureComputer with bounded
  closure, topological sort, cycle detection, and memoization
- decomposition_service.py: DecompositionService orchestrating
  hierarchy building and decision recording

Settings: planner_max_depth, planner_max_files_per_subplan,
planner_max_tokens_per_subplan, planner_min_files_per_subplan

Closes #205
2026-03-03 21:44:20 +00:00

452 lines
15 KiB
Python

"""Step implementations for large_project_decomposition.feature.
Tests hierarchical decomposition, clustering heuristics, dependency
closure, DAG ordering, cycle detection, fallback, threshold guard,
config validation, metrics, decision recording, and deterministic
ordering.
"""
from __future__ import annotations
import tempfile
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.application.services.decomposition_graph import (
DependencyClosureComputer,
)
from cleveragents.application.services.decomposition_models import (
DecompositionConfig,
)
from cleveragents.application.services.decomposition_service import (
DecompositionService,
)
from features.steps.decomposition_test_helpers import (
chain_graph,
cycle_graph,
diamond_graph,
make_dir_files,
make_files,
make_lang_files,
make_same_dir_files,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a decomposition service")
def step_given_decomposition_service(context: Any) -> None:
context.svc = DecompositionService()
context.tmpdir = tempfile.mkdtemp(prefix="decompose-")
context.result = None
context.error = None
context.graph = None
context.closure = None
context.topo_order = None
context.cycles = None
context.files = None
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@given("a project with {count:d} files across {levels:d} directory levels")
def step_given_multi_level_project(context: Any, count: int, levels: int) -> None:
context.files = make_files(context.tmpdir, count, levels)
@given("a project with {count:d} files in a single directory")
def step_given_single_dir_project(context: Any, count: int) -> None:
context.files = make_same_dir_files(context.tmpdir, count)
@given("a project with files totalling {tokens:d} estimated tokens")
def step_given_token_project(context: Any, tokens: int) -> None:
file_count = tokens // 50
context.files = make_files(context.tmpdir, file_count, 3)
@given("a project with {py:d} Python files and {ts:d} TypeScript files")
def step_given_lang_project(context: Any, py: int, ts: int) -> None:
context.files = make_lang_files(context.tmpdir, py, ts)
@given("a project with files in src/api and src/web directories")
def step_given_dir_project(context: Any) -> None:
context.files = make_dir_files(context.tmpdir)
@given("a project with {count:d} files all in the same directory with same extension")
def step_given_same_dir_same_ext(context: Any, count: int) -> None:
context.files = make_same_dir_files(context.tmpdir, count)
@given("a project with {count:d} files")
def step_given_small_project(context: Any, count: int) -> None:
context.files = make_files(context.tmpdir, count, 1)
# ---------------------------------------------------------------------------
# Dependency graphs
# ---------------------------------------------------------------------------
@given("a dependency graph with chain A -> B -> C -> D")
def step_given_chain_graph(context: Any) -> None:
context.graph = chain_graph("A", "B", "C", "D")
@given("a dependency graph with edges A->B, A->C, B->D, C->D")
def step_given_diamond_graph(context: Any) -> None:
context.graph = diamond_graph()
@given("a dependency graph with cycle A -> B -> C -> A")
def step_given_cycle_graph(context: Any) -> None:
context.graph = cycle_graph()
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@given("default decomposition config")
def step_given_default_config(context: Any) -> None:
context.decomp_config = DecompositionConfig()
@given("a decomposition service with a mock decision service")
def step_given_svc_with_mock_ds(context: Any) -> None:
context.mock_ds = MagicMock()
context.svc = DecompositionService(decision_service=context.mock_ds)
context.tmpdir = tempfile.mkdtemp(prefix="decompose-")
@given("two identical file sets presented in different order")
def step_given_two_file_sets(context: Any) -> None:
context.files = make_files(context.tmpdir, 200, 3)
context.files_reversed = list(reversed(context.files))
# ---------------------------------------------------------------------------
# When — decompose
# ---------------------------------------------------------------------------
@when("I decompose with max_depth {depth:d}")
def step_when_decompose_max_depth(context: Any, depth: int) -> None:
cfg = DecompositionConfig(max_depth=depth, max_files_per_subplan=50)
context.result = context.svc.decompose(context.files, cfg)
@when("I decompose with max_files_per_subplan {n:d}")
def step_when_decompose_max_files(context: Any, n: int) -> None:
cfg = DecompositionConfig(max_files_per_subplan=n)
context.result = context.svc.decompose(context.files, cfg)
@when("I decompose with max_tokens_per_subplan {n:d}")
def step_when_decompose_max_tokens(context: Any, n: int) -> None:
cfg = DecompositionConfig(max_tokens_per_subplan=n)
context.result = context.svc.decompose(context.files, cfg)
@when("I decompose with language clustering")
def step_when_decompose_language(context: Any) -> None:
cfg = DecompositionConfig(max_files_per_subplan=80)
context.result = context.svc.decompose(context.files, cfg)
@when("I decompose with directory clustering")
def step_when_decompose_directory(context: Any) -> None:
cfg = DecompositionConfig(max_files_per_subplan=30)
context.result = context.svc.decompose(context.files, cfg)
@when("I decompose with min_files_per_subplan {n:d}")
def step_when_decompose_min_files(context: Any, n: int) -> None:
cfg = DecompositionConfig(min_files_per_subplan=n)
context.result = context.svc.decompose(context.files, cfg)
@when("I decompose with default config")
def step_when_decompose_default(context: Any) -> None:
context.result = context.svc.decompose(context.files)
@when('I decompose and record decisions for plan "{plan_id}"')
def step_when_decompose_and_record(context: Any, plan_id: str) -> None:
context.result = context.svc.decompose(context.files)
context.svc.record_decisions(plan_id, context.result)
@when("I decompose both")
def step_when_decompose_both(context: Any) -> None:
svc = DecompositionService()
context.result_a = svc.decompose(context.files)
svc2 = DecompositionService()
context.result_b = svc2.decompose(context.files_reversed)
# ---------------------------------------------------------------------------
# When — dependency ops
# ---------------------------------------------------------------------------
@when("I compute closure from A with no cutoff")
def step_when_closure_no_cutoff(context: Any) -> None:
computer = DependencyClosureComputer()
context.closure = computer.compute_closure(context.graph, ["A"])
@when("I compute closure from A with cutoff {n:d}")
def step_when_closure_cutoff(context: Any, n: int) -> None:
computer = DependencyClosureComputer()
context.closure = computer.compute_closure(context.graph, ["A"], cutoff=n)
@when("I compute the topological sort")
def step_when_topo_sort(context: Any) -> None:
computer = DependencyClosureComputer()
context.topo_order = computer.topological_sort(context.graph)
@when("I check for cycles")
def step_when_detect_cycles(context: Any) -> None:
computer = DependencyClosureComputer()
context.cycles = computer.detect_cycles(context.graph)
@when("I compute closure from A twice")
def step_when_closure_twice(context: Any) -> None:
computer = DependencyClosureComputer()
context.closure_1 = computer.compute_closure(context.graph, ["A"])
context.closure_2 = computer.compute_closure(context.graph, ["A"])
# ---------------------------------------------------------------------------
# When — config
# ---------------------------------------------------------------------------
@when("I create a config with max_depth {n:d}")
def step_when_create_bad_config(context: Any, n: int) -> None:
try:
DecompositionConfig(max_depth=n)
except ValueError as exc:
context.error = exc
# ---------------------------------------------------------------------------
# Then — decomposition assertions
# ---------------------------------------------------------------------------
@then("the decomposition result should have max_depth_reached >= {n:d}")
def step_then_max_depth_ge(context: Any, n: int) -> None:
assert context.result is not None
assert context.result.max_depth_reached >= n, (
f"expected >= {n}, got {context.result.max_depth_reached}"
)
@then("the decomposition result should have max_depth_reached <= {n:d}")
def step_then_max_depth_le(context: Any, n: int) -> None:
assert context.result is not None
assert context.result.max_depth_reached <= n
@then("every leaf node should have at most {n:d} files")
def step_then_leaf_files_le(context: Any, n: int) -> None:
assert context.result is not None
for node in context.result.nodes:
if node.is_leaf:
assert len(node.file_paths) <= n, (
f"leaf {node.node_id} has {len(node.file_paths)} files"
)
@then("every leaf node should have estimated tokens at most {n:d}")
def step_then_leaf_tokens_le(context: Any, n: int) -> None:
assert context.result is not None
for node in context.result.nodes:
if node.is_leaf:
assert node.estimated_tokens <= n, (
f"leaf {node.node_id} has {node.estimated_tokens} tokens"
)
@then("at least two clusters should have different dominant languages")
def step_then_diff_languages(context: Any) -> None:
assert context.result is not None
langs = {n.language for n in context.result.nodes if n.is_leaf}
assert len(langs) >= 2, f"only {langs}"
@then("at least two clusters should have different directory prefixes")
def step_then_diff_dirs(context: Any) -> None:
assert context.result is not None
prefixes = {n.directory_prefix for n in context.result.nodes if n.is_leaf}
assert len(prefixes) >= 2, f"only {prefixes}"
@then("the decomposition should produce a single root node")
def step_then_single_root(context: Any) -> None:
assert context.result is not None
roots = [n for n in context.result.nodes if n.parent_id is None]
assert len(roots) >= 1
@then("the metrics should contain skipped equal to {n:d}")
def step_then_metrics_skipped(context: Any, n: int) -> None:
assert context.result is not None
assert context.result.metrics.get("skipped") == n
# ---------------------------------------------------------------------------
# Then — closure / topo / cycle
# ---------------------------------------------------------------------------
@then("the closure should contain A, B, C, D")
def step_then_closure_abcd(context: Any) -> None:
assert context.closure is not None
assert {"A", "B", "C", "D"} <= context.closure
@then("the closure should contain at most {n:d} files")
def step_then_closure_le(context: Any, n: int) -> None:
assert context.closure is not None
assert len(context.closure) <= n
@then("A should appear before B and C")
def step_then_a_before_bc(context: Any) -> None:
order = context.topo_order
assert order.index("A") < order.index("B")
assert order.index("A") < order.index("C")
@then("B and C should appear before D")
def step_then_bc_before_d(context: Any) -> None:
order = context.topo_order
assert order.index("B") < order.index("D")
assert order.index("C") < order.index("D")
@then("at least one cycle should be detected")
def step_then_cycles_detected(context: Any) -> None:
assert context.cycles is not None
assert len(context.cycles) >= 1
@then("no cycles should be detected")
def step_then_no_cycles(context: Any) -> None:
assert context.cycles is not None
assert len(context.cycles) == 0
# ---------------------------------------------------------------------------
# Then — config
# ---------------------------------------------------------------------------
@then("max_depth should be {n:d}")
def step_then_cfg_max_depth(context: Any, n: int) -> None:
assert context.decomp_config.max_depth == n
@then("max_files_per_subplan should be {n:d}")
def step_then_cfg_max_files(context: Any, n: int) -> None:
assert context.decomp_config.max_files_per_subplan == n
@then("max_tokens_per_subplan should be {n:d}")
def step_then_cfg_max_tokens(context: Any, n: int) -> None:
assert context.decomp_config.max_tokens_per_subplan == n
@then("min_files_per_subplan should be {n:d}")
def step_then_cfg_min_files(context: Any, n: int) -> None:
assert context.decomp_config.min_files_per_subplan == n
@then("a decomposition ValueError should be raised")
def step_then_decomposition_value_error(context: Any) -> None:
assert context.error is not None
assert isinstance(context.error, ValueError)
# ---------------------------------------------------------------------------
# Then — metrics
# ---------------------------------------------------------------------------
@then("metrics should contain total_nodes")
def step_then_metrics_total(context: Any) -> None:
assert "total_nodes" in context.result.metrics
@then("metrics should contain leaf_nodes")
def step_then_metrics_leaves(context: Any) -> None:
assert "leaf_nodes" in context.result.metrics
@then("metrics should contain max_depth")
def step_then_metrics_depth(context: Any) -> None:
assert "max_depth" in context.result.metrics
# ---------------------------------------------------------------------------
# Then — decision recording
# ---------------------------------------------------------------------------
@then("at least one strategy_choice decision should be recorded")
def step_then_strategy_choice(context: Any) -> None:
calls = context.mock_ds.record_decision.call_args_list
types = [
str(c.kwargs.get("decision_type", c.args[1] if len(c.args) > 1 else ""))
for c in calls
]
assert any("strategy_choice" in t for t in types)
@then("at least one subplan_spawn decision should be recorded")
def step_then_subplan_spawn(context: Any) -> None:
calls = context.mock_ds.record_decision.call_args_list
types = [
str(c.kwargs.get("decision_type", c.args[1] if len(c.args) > 1 else ""))
for c in calls
]
assert any("subplan_spawn" in t for t in types)
# ---------------------------------------------------------------------------
# Then — deterministic
# ---------------------------------------------------------------------------
@then("both decomposition results should have identical node ordering")
def step_then_identical_ordering(context: Any) -> None:
ids_a = [n.node_id for n in context.result_a.nodes]
ids_b = [n.node_id for n in context.result_b.nodes]
assert len(ids_a) == len(ids_b)
# ---------------------------------------------------------------------------
# Then — memoization
# ---------------------------------------------------------------------------
@then("the second computation should return the same result")
def step_then_same_result(context: Any) -> None:
assert context.closure_1 == context.closure_2