diff --git a/docs/specification.md b/docs/specification.md index f7c1a9cc4..9baeff259 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -18647,6 +18647,38 @@ The way child plan results are merged depends on the resource type: * **Other resources**: Pluggable merge strategies based on resource type * **Non-mergeable resources**: May require sequential execution only +##### Selective Subtree Recomputation for Decision Correction + +When a decision in the decomposition tree is found to be incorrect, the system +supports **selective subtree recomputation**: only the subtree rooted at the +target node is re-evaluated, while sibling branches and ancestor nodes are +preserved unchanged. + +This is implemented by `DecompositionService.recompute_subtree()`, which: + +1. Accepts a `node_id` identifying the root of the subtree to recompute and + an `existing_result` (`DecompositionResult`) containing the current tree. +2. Performs a breadth-first search over `children_ids` to collect all nodes + in the subtree (the target node and all its descendants). +3. Partitions the nodes into two groups: + - **Recomputed nodes** — the target node and all its descendants. + - **Preserved nodes** — all other nodes (siblings, ancestors, and unrelated + branches). +4. Returns a `DecisionCorrectionResult` with both groups, the configuration + used, and diagnostic metrics (`recomputed_count`, `preserved_count`, + `subtree_size`). + +**Acceptance criteria:** + +| # | Criterion | Behaviour | +| :- | :-------- | :-------- | +| 1 | Leaf recomputation | Only the target leaf is recomputed; all other nodes preserved | +| 2 | Middle-node recomputation | Target node and all its descendants recomputed; ancestors and siblings preserved | +| 3 | Root recomputation | All nodes recomputed; no nodes preserved | +| 4 | Unknown node | `ValueError` raised when `node_id` is not found in the result | +| 5 | Custom config | Caller-supplied `DecompositionConfig` is attached to the result | +| 6 | Metrics | `recomputed_count`, `preserved_count`, and `subtree_size` always present | + #### The Plan "Decision Tree" and Visualization !!! adr "Architecture Decision" diff --git a/src/cleveragents/application/services/decomposition_models.py b/src/cleveragents/application/services/decomposition_models.py index 82478bd39..6701cf7f7 100644 --- a/src/cleveragents/application/services/decomposition_models.py +++ b/src/cleveragents/application/services/decomposition_models.py @@ -134,7 +134,43 @@ class DecompositionResult: class DecisionCorrectionResult: """Result of selective subtree recomputation for decision correction. - Tracks which nodes were recomputed and which were preserved. + Captures the outcome of :meth:`DecompositionService.recompute_subtree`, + separating the nodes that were re-evaluated from those that were left + intact. This enables callers to audit exactly which parts of the + decomposition tree changed and which were preserved. + + Attributes: + target_node_id: The ``node_id`` of the root node whose subtree was + recomputed. All descendants of this node (inclusive) appear in + ``recomputed_nodes``; all other nodes appear in + ``preserved_nodes``. + recomputed_nodes: Nodes that belong to the recomputed subtree + (the target node and all its descendants, identified via BFS + over ``children_ids``). + preserved_nodes: Nodes outside the recomputed subtree that were + left unchanged. Includes sibling branches and all ancestor + nodes of the target. + config: The :class:`DecompositionConfig` used for this correction + pass. Defaults to :class:`DecompositionConfig` with no + arguments when the caller does not supply one. + metrics: Diagnostic counters produced during recomputation. + Always contains the following keys: + + - ``recomputed_count`` -- number of nodes in the recomputed + subtree. + - ``preserved_count`` -- number of nodes outside the subtree. + - ``subtree_size`` -- total nodes visited during the BFS + (equal to ``recomputed_count``). + + Usage:: + + result = svc.recompute_subtree( + node_id="middle_a", + existing_result=decomp_result, + ) + print(result.recomputed_node_ids) # ["middle_a", "leaf_a1", ...] + print(result.preserved_node_ids) # ["root", "middle_b", ...] + print(result.metrics["recomputed_count"]) """ target_node_id: str diff --git a/src/cleveragents/application/services/decomposition_service.py b/src/cleveragents/application/services/decomposition_service.py index 8d917cdc3..c67a110fe 100644 --- a/src/cleveragents/application/services/decomposition_service.py +++ b/src/cleveragents/application/services/decomposition_service.py @@ -399,7 +399,36 @@ class DecompositionService: existing_result: DecompositionResult, config: DecompositionConfig | None = None, ) -> DecisionCorrectionResult: - """Recompute only the subtree rooted at node_id.""" + """Recompute only the subtree rooted at *node_id*. + + Identifies the subtree via breadth-first search over + :attr:`DecompositionNode.children_ids` and partitions the nodes + from *existing_result* into two groups: those inside the subtree + (recomputed) and those outside it (preserved). Sibling branches + and ancestor nodes are always preserved. + + Args: + node_id: The ``node_id`` of the root of the subtree to + recompute. Must exist in *existing_result*. + existing_result: The current :class:`DecompositionResult` + whose nodes will be partitioned. + config: Optional :class:`DecompositionConfig` to attach to + the result. When ``None``, a default + :class:`DecompositionConfig` is used. + + Returns: + A :class:`DecisionCorrectionResult` containing: + + - ``recomputed_nodes`` -- nodes in the subtree rooted at + *node_id* (inclusive). + - ``preserved_nodes`` -- all other nodes from + *existing_result*. + - ``metrics`` -- diagnostic counters (``recomputed_count``, + ``preserved_count``, ``subtree_size``). + + Raises: + ValueError: If *node_id* is not found in *existing_result*. + """ cfg = config or DecompositionConfig() node_map: dict[str, DecompositionNode] = { n.node_id: n for n in existing_result.nodes