""" BFS 影响传播 — P1-21 / 3.2.17 Phase 1 组件。 根据 BlastRadius 在任务 DAG 中传播影响,标记每个任务为 IMPACTED / BOUNDARY / SAFE。 """ from __future__ import annotations import enum from collections import deque from dataclasses import dataclass, field from air_runtime.change_classifier import BlastRadius from air_runtime.task_graph import TaskGraph class ImpactLabel(enum.Enum): IMPACTED = "impacted" BOUNDARY = "boundary" SAFE = "safe" @dataclass class PropagationResult: impacted: list[str] = field(default_factory=list) boundary: list[str] = field(default_factory=list) safe: list[str] = field(default_factory=list) class ImpactPropagator: """BFS 影响传播,基于 BlastRadius 差异化标记。 传播规则: - IMPLEMENTATION: 仅直接引用 ADR 的任务 → IMPACTED,其他 → SAFE(不下游传播) - INTERFACE: 直接引用 → IMPACTED,下游 1 跳邻居 → BOUNDARY,其他 → SAFE - GLOBAL_CONSTRAINT: 全图 → IMPACTED 结果存储在 TaskNode.meta["impact_label"],不修改 status 字段(正交)。 """ _META_KEY = "impact_label" def propagate(self, graph: TaskGraph, adr_id: str, blast_radius: BlastRadius) -> PropagationResult: self.clear_labels(graph) tasks_ref_adr = [n.id for n in graph.nodes.values() if adr_id in n.adr_refs] all_ids = set(graph.nodes.keys()) if blast_radius == BlastRadius.GLOBAL_CONSTRAINT: for nid in all_ids: graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.IMPACTED.value return PropagationResult( impacted=list(all_ids), boundary=[], safe=[], ) if blast_radius == BlastRadius.IMPLEMENTATION: impacted = list(tasks_ref_adr) safe = [nid for nid in all_ids if nid not in impacted] for nid in impacted: graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.IMPACTED.value for nid in safe: graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.SAFE.value return PropagationResult(impacted=impacted, boundary=[], safe=safe) # INTERFACE: direct refs → IMPACTED, 1-hop downstream → BOUNDARY, rest → SAFE impacted = set(tasks_ref_adr) # BFS 收集下游 1 跳邻居(仅从 IMPACTED 出发,排除自身) boundary = set() for nid in list(impacted): node = graph.nodes.get(nid) if node: for target in node.out_edges: if target not in impacted: boundary.add(target) safe = all_ids - impacted - boundary for nid in impacted: graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.IMPACTED.value for nid in boundary: graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.BOUNDARY.value for nid in safe: graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.SAFE.value return PropagationResult( impacted=list(impacted), boundary=list(boundary), safe=list(safe), ) def impacted_tasks(self, graph: TaskGraph) -> list[str]: return [nid for nid, n in graph.nodes.items() if n.meta.get(self._META_KEY) == ImpactLabel.IMPACTED.value] def boundary_tasks(self, graph: TaskGraph) -> list[str]: return [nid for nid, n in graph.nodes.items() if n.meta.get(self._META_KEY) == ImpactLabel.BOUNDARY.value] def safe_tasks(self, graph: TaskGraph) -> list[str]: return [nid for nid, n in graph.nodes.items() if n.meta.get(self._META_KEY) == ImpactLabel.SAFE.value] def clear_labels(self, graph: TaskGraph) -> None: for node in graph.nodes.values(): node.meta.pop(self._META_KEY, None)