"""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, }