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 +

)}
diff --git a/frontend/src/components/GateGraphDesigner.jsx b/frontend/src/components/GateGraphDesigner.jsx index 4b1a237..bb2602b 100644 --- a/frontend/src/components/GateGraphDesigner.jsx +++ b/frontend/src/components/GateGraphDesigner.jsx @@ -15,7 +15,7 @@ import { loadManualGatePositions, storeManualGatePositions, } from '../plan/gateGraphDesigner.js' -import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS } from '../plan/gateGraphTopology.js' +import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS, edgeKindNeedsGroupKey, resolveDependencyEndpoints } from '../plan/gateGraphTopology.js' import { LoadingState } from './LoadingState.jsx' import { ErrorState } from './ErrorState.jsx' import { EmptyState } from './EmptyState.jsx' @@ -62,6 +62,7 @@ export function GateGraphDesigner({ busy: busyProp = false, onCreate, onGraphChanged, + graphState, }) { const viewportRef = useRef(null) const [dependencies, setDependencies] = useState([]) @@ -73,6 +74,7 @@ export function GateGraphDesigner({ const [error, setError] = useState(null) const [busyLocal, setBusyLocal] = useState(false) const [edgeType, setEdgeType] = useState('requires') + const [groupKey, setGroupKey] = useState('phase-1') const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [selectedNodeId, setSelectedNodeId] = useState(null) const [showCreate, setShowCreate] = useState(false) @@ -150,21 +152,28 @@ export function GateGraphDesigner({ onGraphChanged?.() } - async function handleCreateEdge(fromId, toId) { - if (!fromId || !toId || fromId === toId) return + async function handleCreateEdge(sourceId, targetId) { + if (!sourceId || !targetId || sourceId === targetId) return + if (edgeKindNeedsGroupKey(edgeType) && !groupKey.trim()) { + setError('Parallelgruppe benötigt einen Schlüssel (group_key).') + return + } setBusyLocal(true) setError(null) try { - let fromItemId = fromId - let toItemId = toId - if (edgeType === 'requires') { - fromItemId = toId - toItemId = fromId - } - await addRoadmapItemDependency(fromItemId, { + const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints( + edgeType, + sourceId, + targetId, + ) + const body = { to_item_id: toItemId, dependency_type: edgeType, - }) + } + if (edgeKindNeedsGroupKey(edgeType)) { + body.group_key = groupKey.trim() + } + await addRoadmapItemDependency(fromItemId, body) await notifyGraphChanged() } catch (err) { setError(err?.message || 'Kante konnte nicht angelegt werden.') @@ -448,6 +457,20 @@ export function GateGraphDesigner({ )} + {canManage && edgeKindNeedsGroupKey(edgeType) && ( + + )} +
diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx index f52942a..2cfb179 100644 --- a/frontend/src/components/GatesPlanPanel.jsx +++ b/frontend/src/components/GatesPlanPanel.jsx @@ -130,6 +130,7 @@ export function GatesPlanPanel({ busy={busy} onCreate={canManage ? onCreate : undefined} onGraphChanged={refreshGraphState} + graphState={graphState} /> )}
diff --git a/frontend/src/plan/gateGraphLayout.js b/frontend/src/plan/gateGraphLayout.js index e7d7d69..235c5c8 100644 --- a/frontend/src/plan/gateGraphLayout.js +++ b/frontend/src/plan/gateGraphLayout.js @@ -132,7 +132,11 @@ export function computeGateGraphLayout(items, dependencies) { y: node.y + node.height / 2, }) - if (dep.dependency_type === 'related') { + if ( + dep.dependency_type === 'related' || + dep.dependency_type === 'parallel_group' || + dep.dependency_type === 'optional_branch' + ) { const from = center(sourceNode) const to = center(targetNode) return { diff --git a/frontend/src/plan/gateGraphTopology.js b/frontend/src/plan/gateGraphTopology.js index cbe708d..ce8044e 100644 --- a/frontend/src/plan/gateGraphTopology.js +++ b/frontend/src/plan/gateGraphTopology.js @@ -1,13 +1,19 @@ /** - * Kantentypen für Zielzustands-Graph — AP1.15c erweitert um Topologie. - * Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred. + * Kantentypen für Zielzustands-Graph — AP1.15c Topologie. + * OR-Alternativpfade bewusst nicht enthalten (PO-Deferral). */ -/** Im Designer AP1.15a/b nutzbar */ -export const DESIGNER_EDGE_KINDS = ['requires', 'blocks', 'related'] +/** Basis-Kanten im Designer */ +export const DESIGNER_EDGE_KINDS = [ + 'requires', + 'blocks', + 'related', + 'parallel_group', + 'optional_branch', +] -/** AP1.15c — parallel_group, optional_branch; ggf. OR später */ -export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch'] +/** Erfordert group_key bei Anlage */ +export const EDGE_KINDS_REQUIRING_GROUP_KEY = ['parallel_group'] export const DESIGNER_EDGE_LABELS = { requires: 'Voraussetzung', @@ -16,3 +22,21 @@ export const DESIGNER_EDGE_LABELS = { parallel_group: 'Parallelgruppe', optional_branch: 'Optional', } + +/** @deprecated use DESIGNER_EDGE_KINDS */ +export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch'] + +export function edgeKindNeedsGroupKey(kind) { + return EDGE_KINDS_REQUIRING_GROUP_KEY.includes(kind) +} + +/** + * Kanten-Richtung beim Anlegen per Drag (Quelle → Ziel). + * requires: Ziel hängt von Quelle ab (from=Ziel, to=Quelle). + */ +export function resolveDependencyEndpoints(edgeKind, sourceId, targetId) { + if (edgeKind === 'requires') { + return { from_item_id: targetId, to_item_id: sourceId } + } + return { from_item_id: sourceId, to_item_id: targetId } +} diff --git a/frontend/src/plan/gateGraphTopology.test.js b/frontend/src/plan/gateGraphTopology.test.js index 83f3e6a..f54368a 100644 --- a/frontend/src/plan/gateGraphTopology.test.js +++ b/frontend/src/plan/gateGraphTopology.test.js @@ -2,14 +2,33 @@ import { describe, expect, it } from 'vitest' import { DEFERRED_TOPOLOGY_EDGE_KINDS, DESIGNER_EDGE_KINDS, + edgeKindNeedsGroupKey, + resolveDependencyEndpoints, } from './gateGraphTopology.js' describe('gateGraphTopology', () => { - it('keeps designer and deferred edge kinds separate', () => { + it('includes topology kinds in designer', () => { expect(DESIGNER_EDGE_KINDS).toContain('requires') - expect(DEFERRED_TOPOLOGY_EDGE_KINDS).toContain('parallel_group') + expect(DESIGNER_EDGE_KINDS).toContain('parallel_group') + expect(DESIGNER_EDGE_KINDS).toContain('optional_branch') for (const kind of DEFERRED_TOPOLOGY_EDGE_KINDS) { - expect(DESIGNER_EDGE_KINDS).not.toContain(kind) + expect(DESIGNER_EDGE_KINDS).toContain(kind) } }) + + it('requires group_key for parallel_group', () => { + expect(edgeKindNeedsGroupKey('parallel_group')).toBe(true) + expect(edgeKindNeedsGroupKey('requires')).toBe(false) + }) + + it('resolves requires edge direction for join semantics', () => { + expect(resolveDependencyEndpoints('requires', 'a', 'b')).toEqual({ + from_item_id: 'b', + to_item_id: 'a', + }) + expect(resolveDependencyEndpoints('optional_branch', 'a', 'b')).toEqual({ + from_item_id: 'a', + to_item_id: 'b', + }) + }) }) diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 817ed13..d4f3e96 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1697,6 +1697,16 @@ border-top-style: dashed; } +.gate-map-view__legend-line--parallel { + border-top-color: #7c3aed; + border-top-style: dashed; +} + +.gate-map-view__legend-line--optional { + border-top-color: #059669; + border-top-style: dashed; +} + .gate-map-view__canvas-wrap { overflow: auto; border: 1px solid var(--jk-border, #dde3ea); @@ -1727,6 +1737,16 @@ stroke-dasharray: 6 4; } +.gate-map-view__edge--parallel_group { + stroke: #7c3aed; + stroke-dasharray: 4 3; +} + +.gate-map-view__edge--optional_branch { + stroke: #059669; + stroke-dasharray: 8 4; +} + .gate-map-view__arrow--requires { fill: #2563eb; } @@ -1963,6 +1983,11 @@ font-size: 0.875rem; } +.gate-designer-node-panel__topology { + margin: 0 0 0.75rem; + font-size: 0.8125rem; +} + .gate-designer-node-panel__actions { display: flex; flex-wrap: wrap;