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
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
452 lines
15 KiB
Python
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
|