diff --git a/src/docforge/manual_projection.py b/src/docforge/manual_projection.py index 9e5640c..a249d38 100644 --- a/src/docforge/manual_projection.py +++ b/src/docforge/manual_projection.py @@ -24,47 +24,51 @@ def _cycles(node_ids: tuple[str, ...], edges: tuple[Edge, ...]) -> list[list[str """Return deterministic strongly connected components that represent cycles.""" adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids} + reverse_adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids} for edge in edges: adjacency[edge.source_id].append(edge.target_id) - for targets in adjacency.values(): + reverse_adjacency[edge.target_id].append(edge.source_id) + for targets in (*adjacency.values(), *reverse_adjacency.values()): targets.sort() - index = 0 - indexes: dict[str, int] = {} - lowlinks: dict[str, int] = {} - stack: list[str] = [] - on_stack: set[str] = set() - components: list[list[str]] = [] + visited: set[str] = set() + finished: list[str] = [] + for node_id in node_ids: + if node_id in visited: + continue + visited.add(node_id) + traversal: list[tuple[str, int]] = [(node_id, 0)] + while traversal: + current, position = traversal[-1] + targets = adjacency[current] + if position < len(targets): + target = targets[position] + traversal[-1] = (current, position + 1) + if target not in visited: + visited.add(target) + traversal.append((target, 0)) + continue + finished.append(current) + traversal.pop() - def visit(node_id: str) -> None: - nonlocal index - indexes[node_id] = index - lowlinks[node_id] = index - index += 1 - stack.append(node_id) - on_stack.add(node_id) - for target_id in adjacency[node_id]: - if target_id not in indexes: - visit(target_id) - lowlinks[node_id] = min(lowlinks[node_id], lowlinks[target_id]) - elif target_id in on_stack: - lowlinks[node_id] = min(lowlinks[node_id], indexes[target_id]) - if lowlinks[node_id] != indexes[node_id]: - return + assigned: set[str] = set() + components: list[list[str]] = [] + for node_id in reversed(finished): + if node_id in assigned: + continue + assigned.add(node_id) component: list[str] = [] - while stack: - member = stack.pop() - on_stack.remove(member) - component.append(member) - if member == node_id: - break + component_stack = [node_id] + while component_stack: + current = component_stack.pop() + component.append(current) + for target in reversed(reverse_adjacency[current]): + if target not in assigned: + assigned.add(target) + component_stack.append(target) component.sort() if len(component) > 1 or component[0] in adjacency[component[0]]: components.append(component) - - for node_id in node_ids: - if node_id not in indexes: - visit(node_id) return sorted(components) diff --git a/tests/test_projection_contract.py b/tests/test_projection_contract.py index 26592ae..27844cb 100644 --- a/tests/test_projection_contract.py +++ b/tests/test_projection_contract.py @@ -14,6 +14,7 @@ from unittest import mock from docforge.errors import DocForgeError from docforge.manual_projection import ( + _cycles, build_manual_projection_package, build_manual_render_plan, ) @@ -390,6 +391,21 @@ class ProjectionContractTests(unittest.TestCase): workflow["cross_references"], ) + def test_cycle_detection_handles_the_maximum_deep_graph_iteratively(self) -> None: + node_ids = tuple(f"node.{index:05d}" for index in range(10_000)) + chain = tuple( + Edge(node_ids[index], "depends_on", node_ids[index + 1]) + for index in range(len(node_ids) - 1) + ) + self.assertEqual([], _cycles(node_ids, chain)) + self.assertEqual( + [list(node_ids)], + _cycles( + node_ids, + (*chain, Edge(node_ids[-1], "depends_on", node_ids[0])), + ), + ) + def test_alpha_compatibility_shim_preserves_legacy_identity_and_bytes(self) -> None: renderer = GenericHtmlRenderer() self.assertEqual(ALPHA_RENDERER_VERSION, renderer.renderer_version)