Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597c73bc89 | ||
|
|
7e8910e5ff | ||
|
|
1d69539b9b | ||
|
|
308c06c31d |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+10
-12
@@ -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,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]]
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
"""
|
||||||
Reference in New Issue
Block a user