Kairo-Jinkendo/backend/steering/graph/join_branch.py
Lars d7aefed0de
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
AP1.15c: Join/Branch-Topologie mit parallel_group und optional_branch.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:27:56 +02:00

108 lines
3.4 KiB
Python

"""Join- und Branch-Topologie — AP1.15c.
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
DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
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]],
item_by_id: dict[str, dict[str, Any]],
blocked_by_map: dict[str, list[str]],
) -> dict[str, list[str]]:
"""
Entfernt optionale Voraussetzungen aus blocked_by.
`parallel_group` ändert blocked_by nicht — Join bleibt über `requires`-Kanten.
"""
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,
}