diff --git a/tests/test_dag.py b/tests/test_dag.py index cb292a6..2a4e26f 100644 --- a/tests/test_dag.py +++ b/tests/test_dag.py @@ -348,3 +348,63 @@ class TestDAGComplexScenarios: assert len(sub) == 2 assert set(sub["D"]) == {"E"} assert set(sub["E"]) == {"F"} + + nested = sub.subgraph("E") + assert all_nodes(nested) == {"E", "F"} + assert set(nested.reverse["E"]) == set() + nested.remove_edge("E", "F") + assert "F" in sub["E"] + assert "F" in g["E"] + + @pytest.mark.parametrize( + ("seeds", "reverse", "expected_nodes", "expected_edges"), + [ + ({"C", "D"}, False, {"C", "D", "E", "F"}, {("D", "E"), ("E", "F")}), + ("D", True, {"A", "B", "D"}, {("D", "B"), ("B", "A")}), + ("F", False, {"F"}, set()), + ], + ids=["multiple-seeds", "reverse-extraction", "sink-only"], + ) + def test_subgraph_boundaries( + self, + seeds: str | set[str], + reverse: bool, + expected_nodes: set[str], + expected_edges: set[tuple[str, str]], + ) -> None: + graph = DAG[str]() + graph["A"] = "B" + graph["B"] = {"C", "D"} + graph["D"] = "E" + graph["E"] = "F" + + subgraph = (graph.reverse if reverse else graph).subgraph(seeds) + + assert all_nodes(subgraph) == expected_nodes + assert set(subgraph.topo_sort()) == expected_nodes + assert len(subgraph) == len(expected_edges) + assert {(source, target) for source in subgraph for target in subgraph[source]} == expected_edges + assert { + (source, target) for target in subgraph.reverse for source in subgraph.reverse[target] + } == expected_edges + + def test_subgraph_preserves_callbacks_without_firing_during_extraction(self) -> None: + added: list[tuple[str, str]] = [] + removed: list[tuple[str, str]] = [] + graph = DAG[str]() + graph.add_edge("A", "B") + graph.on_add = lambda source, target: added.append((source, target)) + graph.on_remove = lambda source, target: removed.append((source, target)) + + subgraph = graph.subgraph("A") + + assert subgraph.on_add is graph.on_add + assert subgraph.on_remove is graph.on_remove + assert added == [] + assert removed == [] + subgraph["A"].add("C") + subgraph["A"].discard("B") + assert added == [("A", "C")] + assert removed == [("A", "B")] + assert set(graph["A"]) == {"B"} + assert set(graph.reverse["B"]) == {"A"} diff --git a/tests/test_example_build-graph.py b/tests/test_example_build-graph.py new file mode 100644 index 0000000..c6a1776 --- /dev/null +++ b/tests/test_example_build-graph.py @@ -0,0 +1,76 @@ +import pytest + +from daglib.dag import DAG +from daglib.operations import all_nodes + + +@pytest.fixture +def build_graph() -> DAG[str]: + graph = DAG[str]() + graph.add_edge("checkout", "compile") + graph.add_edge("toolchain", "compile") + graph.add_edge("compile", "package") + graph.add_edge("package", "deploy") + return graph + + +def test_subgraph_reverse_mutation_preserves_parent_edge_consistency(build_graph: DAG[str]) -> None: + assert "compile" in build_graph["toolchain"] + assert "toolchain" in build_graph.reverse["compile"] + + pipeline = build_graph.subgraph("checkout") + pipeline.reverse["compile"].discard("toolchain") + + forward_has_edge = "compile" in build_graph["toolchain"] + reverse_has_edge = "toolchain" in build_graph.reverse["compile"] + assert forward_has_edge == reverse_has_edge, "Subgraph mutation desynchronized the parent DAG's edge indexes" + assert forward_has_edge + + +def test_subgraph_reverse_mutation_preserves_parent_topological_dependencies(build_graph: DAG[str]) -> None: + assert "toolchain" in build_graph.topo_sort() + + pipeline = build_graph.subgraph("checkout") + pipeline.reverse["compile"].discard("toolchain") + + assert "compile" in build_graph["toolchain"] + order = build_graph.topo_sort() + assert "toolchain" in order, "Topological sorting omitted a node with an outgoing edge in the parent DAG" + assert order.index("toolchain") < order.index("compile") + + +def test_build_subgraph_excludes_external_dependencies(build_graph: DAG[str]) -> None: + pipeline = build_graph.subgraph("checkout") + + assert all_nodes(pipeline) == {"checkout", "compile", "package", "deploy"} + assert set(pipeline.reverse["compile"]) == {"checkout"} + assert len(pipeline) == 3 + order = pipeline.topo_sort() + assert set(order) == all_nodes(pipeline) + assert order.index("checkout") < order.index("compile") < order.index("package") < order.index("deploy") + + +@pytest.mark.parametrize("mutate_parent", [False, True], ids=["child-mutation", "parent-mutation"]) +@pytest.mark.parametrize("mutation", ["add", "remove", "reverse-remove", "discard-node"]) +def test_build_subgraph_mutations_are_isolated(build_graph: DAG[str], mutate_parent: bool, mutation: str) -> None: + pipeline = build_graph.subgraph("checkout") + changed, protected = (build_graph, pipeline) if mutate_parent else (pipeline, build_graph) + successors = {node: set(protected[node]) for node in protected} + predecessors = {node: set(protected.reverse[node]) for node in protected.reverse} + + match mutation: + case "add": + changed["package"].add("publish") + case "remove": + changed["compile"].discard("package") + case "reverse-remove": + changed.reverse["compile"].discard("checkout") + case "discard-node": + changed.discard_node("compile") + + assert {node: set(protected[node]) for node in protected} == successors + assert {node: set(protected.reverse[node]) for node in protected.reverse} == predecessors + for graph in (changed, protected): + forward_edges = {(source, target) for source in graph for target in graph[source]} + reverse_edges = {(source, target) for target in graph.reverse for source in graph.reverse[target]} + assert forward_edges == reverse_edges diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..c616bec --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,334 @@ +"""Performance test scaffolding for generating large DAGs.""" + +from __future__ import annotations + +import random +import time +from collections.abc import Callable + +import pytest + +from daglib.dag import DAG + + +class DAGGenerator: + """Utility class for procedurally generating test DAGs.""" + + @staticmethod + def chain(n: int) -> DAG[int]: + """ + Generate a simple chain: 0 -> 1 -> 2 -> ... -> n-1 + + Time complexity: O(n) + Edge count: n-1 + """ + g = DAG[int]() + for i in range(n - 1): + g.add_edge(i, i + 1) + return g + + @staticmethod + def tree(depth: int, branching_factor: int = 2) -> DAG[int]: + """ + Generate a balanced tree with given depth and branching factor. + + Args: + depth: Depth of the tree (root is depth 0) + branching_factor: Number of children per node + + Time complexity: O(b^d) where b=branching_factor, d=depth + Node count: (b^(d+1) - 1) / (b - 1) + """ + g = DAG[int]() + node_id = 0 + queue = [(node_id, 0)] # (node_id, current_depth) + node_id += 1 + + while queue: + parent, curr_depth = queue.pop(0) + if curr_depth < depth: + for _ in range(branching_factor): + child = node_id + g.add_edge(parent, child) + queue.append((child, curr_depth + 1)) + node_id += 1 + + return g + + @staticmethod + def diamond(layers: int, nodes_per_layer: int = 10) -> DAG[int]: + """ + Generate a diamond-shaped DAG with multiple layers. + Each node in layer i connects to all nodes in layer i+1. + + Args: + layers: Number of layers + nodes_per_layer: Number of nodes in each middle layer + + Edge count: O(layers * nodes_per_layer^2) + """ + g = DAG[int]() + node_id = 0 + + # Create layers + layer_nodes = [] + for layer in range(layers): + if layer == 0 or layer == layers - 1: + # Source and sink are single nodes + layer_nodes.append([node_id]) + node_id += 1 + else: + # Middle layers have multiple nodes + current_layer = list(range(node_id, node_id + nodes_per_layer)) + layer_nodes.append(current_layer) + node_id += nodes_per_layer + + # Connect layers + for i in range(len(layer_nodes) - 1): + for u in layer_nodes[i]: + for v in layer_nodes[i + 1]: + g.add_edge(u, v) + + return g + + @staticmethod + def random_dag(n_nodes: int, edge_probability: float = 0.1, seed: int | None = None) -> DAG[int]: + """ + Generate a random DAG by considering all possible edges i->j where i < j. + + Args: + n_nodes: Number of nodes + edge_probability: Probability of creating each possible edge + seed: Random seed for reproducibility + + Expected edge count: n*(n-1)/2 * edge_probability + """ + if seed is not None: + random.seed(seed) + + g = DAG[int]() + for i in range(n_nodes): + for j in range(i + 1, n_nodes): + if random.random() < edge_probability: + g.add_edge(i, j) + + return g + + @staticmethod + def grid_dag(rows: int, cols: int) -> DAG[tuple[int, int]]: + """ + Generate a grid DAG where edges flow right and down. + Node (i,j) connects to (i+1,j) and (i,j+1). + + Args: + rows: Number of rows + cols: Number of columns + + Node count: rows * cols + Edge count: ~2 * rows * cols + """ + g = DAG[tuple[int, int]]() + + for i in range(rows): + for j in range(cols): + # Add edge going right + if j < cols - 1: + g.add_edge((i, j), (i, j + 1)) + # Add edge going down + if i < rows - 1: + g.add_edge((i, j), (i + 1, j)) + + return g + + @staticmethod + def layered_random( + n_layers: int, nodes_per_layer: int, connection_probability: float = 0.3, seed: int | None = None + ) -> DAG[int]: + """ + Generate a layered DAG where nodes only connect to the next layer. + + Args: + n_layers: Number of layers + nodes_per_layer: Number of nodes in each layer + connection_probability: Probability of edge between adjacent layers + seed: Random seed + + Guarantees: No cycles (edges only go forward) + """ + if seed is not None: + random.seed(seed) + + g = DAG[int]() + node_id = 0 + layers = [] + + # Create layers + for _ in range(n_layers): + layer = list(range(node_id, node_id + nodes_per_layer)) + layers.append(layer) + node_id += nodes_per_layer + + # Connect adjacent layers + for i in range(len(layers) - 1): + for u in layers[i]: + for v in layers[i + 1]: + if random.random() < connection_probability: + g.add_edge(u, v) + + return g + + @staticmethod + def complete_bipartite(n_left: int, n_right: int) -> DAG[int]: + """ + Generate a complete bipartite DAG. + All nodes in left partition connect to all nodes in right partition. + + Args: + n_left: Number of nodes in left partition + n_right: Number of nodes in right partition + + Edge count: n_left * n_right + """ + g = DAG[int]() + + for i in range(n_left): + for j in range(n_left, n_left + n_right): + g.add_edge(i, j) + + return g + + +# Performance benchmarking utilities + + +def benchmark_operation(operation: Callable[[], None], n_runs: int = 3) -> dict[str, float]: + """ + Benchmark an operation by running it multiple times. + + Returns: + Dictionary with min, max, avg, and median times in seconds + """ + times = [] + for _ in range(n_runs): + start = time.perf_counter() + operation() + end = time.perf_counter() + times.append(end - start) + + times.sort() + return { + "min": min(times), + "max": max(times), + "avg": sum(times) / len(times), + "median": times[len(times) // 2], + } + + +# Example performance tests + + +class TestPerformanceChain: + """Performance tests for chain DAGs.""" + + @pytest.mark.parametrize("n", [100, 1000, 10_000]) + def test_chain_creation(self, n: int) -> None: + """Test creation time for chain DAGs.""" + result = benchmark_operation(lambda: DAGGenerator.chain(n)) + print(f"\nChain({n}) creation: {result['avg']:.4f}s") + + @pytest.mark.parametrize("n", [100, 1000, 5_000]) + def test_chain_traversal(self, n: int) -> None: + """Test traversal time for chain DAGs.""" + g = DAGGenerator.chain(n) + result = benchmark_operation(lambda: g.topo_sort()) + print(f"\nChain({n}) topo_sort: {result['avg']:.4f}s") + + +class TestPerformanceTree: + """Performance tests for tree DAGs.""" + + @pytest.mark.parametrize("depth,branching", [(5, 2), (4, 3), (3, 5)]) + def test_tree_creation(self, depth: int, branching: int) -> None: + """Test creation time for tree DAGs.""" + result = benchmark_operation(lambda: DAGGenerator.tree(depth, branching)) + g = DAGGenerator.tree(depth, branching) + print(f"\nTree(depth={depth}, branch={branching}, nodes={len(g._succ)}) creation: {result['avg']:.4f}s") + + +class TestPerformanceDiamond: + """Performance tests for diamond DAGs.""" + + @pytest.mark.parametrize("layers,nodes", [(5, 10), (5, 50), (10, 20)]) + def test_diamond_creation(self, layers: int, nodes: int) -> None: + """Test creation time for diamond DAGs.""" + result = benchmark_operation(lambda: DAGGenerator.diamond(layers, nodes)) + g = DAGGenerator.diamond(layers, nodes) + print( + f"\nDiamond(layers={layers}, nodes_per_layer={nodes}, total_edges={len(g)}) creation: {result['avg']:.4f}s" + ) + + +class TestPerformanceRandom: + """Performance tests for random DAGs.""" + + @pytest.mark.parametrize("n,prob", [(100, 0.1), (500, 0.05), (1000, 0.01)]) + def test_random_creation(self, n: int, prob: float) -> None: + """Test creation time for random DAGs.""" + result = benchmark_operation(lambda: DAGGenerator.random_dag(n, prob, seed=42)) + g = DAGGenerator.random_dag(n, prob, seed=42) + print(f"\nRandomDAG(n={n}, p={prob}, edges={len(g)}) creation: {result['avg']:.4f}s") + + +# Usage examples in docstring + +""" +Example usage in your own tests: + +```python +from tests.test_performance import DAGGenerator, benchmark_operation + +# Generate a large chain for testing +g = DAGGenerator.chain(10000) + +# Generate a random DAG +g = DAGGenerator.random_dag(1000, edge_probability=0.05, seed=42) + +# Generate a layered DAG (useful for realistic pipeline tests) +g = DAGGenerator.layered_random(10, 100, connection_probability=0.2) + +# Benchmark a custom operation +def my_operation(): + g = DAGGenerator.tree(5, 3) + return g.topo_sort() + +stats = benchmark_operation(my_operation, n_runs=10) +print(f"Operation took {stats['avg']:.4f}s on average") + +# Generate different graph types for stress testing +graphs = { + "small_chain": DAGGenerator.chain(100), + "large_chain": DAGGenerator.chain(10000), + "balanced_tree": DAGGenerator.tree(depth=7, branching_factor=2), + "wide_tree": DAGGenerator.tree(depth=3, branching_factor=10), + "dense_diamond": DAGGenerator.diamond(5, 100), + "sparse_random": DAGGenerator.random_dag(1000, 0.01), + "dense_random": DAGGenerator.random_dag(100, 0.3), + "grid_10x10": DAGGenerator.grid_dag(10, 10), + "grid_100x100": DAGGenerator.grid_dag(100, 100), +} +``` + +To run performance tests: +```bash +pytest tests/test_performance.py -v -s +``` + +To run specific test patterns: +```bash +# Run only chain tests +pytest tests/test_performance.py::TestPerformanceChain -v -s + +# Run with specific parameters +pytest tests/test_performance.py::TestPerformanceTree::test_tree_creation -v -s +``` +"""