diff --git a/backend/steering/graph/join_branch.py b/backend/steering/graph/join_branch.py index b2b5ac6..06faf24 100644 --- a/backend/steering/graph/join_branch.py +++ b/backend/steering/graph/join_branch.py @@ -1,26 +1,69 @@ -"""Join- und Branch-Topologie — Reservierung für AP1.15c+. +"""Join- und Branch-Topologie — AP1.15c. -Heute blockiert `roadmap_engine.compute_initiative_graph_state` nur über -`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema -vorhanden, werden aber noch nicht für blocked/ready ausgewertet. - -Erweiterungspunkt: neue Auswertung hier kapseln, Engine ruft dann -`apply_topology_to_blocked_map(...)` auf — ohne Router- oder UI-Sonderlogik. - -Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred. +AND-Join: mehrere `requires` auf dasselbe Gate (weiterhin in roadmap_engine). +`optional_branch`: blockiert nicht — wird aus blocked_by entfernt (defensiv). +`parallel_group`: Metadaten für parallele Stränge (group_key); keine Blockade. +OR-Alternativpfade: bewusst nicht implementiert (PO-Deferral). """ from __future__ import annotations +from collections import defaultdict from typing import Any -# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"}) - -# Designer AP1.15c; OR-Pfade nur nach validiertem Use Case + PO-Freigabe PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",) +def _dep_type(dep: dict[str, Any]) -> str: + return dep.get("dependency_type") or dep.get("edge_kind") or "requires" + + +def build_topology_metadata( + *, + dependencies: list[dict[str, Any]], + item_by_id: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """Read-only Topologie-Hinweise für UI/Steuerung.""" + parallel_groups: dict[str, list[str]] = defaultdict(list) + optional_edges: list[dict[str, str]] = [] + + for dep in dependencies: + dep_type = _dep_type(dep) + from_id = str(dep["from_item_id"]) + to_id = str(dep["to_item_id"]) + if from_id not in item_by_id or to_id not in item_by_id: + continue + + if dep_type == "optional_branch": + optional_edges.append({"from_item_id": from_id, "to_item_id": to_id}) + elif dep_type == "parallel_group": + group_key = (dep.get("group_key") or "default").strip() or "default" + for item_id in (from_id, to_id): + if item_id not in parallel_groups[group_key]: + parallel_groups[group_key].append(item_id) + + return { + "parallel_groups": dict(parallel_groups), + "optional_branches": optional_edges, + } + + +def _optional_prerequisite_pairs( + dependencies: list[dict[str, Any]], + item_by_id: dict[str, dict[str, Any]], +) -> set[tuple[str, str]]: + pairs: set[tuple[str, str]] = set() + for dep in dependencies: + if _dep_type(dep) != "optional_branch": + continue + from_id = str(dep["from_item_id"]) + to_id = str(dep["to_item_id"]) + if from_id in item_by_id and to_id in item_by_id: + pairs.add((from_id, to_id)) + return pairs + + def apply_deferred_topology( *, dependencies: list[dict[str, Any]], @@ -28,9 +71,37 @@ def apply_deferred_topology( blocked_by_map: dict[str, list[str]], ) -> dict[str, list[str]]: """ - Erweitert blocked_by_map um parallel_group / optional_branch (AP1.15c). + Entfernt optionale Voraussetzungen aus blocked_by. - Aktuell: No-Op — gibt blocked_by_map unverändert zurück. + `parallel_group` ändert blocked_by nicht — Join bleibt über `requires`-Kanten. """ - _ = (dependencies, item_by_id) - return blocked_by_map + optional_pairs = _optional_prerequisite_pairs(dependencies, item_by_id) + if not optional_pairs: + return blocked_by_map + + adjusted: dict[str, list[str]] = {} + for item_id, blockers in blocked_by_map.items(): + filtered = [b for b in blockers if (item_id, b) not in optional_pairs] + adjusted[item_id] = filtered + return adjusted + + +def topology_hints_for_item( + *, + item_id: str, + topology: dict[str, Any], +) -> dict[str, Any]: + parallel_keys = [ + key + for key, members in topology.get("parallel_groups", {}).items() + if item_id in members + ] + optional_waiting_on = [ + edge["to_item_id"] + for edge in topology.get("optional_branches", []) + if edge["from_item_id"] == item_id + ] + return { + "parallel_group_keys": parallel_keys, + "optional_prerequisites": optional_waiting_on, + } diff --git a/backend/steering/graph/roadmap_engine.py b/backend/steering/graph/roadmap_engine.py index 6aab5d0..451e6d9 100644 --- a/backend/steering/graph/roadmap_engine.py +++ b/backend/steering/graph/roadmap_engine.py @@ -10,7 +10,11 @@ from steering.graph.profiles import ( get_graph_profile, graph_profile_as_dict, ) -from steering.graph.join_branch import apply_deferred_topology +from steering.graph.join_branch import ( + apply_deferred_topology, + build_topology_metadata, + topology_hints_for_item, +) TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"}) @@ -113,6 +117,11 @@ def compute_initiative_graph_state( blocked_by_map=blocked_by_map, ) + topology = build_topology_metadata( + dependencies=dependencies, + item_by_id=item_by_id, + ) + item_states: dict[str, dict[str, Any]] = {} blocked_items: list[str] = [] ready_items: list[str] = [] @@ -127,12 +136,15 @@ def compute_initiative_graph_state( ) ratio = _fulfillment_ratio(criteria_progress.get(item_id)) + hints = topology_hints_for_item(item_id=item_id, topology=topology) item_states[item_id] = { "status": status, "blocked": blocked, "ready": ready, "blocked_by": blocked_by, "fulfillment_ratio": ratio, + "parallel_group_keys": hints["parallel_group_keys"], + "optional_prerequisites": hints["optional_prerequisites"], } if blocked and status in ACTIVE_STATUSES: blocked_items.append(item_id) @@ -143,6 +155,7 @@ def compute_initiative_graph_state( "items": item_states, "blocked_items": blocked_items, "ready_items": ready_items, + "topology": topology, } diff --git a/backend/tests/test_ap15c_join_branch.py b/backend/tests/test_ap15c_join_branch.py new file mode 100644 index 0000000..467a931 --- /dev/null +++ b/backend/tests/test_ap15c_join_branch.py @@ -0,0 +1,70 @@ +"""Tests for join/branch topology — AP1.15c.""" + +from steering.graph.join_branch import ( + apply_deferred_topology, + build_topology_metadata, +) +from steering.graph.roadmap_engine import compute_initiative_graph_state + + +def _item(item_id: str, status: str = "planned", **kwargs): + return { + "id": item_id, + "status": status, + "sort_order": kwargs.get("sort_order", 0), + "sequencing_mode": kwargs.get("sequencing_mode", "sequential"), + "title": kwargs.get("title", item_id), + } + + +def test_optional_branch_does_not_block(): + items = [_item("join", "planned"), _item("opt", "planned"), _item("req", "planned")] + deps = [ + { + "from_item_id": "join", + "to_item_id": "req", + "dependency_type": "requires", + }, + { + "from_item_id": "join", + "to_item_id": "opt", + "dependency_type": "optional_branch", + }, + ] + state = compute_initiative_graph_state(items=items, dependencies=deps) + assert state["items"]["join"]["blocked"] is True + assert state["items"]["join"]["blocked_by"] == ["req"] + assert "opt" not in state["items"]["join"]["blocked_by"] + assert state["items"]["join"]["optional_prerequisites"] == ["opt"] + + +def test_parallel_group_metadata(): + items = [_item("a"), _item("b"), _item("c")] + deps = [ + { + "from_item_id": "b", + "to_item_id": "c", + "dependency_type": "parallel_group", + "group_key": "phase-1", + } + ] + topology = build_topology_metadata(dependencies=deps, item_by_id={i["id"]: i for i in items}) + assert set(topology["parallel_groups"]["phase-1"]) == {"b", "c"} + + +def test_apply_deferred_topology_strips_optional_blockers(): + item_by_id = {"x": _item("x"), "y": _item("y")} + blocked_by_map = {"x": ["y", "z"]} + deps = [ + { + "from_item_id": "x", + "to_item_id": "y", + "dependency_type": "optional_branch", + } + ] + result = apply_deferred_topology( + dependencies=deps, + item_by_id=item_by_id, + blocked_by_map=blocked_by_map, + ) + assert result["x"] == ["z"] diff --git a/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md b/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md index fa93f20..8a6c341 100644 --- a/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md +++ b/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md @@ -224,6 +224,9 @@ Plan-UI: **Detail-Modal/Route** `/initiatives/:id/plan/items/:itemId` — Checkl | **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage | | **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) | | **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer | +| **AP1.15d** | (optional) OR-Alternativpfade — nur bei validiertem Use Case | + +**Stand AP1.15c (2026-07-11):** AND-Join über `requires`; optional blockiert nicht; parallel als Metadaten; OR ausstehend. --- diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 1175161..4852fff 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -105,7 +105,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Abhängigkeiten | ◐ | API; Graph Engine AP1.4d | | parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d | | Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt | -| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a Designer MVP; Join/Branch → AP1.15c (deferred, vorbereitet) | +| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a/b Designer; AP1.15c parallel/optional in Engine+Designer; OR deferred | | Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots | | Verify vor `reached` | ◐ | Kriterienplan + gate_override | | Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b | diff --git a/frontend/src/components/GateDesignerNodePanel.jsx b/frontend/src/components/GateDesignerNodePanel.jsx index 5d87a33..be454e7 100644 --- a/frontend/src/components/GateDesignerNodePanel.jsx +++ b/frontend/src/components/GateDesignerNodePanel.jsx @@ -19,11 +19,14 @@ function formatDate(value) { export function GateDesignerNodePanel({ item, + graphState, criteriaProgress, onClose, }) { if (!item) return null + const graphItem = graphState?.items?.[item.id] + const progress = criteriaProgress?.[item.id] const closed = progress?.closed ?? 0 const total = progress?.total ?? 0 @@ -69,8 +72,20 @@ export function GateDesignerNodePanel({ )} - {item.goal_description && ( -
{item.goal_description}
+ {item.goal_description && ( +{item.goal_description}
+ )} + + {graphItem?.parallel_group_keys?.length > 0 && ( ++ Parallel: {graphItem.parallel_group_keys.join(', ')} +
+ )} + + {graphItem?.optional_prerequisites?.length > 0 && ( ++ Optional: {graphItem.optional_prerequisites.length} Zweig(e) — blockieren nicht +
)}