Compare commits

..
4 Commits
Author SHA1 Message Date
John Lancaster 597c73bc89 readme update 2026-10-10 11:15:08 -05:00
John Lancaster 7e8910e5ff improved tests 2026-10-10 11:05:04 -05:00
John Lancaster 1d69539b9b subgraph isolation 2026-10-10 11:04:53 -05:00
John Lancaster 308c06c31d type modernization 2026-10-10 10:20:22 -05:00
12 changed files with 1942 additions and 447 deletions
+6
View File
@@ -148,6 +148,12 @@ assert set(sub["D"]) == {"E"}
assert set(sub["E"]) == {"F"} assert set(sub["E"]) == {"F"}
``` ```
`subgraph()` returns an independent snapshot of the successor closure, including the seeds. Only edges whose endpoints belong to that closure are retained; incoming dependencies from outside it are excluded.
Unlike the earlier shared-set behavior, mutations to a subgraph do not change its parent, and later parent mutations do not change the subgraph. `sub.reverse` remains a live reverse view of the subgraph itself.
Node objects and callback functions are not copied. The subgraph retains the parent's `on_add` and `on_remove` callbacks without invoking them during extraction, so any external side effects of those callbacks remain shared.
## `DAGSetView` as a Standalone Type ## `DAGSetView` as a Standalone Type
`DAGSetView` can also be used directly as a mutable set wrapper. `DAGSetView` can also be used directly as a mutable set wrapper.
+2 -2
View File
@@ -6,7 +6,7 @@ readme = "README.md"
authors = [ authors = [
{ name = "John Lancaster", email = "[email protected]" } { name = "John Lancaster", email = "[email protected]" }
] ]
requires-python = ">=3.10" requires-python = ">=3.12"
dependencies = [] dependencies = []
[project.scripts] [project.scripts]
@@ -19,6 +19,7 @@ build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"ipykernel>=7.2.0", "ipykernel>=7.2.0",
"notebook>=7.6.3",
"pre-commit>=4.5.1", "pre-commit>=4.5.1",
"rich>=14.3.3", "rich>=14.3.3",
"ruff>=0.15.2", "ruff>=0.15.2",
@@ -54,7 +55,6 @@ select = [
"SIM", # flake8-simplify "SIM", # flake8-simplify
] ]
extend-fixable = ["ALL"] extend-fixable = ["ALL"]
ignore = ["UP046", "UP047"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["daglib"] known-first-party = ["daglib"]
+10 -9
View File
@@ -4,17 +4,15 @@ from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, MutableMapping from collections.abc import Callable, Iterable, Iterator, MutableMapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from graphlib import TopologicalSorter from graphlib import TopologicalSorter
from typing import Generic, TypeVar from typing import Self
from .operations import transitive_closure from .operations import transitive_closure
from .set import DAGSetView from .set import DAGSetView
from .typing import dictset from .typing import dictset
T = TypeVar("T")
@dataclass(repr=False) @dataclass(repr=False)
class DAG(Generic[T], MutableMapping[T, DAGSetView[T]]): class DAG[T](MutableMapping[T, DAGSetView[T]]):
""" """
DAG adjacency map: DAG adjacency map:
- g[u] -> mutable set-like view of successors of u - g[u] -> mutable set-like view of successors of u
@@ -25,13 +23,15 @@ class DAG(Generic[T], MutableMapping[T, DAGSetView[T]]):
""" """
_succ: dictset[T] = field(default_factory=lambda: defaultdict(set)) _succ: dictset[T] = field(default_factory=lambda: defaultdict(set))
"""Mapping of nodes to the set of nodes that succeed them in the DAG."""
_pred: dictset[T] = field(default_factory=lambda: defaultdict(set)) _pred: dictset[T] = field(default_factory=lambda: defaultdict(set))
"""Mapping of nodes to the set of nodes that precede them in the DAG."""
on_add: Callable[[T, T], None] | None = None on_add: Callable[[T, T], None] | None = None
on_remove: Callable[[T, T], None] | None = None on_remove: Callable[[T, T], None] | None = None
@property @property
def reverse(self) -> DAG[T]: def reverse(self) -> Self:
return type(self)( return type(self)(
_succ=self._pred, _succ=self._pred,
_pred=self._succ, _pred=self._succ,
@@ -57,7 +57,7 @@ class DAG(Generic[T], MutableMapping[T, DAGSetView[T]]):
# --- MutableMapping interface --- # --- MutableMapping interface ---
def __setitem__(self, u: T, value: Iterable[T]) -> None: def __setitem__(self, u: T, value: T | Iterable[T]) -> None:
match value: match value:
case set() as vs: case set() as vs:
self.__dagsetmulti__(u, vs) self.__dagsetmulti__(u, vs)
@@ -120,11 +120,12 @@ class DAG(Generic[T], MutableMapping[T, DAGSetView[T]]):
self._succ.pop(u, None) self._succ.pop(u, None)
self._pred.pop(u, None) self._pred.pop(u, None)
def subgraph(self, sub: Iterable[T]) -> DAG[T]: def subgraph(self, sub: T | Iterable[T]) -> Self:
"""Return an independent graph induced by the successor closure of sub."""
closure = transitive_closure(self, sub) closure = transitive_closure(self, sub)
subgraph = type(self)( subgraph = type(self)(
_succ=defaultdict(set, {k: self._succ[k] for k in set(self._succ.keys()) & closure}), _succ=defaultdict(set, {node: self._succ[node] & closure for node in self._succ.keys() & closure}),
_pred=defaultdict(set, {k: self._pred[k] for k in set(self._pred.keys()) & closure}), _pred=defaultdict(set, {node: self._pred[node] & closure for node in self._pred.keys() & closure}),
) )
subgraph.on_add = self.on_add subgraph.on_add = self.on_add
subgraph.on_remove = self.on_remove subgraph.on_remove = self.on_remove
+3 -5
View File
@@ -1,21 +1,19 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING
from .util import ensure_set from .util import ensure_set
if TYPE_CHECKING: if TYPE_CHECKING:
from .dag import DAG from .dag import DAG
T = TypeVar("T")
def all_nodes[T](graph: DAG[T]) -> set[T]:
def all_nodes(graph: DAG) -> set:
return set(graph.keys()) | set(graph.reverse.keys()) return set(graph.keys()) | set(graph.reverse.keys())
def transitive_closure(graph: DAG[T], sub: T | Iterable[T], *, include_self: bool = False): def transitive_closure[T](graph: DAG[T], sub: T | Iterable[T], *, include_self: bool = False) -> set[T]:
sub = ensure_set(sub) sub = ensure_set(sub)
stack = list(sub) stack = list(sub)
seen = set() seen = set()
View File
+10 -12
View File
@@ -1,22 +1,20 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterable, Iterator, MutableSet from collections.abc import Callable, Iterable, Iterator, MutableSet
from typing import Any, TypeVar from typing import Self
from .util import ensure_set from .util import ensure_set
T = TypeVar("T")
class DAGSetView[T](MutableSet[T]):
class DAGSetView(MutableSet[T]):
"""A mutable set-like view onto DAG._succ[u], with reverse-index maintenance.""" """A mutable set-like view onto DAG._succ[u], with reverse-index maintenance."""
_data: set[T] _data: set[T]
on_add: Callable[[Any], None] | None = None on_add: Callable[[T], None] | None = None
on_remove: Callable[[Any], None] | None = None on_remove: Callable[[T], None] | None = None
def __init__(self, v: Iterable[T] | None = None) -> None: def __init__(self, v: T | Iterable[T] | None = None) -> None:
self._data: set[T] = ensure_set(v) self._data: set[T] = ensure_set(v)
# --- required MutableSet methods --- # --- required MutableSet methods ---
@@ -43,23 +41,23 @@ class DAGSetView(MutableSet[T]):
# --- in-place operator support --- # --- in-place operator support ---
def __iadd__(self, other: Iterable[T]) -> DAGSetView[T]: def __iadd__(self, other: T | Iterable[T]) -> Self:
# a += b => update/extend # a += b => update/extend
return super().__ior__(ensure_set(other)) return super().__ior__(ensure_set(other))
def __ior__(self, other: Iterable[T]) -> DAGSetView[T]: def __ior__(self, other: T | Iterable[T]) -> Self:
# a |= b => update/extend # a |= b => update/extend
return super().__ior__(ensure_set(other)) return super().__ior__(ensure_set(other))
def __isub__(self, other: Iterable[T]) -> DAGSetView[T]: def __isub__(self, other: T | Iterable[T]) -> Self:
# a -= b => remove those in other # a -= b => remove those in other
return super().__isub__(ensure_set(other)) return super().__isub__(ensure_set(other))
def __iand__(self, other: Iterable[T]) -> DAGSetView[T]: def __iand__(self, other: T | Iterable[T]) -> Self:
# a &= b => keep only those also in other # a &= b => keep only those also in other
return super().__iand__(ensure_set(other)) return super().__iand__(ensure_set(other))
def __ixor__(self, other: Iterable[T]) -> DAGSetView[T]: def __ixor__(self, other: T | Iterable[T]) -> Self:
# a ^= b => symmetric difference update # a ^= b => symmetric difference update
return super().__ixor__(ensure_set(other)) return super().__ixor__(ensure_set(other))
+2 -5
View File
@@ -2,9 +2,6 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import MutableMapping, MutableSet from collections.abc import MutableMapping, MutableSet
from typing import TypeVar
T = TypeVar("T") type dictset[T] = defaultdict[T, set[T]]
type DAGType[T] = MutableMapping[T, MutableSet[T]]
type dictset = defaultdict[T, set[T]]
type DAGType = MutableMapping[T, MutableSet[T]]
+80
View File
@@ -3,11 +3,17 @@ from __future__ import annotations
import pytest import pytest
from daglib.dag import DAG from daglib.dag import DAG
from daglib.operations import all_nodes, transitive_closure
from daglib.typing import DAGType, dictset
class TestDAGInit: class TestDAGInit:
"""Test DAG initialization.""" """Test DAG initialization."""
def test_generic_alias_parameters(self) -> None:
assert len(dictset.__type_params__) == 1
assert len(DAGType.__type_params__) == 1
def test_init_empty(self) -> None: def test_init_empty(self) -> None:
g = DAG() g = DAG()
assert len(g) == 0 assert len(g) == 0
@@ -281,6 +287,20 @@ class TestDAGCallbacks:
class TestDAGComplexScenarios: class TestDAGComplexScenarios:
"""Test complex usage patterns.""" """Test complex usage patterns."""
def test_typed_traversal_and_subclass_returns(self) -> None:
class IntDAG(DAG[int]):
pass
graph = IntDAG()
graph.add_edge(1, 2)
graph.add_edge(2, 3)
assert all_nodes(graph) == {1, 2, 3}
assert transitive_closure(graph, 1) == {1, 2, 3}
assert isinstance(graph.reverse, IntDAG)
assert isinstance(graph.subgraph(1), IntDAG)
graph.remove_edge(1, 2)
assert 2 not in graph[1]
def test_chain_graph(self) -> None: def test_chain_graph(self) -> None:
g = DAG[str]() g = DAG[str]()
g["foo"] = {"bar"} g["foo"] = {"bar"}
@@ -328,3 +348,63 @@ class TestDAGComplexScenarios:
assert len(sub) == 2 assert len(sub) == 2
assert set(sub["D"]) == {"E"} assert set(sub["D"]) == {"E"}
assert set(sub["E"]) == {"F"} 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"}
+18
View File
@@ -39,6 +39,10 @@ class TestDAGSetInit:
s = DAGSetView(None) s = DAGSetView(None)
assert len(s) == 0 assert len(s) == 0
def test_from_scalar(self) -> None:
s = DAGSetView[int](2)
assert set(s) == {2}
class TestDAGSetBasicOps: class TestDAGSetBasicOps:
"""Test basic set operations.""" """Test basic set operations."""
@@ -169,6 +173,20 @@ class TestDAGSetCallbacks:
class TestDAGSetInPlaceOperators: class TestDAGSetInPlaceOperators:
"""Test in-place set operators.""" """Test in-place set operators."""
def test_scalar_operators_preserve_instance(self) -> None:
class IntSetView(DAGSetView[int]):
pass
view = IntSetView({1, 2})
original = view
view += 3
view |= 4
view -= 1
view &= 2
view ^= 5
assert view is original
assert set(view) == {2, 5}
class TestIOR: class TestIOR:
def test_set(self) -> None: def test_set(self) -> None:
s = DAGSetView({"foo", 2}) s = DAGSetView({"foo", 2})
+76
View File
@@ -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
+334
View File
@@ -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
```
"""
Generated
+1401 -414
View File
File diff suppressed because it is too large Load Diff