diff --git a/src/orcapod/pipeline/dag.py b/src/orcapod/pipeline/dag.py new file mode 100644 index 000000000..976343fe0 --- /dev/null +++ b/src/orcapod/pipeline/dag.py @@ -0,0 +1,348 @@ +"""Lean directed acyclic graph for Orcapod pipeline topology. + +Replaces `networkx.DiGraph` with a minimal, zero-dependency implementation +covering exactly the nine API shapes Orcapod requires. Backed by plain dicts +and `graphlib.TopologicalSorter` from the Python standard library. + +See superpowers/specs/2026-05-21-networkx-replacement-design.md for the full +rationale and migration map. + +Example: + >>> dag: OrcaDAG[str] = OrcaDAG() + >>> dag.add_edge("a", "b") + >>> dag.add_edge("b", "c") + >>> dag.topological_sort() + ['a', 'b', 'c'] +""" + +from __future__ import annotations + +import heapq +from collections.abc import Hashable +from graphlib import CycleError, TopologicalSorter +from typing import ( + Any, + Generic, + Iterable, + Iterator, + Protocol, + TypeVar, + runtime_checkable, +) + +__all__ = ["Comparable", "GraphBackend", "OrcaDAG", "CycleError"] + + +class Comparable(Hashable, Protocol): + """Protocol for node types that support both hashing and ordering. + + Nodes must be hashable to serve as dict keys and must support `<` + for deterministic topological sort via `heapq`. + + Any type that implements `__hash__` (or inherits it from `object`) + and `__lt__` satisfies this protocol — e.g. `str`, `int`, or a + custom dataclass with those methods defined. + """ + + def __lt__(self, other: Any) -> bool: ... + + +# Class-level TypeVar: only requires hashability (usable as a dict key). +NodeT = TypeVar("NodeT", bound=Hashable) + +# Method-level TypeVar for topological_sort_deterministic: additionally +# requires ordering so heapq operations are type-safe. +ComparableNodeT = TypeVar("ComparableNodeT", bound=Comparable) + + +@runtime_checkable +class GraphBackend(Protocol[NodeT]): + """Structural protocol for DAG backend implementations. + + Both `OrcaDAG` and `NetworkxBackend` satisfy this protocol, enabling + callers to switch graph implementations via a config flag (ENG-494). + + NodeT must be `Hashable`. The `topological_sort_deterministic` method + is not part of this protocol because it additionally requires NodeT to + satisfy `Comparable`; callers that need deterministic ordering should + use `OrcaDAG` or `NetworkxBackend` directly with a comparable node type + (e.g. `OrcaDAG[str]`). + """ + + def add_node(self, node: NodeT, **attrs: Any) -> None: ... + + def add_edge(self, u: NodeT, v: NodeT) -> None: ... + + def node_attrs(self, node: NodeT) -> dict[str, Any]: ... + + def __contains__(self, node: object) -> bool: ... + + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[NodeT]: ... + + def nodes(self) -> Iterable[NodeT]: ... + + def edges(self) -> Iterable[tuple[NodeT, NodeT]]: ... + + def successors(self, node: NodeT) -> frozenset[NodeT]: ... + + def predecessors(self, node: NodeT) -> frozenset[NodeT]: ... + + def in_degree(self, node: NodeT) -> int: ... + + def topological_sort(self) -> list[NodeT]: ... + + +class OrcaDAG(Generic[NodeT]): + """Minimal directed acyclic graph for Orcapod pipeline topology. + + Covers exactly the operations Orcapod needs — DAG construction, node + attribute storage, basic traversal, and topological sort. No external + dependencies; backed entirely by plain dicts and stdlib `graphlib`. + + Satisfies `GraphBackend[NodeT]` for any hashable NodeT. + + Args: + NodeT: The node type. Must be hashable (used as a dict key). + To call `topological_sort_deterministic`, NodeT must additionally + satisfy `Comparable` (support `<` ordering). + """ + + def __init__(self) -> None: + # node → mutable attribute dict + self._attrs: dict[NodeT, dict[str, Any]] = {} + # node → set of immediate successors (outgoing edges) + self._successors: dict[NodeT, set[NodeT]] = {} + # node → set of immediate predecessors (incoming edges) + self._predecessors: dict[NodeT, set[NodeT]] = {} + # node → number of incoming edges (kept in sync with _predecessors) + self._in_degree: dict[NodeT, int] = {} + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + + def add_node(self, node: NodeT, **attrs: Any) -> None: + """Add *node* if not already present, optionally setting attributes. + + Safe to call multiple times; subsequent calls with the same node do + not overwrite existing attributes unless new ones are passed. + + Args: + node: The node to add. + **attrs: Attribute key/value pairs to store on the node. + """ + if node not in self._attrs: + self._attrs[node] = {} + self._successors[node] = set() + self._predecessors[node] = set() + self._in_degree[node] = 0 + if attrs: + self._attrs[node].update(attrs) + + def add_edge(self, u: NodeT, v: NodeT) -> None: + """Add a directed edge from *u* to *v*. + + Both nodes are implicitly added if not already present. Adding an + edge that already exists is a no-op (idempotent). + + Args: + u: Source node. + v: Target node. + """ + self.add_node(u) + self.add_node(v) + if v not in self._successors[u]: + self._successors[u].add(v) + self._predecessors[v].add(u) + self._in_degree[v] += 1 + + # ------------------------------------------------------------------ + # Node attribute access + # ------------------------------------------------------------------ + + def node_attrs(self, node: NodeT) -> dict[str, Any]: + """Return the mutable attribute dict for *node*. + + The returned dict is live — mutations are reflected in the graph. + + Args: + node: The node whose attributes to access. + + Returns: + Mutable attribute dict for the node. + + Raises: + KeyError: If *node* is not in the graph. + """ + return self._attrs[node] + + # ------------------------------------------------------------------ + # Membership and sizing + # ------------------------------------------------------------------ + + def __contains__(self, node: object) -> bool: + return node in self._attrs + + def __len__(self) -> int: + return len(self._attrs) + + def __iter__(self) -> Iterator[NodeT]: + """Iterate over all nodes (insertion order).""" + return iter(self._attrs) + + # ------------------------------------------------------------------ + # Traversal + # ------------------------------------------------------------------ + + def nodes(self) -> Iterable[NodeT]: + """Return an iterable over all nodes in insertion order. + + Returns: + Iterable of all nodes. + """ + return self._attrs.keys() + + def edges(self) -> Iterable[tuple[NodeT, NodeT]]: + """Return an iterable over all (source, target) edge pairs. + + Returns: + Iterable of (u, v) tuples for every directed edge in the graph. + """ + for u, successors in self._successors.items(): + for v in successors: + yield u, v + + def successors(self, node: NodeT) -> frozenset[NodeT]: + """Return the immediate successors (outgoing neighbours) of *node*. + + Returns a snapshot `frozenset` so callers cannot mutate internal + graph state through the returned value. + + Args: + node: The source node. + + Returns: + Frozen set of nodes that *node* has a directed edge to. + + Raises: + KeyError: If *node* is not in the graph. + """ + return frozenset(self._successors[node]) + + def predecessors(self, node: NodeT) -> frozenset[NodeT]: + """Return the immediate predecessors (incoming neighbours) of *node*. + + Returns a snapshot `frozenset` so callers cannot mutate internal + graph state through the returned value. + + Args: + node: The target node. + + Returns: + Frozen set of nodes that have a directed edge to *node*. + + Raises: + KeyError: If *node* is not in the graph. + """ + return frozenset(self._predecessors[node]) + + def in_degree(self, node: NodeT) -> int: + """Return the number of incoming edges for *node*. + + Args: + node: The node to query. + + Returns: + Count of edges pointing into *node*. + + Raises: + KeyError: If *node* is not in the graph. + """ + return self._in_degree[node] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_predecessor_dict(self) -> dict[NodeT, set[NodeT]]: + """Build the predecessor mapping required by TopologicalSorter. + + Returns a copy of the internal predecessor sets — safe for mutation + by `graphlib.TopologicalSorter`. + + Returns: + Mapping of each node to a fresh copy of its predecessor set. + """ + return {node: set(preds) for node, preds in self._predecessors.items()} + + # ------------------------------------------------------------------ + # Ordering + # ------------------------------------------------------------------ + + def topological_sort(self) -> list[NodeT]: + """Return nodes in a valid topological order. + + Uses `graphlib.TopologicalSorter` (Python stdlib). The order is + stable within a single run but is not guaranteed to be + deterministic across Python versions or between runs. Use + `topological_sort_deterministic` when a stable, reproducible + order is required (e.g. for content hashing). + + Returns: + List of nodes in topological order (sources before dependents). + + Raises: + graphlib.CycleError: If the graph contains a cycle. + """ + ts: TopologicalSorter[NodeT] = TopologicalSorter(self._build_predecessor_dict()) + return list(ts.static_order()) + + def topological_sort_deterministic( + self: "OrcaDAG[ComparableNodeT]", + ) -> list[ComparableNodeT]: + """Return nodes in a deterministic topological order. + + Implements Kahn's algorithm with a min-heap frontier so that the + output ordering is stable across runs and Python versions. + + This method is type-gated: it is only callable on an `OrcaDAG` + whose node type satisfies `Comparable` (supports `<`). Graphs + whose nodes are only `Hashable` — e.g. `OrcaDAG[GraphNode]` — + must use `topological_sort` instead. + + This is a direct port of the existing Kahn's implementation already + present in `graph.py` (`_compute_pipeline_snapshot_hash`), moved + here so it lives alongside the graph abstraction it operates on. + + Returns: + List of nodes in deterministic topological order. + + Raises: + graphlib.CycleError: If the graph contains a cycle. + """ + in_deg: dict[ComparableNodeT, int] = dict(self._in_degree) + frontier: list[ComparableNodeT] = [n for n, d in in_deg.items() if d == 0] + heapq.heapify(frontier) + ordered: list[ComparableNodeT] = [] + + while frontier: + node: ComparableNodeT = heapq.heappop(frontier) + ordered.append(node) + for successor in sorted(self._successors[node]): + in_deg[successor] -= 1 + if in_deg[successor] == 0: + heapq.heappush(frontier, successor) + + if len(ordered) != len(self._attrs): + # Kahn's detected a cycle (unprocessed nodes remain). Delegate to + # TopologicalSorter so the raised CycleError has its .cycle + # attribute (args[1]) populated with the offending nodes — the + # same information callers get from topological_sort(). + list( + TopologicalSorter(self._build_predecessor_dict()).static_order() + ) # raises CycleError + raise AssertionError("unreachable") # pragma: no cover + + return ordered diff --git a/src/orcapod/pipeline/networkx_backend.py b/src/orcapod/pipeline/networkx_backend.py new file mode 100644 index 000000000..1b2a5c464 --- /dev/null +++ b/src/orcapod/pipeline/networkx_backend.py @@ -0,0 +1,282 @@ +"""Thin networkx adapter satisfying the GraphBackend protocol. + +`NetworkxBackend` wraps `networkx.DiGraph` and exposes the same interface as +`OrcaDAG`, allowing callers to swap implementations via a config flag without +changing any call sites. + +This module is scaffolding for the forthcoming ENG-494 migration. It is not +yet wired into the pipeline — the active code still uses `networkx.DiGraph` +directly. Once the pipeline is migrated, a single flag will choose between +`OrcaDAG` (the default) and `NetworkxBackend` (compatibility / debugging). + +Behavioural notes vs. `OrcaDAG`: +- `topological_sort()` is implemented via `graphlib.TopologicalSorter` (not + `nx.topological_sort`) so that cycles always raise `graphlib.CycleError` — + the same exception type that `OrcaDAG.topological_sort()` raises. Using + the networkx native sort would raise `nx.NetworkXUnfeasible` instead. + +Example: + >>> backend: NetworkxBackend[str] = NetworkxBackend() + >>> backend.add_edge("a", "b") + >>> backend.topological_sort() + ['a', 'b'] +""" + +from __future__ import annotations + +import heapq +from collections.abc import Hashable +from graphlib import CycleError, TopologicalSorter +from typing import TYPE_CHECKING, Any, Generic, Iterable, Iterator, TypeVar + +from orcapod.pipeline.dag import Comparable, ComparableNodeT +from orcapod.utils.lazy_module import LazyModule + +if TYPE_CHECKING: + import networkx as nx +else: + nx = LazyModule("networkx") + +__all__ = ["NetworkxBackend"] + +NodeT = TypeVar("NodeT", bound=Hashable) + + +class NetworkxBackend(Generic[NodeT]): + """Thin `networkx.DiGraph` adapter satisfying the `GraphBackend` protocol. + + Wraps an internal `nx.DiGraph` and provides the same twelve-method surface + as `OrcaDAG`. Intended for use as a drop-in during migration (ENG-494) and + as a debugging aid when you want to inspect the graph with networkx tools + (visualisation, path queries, etc.) without changing call sites. + + All mutation methods (`add_node`, `add_edge`) delegate directly to the + wrapped `DiGraph`. Query methods (`successors`, `predecessors`, `in_degree`, + `node_attrs`) translate between networkx's attribute-dict style and the + `GraphBackend` interface. + + Args: + NodeT: The node type. Must be hashable (same constraint as `DiGraph`). + To call `topological_sort_deterministic`, NodeT must additionally + satisfy `Comparable` (support `<` ordering). + """ + + def __init__(self) -> None: + self._graph: nx.DiGraph = nx.DiGraph() + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + + def add_node(self, node: NodeT, **attrs: Any) -> None: + """Add *node* if not already present, optionally setting attributes. + + Safe to call multiple times; subsequent calls with the same node do + not overwrite existing attributes unless new ones are passed. + + Args: + node: The node to add. + **attrs: Attribute key/value pairs to store on the node. + """ + if node not in self._graph: + self._graph.add_node(node, **attrs) + elif attrs: + self._graph.nodes[node].update(attrs) + + def add_edge(self, u: NodeT, v: NodeT) -> None: + """Add a directed edge from *u* to *v*. + + Both nodes are implicitly added if not already present. Adding an + edge that already exists is a no-op (idempotent). + + Args: + u: Source node. + v: Target node. + """ + self._graph.add_edge(u, v) + + # ------------------------------------------------------------------ + # Node attribute access + # ------------------------------------------------------------------ + + def node_attrs(self, node: NodeT) -> dict[str, Any]: + """Return the mutable attribute dict for *node*. + + The returned dict is live — mutations are reflected in the graph. + + Args: + node: The node whose attributes to access. + + Returns: + Mutable attribute dict for the node. + + Raises: + KeyError: If *node* is not in the graph. + """ + if node not in self._graph: + raise KeyError(node) + return self._graph.nodes[node] + + # ------------------------------------------------------------------ + # Membership and sizing + # ------------------------------------------------------------------ + + def __contains__(self, node: object) -> bool: + return node in self._graph + + def __len__(self) -> int: + return len(self._graph) + + def __iter__(self) -> Iterator[NodeT]: + """Iterate over all nodes (insertion order, Python ≥ 3.7+).""" + return iter(self._graph) + + # ------------------------------------------------------------------ + # Traversal + # ------------------------------------------------------------------ + + def nodes(self) -> Iterable[NodeT]: + """Return an iterable over all nodes in insertion order. + + Returns: + Iterable of all nodes. + """ + return self._graph.nodes() + + def edges(self) -> Iterable[tuple[NodeT, NodeT]]: + """Return an iterable over all (source, target) edge pairs. + + Returns: + Iterable of (u, v) tuples for every directed edge in the graph. + """ + return self._graph.edges() + + def successors(self, node: NodeT) -> frozenset[NodeT]: + """Return the immediate successors (outgoing neighbours) of *node*. + + Returns a snapshot `frozenset` so callers cannot mutate internal graph + state through the returned value. + + Args: + node: The source node. + + Returns: + Frozen set of nodes that *node* has a directed edge to. + + Raises: + KeyError: If *node* is not in the graph. + """ + if node not in self._graph: + raise KeyError(node) + return frozenset(self._graph.successors(node)) + + def predecessors(self, node: NodeT) -> frozenset[NodeT]: + """Return the immediate predecessors (incoming neighbours) of *node*. + + Returns a snapshot `frozenset` so callers cannot mutate internal graph + state through the returned value. + + Args: + node: The target node. + + Returns: + Frozen set of nodes that have a directed edge to *node*. + + Raises: + KeyError: If *node* is not in the graph. + """ + if node not in self._graph: + raise KeyError(node) + return frozenset(self._graph.predecessors(node)) + + def in_degree(self, node: NodeT) -> int: + """Return the number of incoming edges for *node*. + + Args: + node: The node to query. + + Returns: + Count of edges pointing into *node*. + + Raises: + KeyError: If *node* is not in the graph. + """ + if node not in self._graph: + raise KeyError(node) + # nx.DiGraph.in_degree(node) returns an int when the node is present. + return self._graph.in_degree(node) # type: ignore[return-value] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_predecessor_dict(self) -> dict[NodeT, set[NodeT]]: + """Build the predecessor mapping required by `TopologicalSorter`. + + Returns: + Mapping of each node to a fresh copy of its predecessor set. + """ + return {node: set(self._graph.predecessors(node)) for node in self._graph} + + # ------------------------------------------------------------------ + # Ordering + # ------------------------------------------------------------------ + + def topological_sort(self) -> list[NodeT]: + """Return nodes in a valid topological order. + + Uses `graphlib.TopologicalSorter` (Python stdlib) rather than + `nx.topological_sort`, so that cycles raise `graphlib.CycleError` + (the same exception as `OrcaDAG.topological_sort`). + + Returns: + List of nodes in topological order (sources before dependents). + + Raises: + graphlib.CycleError: If the graph contains a cycle. + """ + ts: TopologicalSorter[NodeT] = TopologicalSorter(self._build_predecessor_dict()) + return list(ts.static_order()) + + def topological_sort_deterministic( + self: "NetworkxBackend[ComparableNodeT]", + ) -> list[ComparableNodeT]: + """Return nodes in a deterministic topological order. + + Implements Kahn's algorithm with a min-heap frontier so that the + output ordering is stable across runs and Python versions. Mirrors + `OrcaDAG.topological_sort_deterministic` exactly. + + This method is type-gated: it is only callable on a `NetworkxBackend` + whose node type satisfies `Comparable` (supports `<`). + + Returns: + List of nodes in deterministic topological order. + + Raises: + graphlib.CycleError: If the graph contains a cycle. + """ + in_deg: dict[ComparableNodeT, int] = { + node: self._graph.in_degree(node) for node in self._graph + } + frontier: list[ComparableNodeT] = [n for n, d in in_deg.items() if d == 0] + heapq.heapify(frontier) + ordered: list[ComparableNodeT] = [] + + while frontier: + node: ComparableNodeT = heapq.heappop(frontier) + ordered.append(node) + for successor in sorted(self._graph.successors(node)): + in_deg[successor] -= 1 + if in_deg[successor] == 0: + heapq.heappush(frontier, successor) + + if len(ordered) != len(self._graph): + # Delegate to TopologicalSorter so the CycleError has its .cycle + # attribute populated with the offending nodes. + list( + TopologicalSorter(self._build_predecessor_dict()).static_order() + ) # raises CycleError + raise AssertionError("unreachable") # pragma: no cover + + return ordered diff --git a/superpowers/specs/2026-05-21-networkx-replacement-design.md b/superpowers/specs/2026-05-21-networkx-replacement-design.md new file mode 100644 index 000000000..85d476cad --- /dev/null +++ b/superpowers/specs/2026-05-21-networkx-replacement-design.md @@ -0,0 +1,305 @@ +# Orcapod — networkx Replacement Design + +**Issue:** ENG-492 +**Date:** 2026-05-21 +**Status:** Spike / Recommendation +**Author:** Kurodo (agent-kurodo[bot]) + +--- + +## Executive Summary + +Orcapod should replace `networkx` with a lean, in-house `OrcaDAG` class +(`src/orcapod/pipeline/dag.py`, ~255 lines including full docstrings and type +annotations; core logic is under 80 lines). The case rests on +three converging facts: + +1. Orcapod's networkx API surface is minimal — nine call shapes covering basic + DAG construction, traversal, and topological sort. +2. Python's standard library (`graphlib.TopologicalSorter`, Python ≥ 3.9) already + provides the only non-trivial algorithm needed; the rest is a thin dict wrapper. +3. networkx's release history shows two major migration guides (1.x → 2.0, 2.x → + 3.0) with documented breaking changes, and the dep is currently **unpinned** in + `pyproject.toml` — a silent breakage risk on every `uv sync`. + +The full migration (wiring `OrcaDAG` into `graph.py`, the orchestrators, and the +test suite) is scoped as a separate follow-on issue (ENG-494, see §7). This spike +delivers the prototype and the recommendation that justifies it. + +--- + +## 1. networkx Stability — Fact-Check + +The concern that networkx "has been unstable" is **directionally correct but +requires precision**. The evidence: + +### What changed across major versions + +| Migration | Key breaking changes | Relevant to Orcapod? | +|---|---|---| +| 1.x → 2.0 | `G.nodes()` changed from list to `NodeView`; `G.node` removed in favour of `G.nodes[n]`; `set_node_attributes` parameter order changed; several methods moved to main namespace | **Potentially** — `G.nodes[key]` is the new pattern, which orcapod already uses. Any code written against 1.x would break. | +| 2.x → 3.0 | `read_gpickle`, `write_gpickle`, `read_yaml`, `write_yaml` removed; `decorator` library dep removed | **No** — orcapod uses none of these. | + +### Orcapod's specific API surface vs. breakage history + +The nine API shapes orcapod uses (`DiGraph()`, `add_edge`, `add_node`, +`topological_sort`, `nodes[]`, `in_degree`, `successors`, `nodes()`, `edges()`) +are all **core, stable APIs** that survived both major version bumps intact. There +is no evidence these specific calls were broken in the 2.x or 3.x series. + +### The real risk is not historical but structural + +- The dep is **unpinned** (`"networkx"` with no version constraint in `pyproject.toml`). +- A hypothetical networkx 4.0 could change any of these APIs without warning. +- Every `uv sync` in CI or a developer environment can silently pull a new + networkx release. +- The existing mismatch already shows this in practice: `uv.lock` pins `3.5` + while the system venv has `3.4.2`. + +**Verdict:** The instability concern is valid as a *forward risk*, not a documented +historical regression on orcapod's specific calls. The unpinned dep is the concrete +problem. Replacing networkx eliminates this class of risk entirely rather than +patching it with a version pin. + +--- + +## 2. Dependency Footprint + +### networkx alone + +| Metric | Value | +|---|---| +| Wheel size | **2.1 MB** (networkx 3.6.1, pure Python) | +| Required transitive deps | **None** (removed in 3.0; numpy/scipy/matplotlib are optional) | +| Optional dep groups | `default`, `extra`, `developer`, `doc` (all optional) | + +### What orcapod actually exercises + +Orcapod uses **nine distinct API shapes** from networkx: + +| API | Call sites | Purpose | +|---|---|---| +| `nx.DiGraph()` | 9 | Create directed graph | +| `.add_edge(u, v)` | 7 | Record a dependency edge | +| `.add_node(n)` | 4 | Add isolated node | +| `nx.topological_sort(g)` | 4 | Execution/compile ordering | +| `.nodes[key]` | 4 | Read/write per-node attributes | +| `.in_degree(n)` | 1 | Kahn's algorithm (custom impl already) | +| `.successors(n)` | 1 | Kahn's algorithm (custom impl already) | +| `.nodes()` | 4 | Iterate all nodes | +| `.edges()` | 4 | Iterate all edges | + +The 2.1 MB import provides thousands of graph algorithms (shortest path, centrality, +clustering, network flow, etc.) that orcapod never calls. This is the legitimate +"too heavy for what we use" concern — not transitive deps, but the sheer ratio of +imported-but-unused code. + +--- + +## 3. Alternatives Matrix + +Four candidates were evaluated: + +| Candidate | Dep footprint | Stability | Migration cost | License | Verdict | +|---|---|---|---|---|---| +| **networkx** (status quo) | 2.1 MB, no transitive | Good within a pinned version; unpinned is risky | — | BSD-3 | Keep only if pinned as a stopgap | +| **rustworkx** | Rust wheel est. 3–5 MB per platform (varies by arch); PyO3 bindings | Mature (IBM/Qiskit); active | Low (networkx-adjacent API) | Apache-2.0 | Good but still external; adds wheel-per-platform CI complexity | +| **python-igraph** | C extension, ~1.5 MB; libigraph ~5 MB native | Very mature; stable | Medium (different API) | GPL-2.0 | **GPL is a license concern** for an MIT library | +| **stdlib `graphlib`** | Zero (Python ≥ 3.9 stdlib) | Stable, maintained by CPython | Medium (only provides TopologicalSorter; no DiGraph container) | PSF (stdlib) | Good building block; not a standalone replacement | +| **In-house `OrcaDAG`** | Zero | Under orcapod's own control | Low (we write the interface to match usage exactly) | MIT (same as orcapod) | **Recommended** | + +### Why not rustworkx? + +`rustworkx` is an excellent library and would be the right call if orcapod's graph +operations were performance-sensitive or algorithmically complex. They are not. +Orcapod pipeline graphs have tens of nodes at most. At that scale, a pure-Python +`dict` is faster than any FFI boundary. Adding a Rust wheel also introduces per- +platform CI complexity (manylinux, macOS x86_64/arm64, Windows) that has zero +payoff at orcapod's graph sizes. + +### Why not "reinventing the wheel"? + +The "reinventing the wheel" anti-pattern applies to code with **genuine hidden +complexity**: correctness edge cases (cryptography), performance tuning +(numerics), or a large combinatorial test surface (HTTP). A DAG data structure +backed by dicts does not qualify: + +- The only non-trivial algorithm — topological sort — is already in the Python + standard library (`graphlib.TopologicalSorter`). +- Orcapod already **reimplemented the most complex variant** (deterministic + topological sort via Kahn's + min-heap) in `graph.py` lines 559–571. The team + has already proven it can own this code. +- The replacement is ~120 lines including type annotations and docstrings. This + is smaller than the average orcapod test file. +- Ownership cost: near-zero. The only operations are dict mutations and one + stdlib call. + +Owning 120 lines of well-typed, well-tested dict manipulation is strictly better +than depending on a 2.1 MB library with its own release cadence, deprecation +policy, and scope. + +--- + +## 4. Recommendation + +**Build and adopt an in-house `OrcaDAG`** (`src/orcapod/pipeline/dag.py`). + +- Zero new external dependencies. +- Eliminates the instability risk permanently (no external release cadence to track). +- The full API surface is trivially implementable using stdlib `graphlib` for + topological sort and plain `dict` for everything else. +- Well-typed (`OrcaDAG[NodeT]`) so both usage patterns (hash-string nodes and + `GraphNode` object nodes) work without casting. +- Covered by orcapod's existing MIT license. + +The full migration (wiring `OrcaDAG` into the three call-site files and updating +tests) is tracked as a follow-on issue (ENG-494, created as part of this spike). + +--- + +## 5. `OrcaDAG` Interface Design + +### Public API + +```python +from __future__ import annotations +from collections.abc import Hashable +from typing import Any, Generic, Iterable, Protocol, TypeVar + +class Comparable(Hashable, Protocol): + """Nodes must be hashable (dict keys) and support < (heapq / sorted).""" + def __lt__(self, other: Any) -> bool: ... + +NodeT = TypeVar("NodeT", bound=Comparable) + +class OrcaDAG(Generic[NodeT]): + """Minimal directed acyclic graph for Orcapod pipeline topology. + + Covers exactly the nine API shapes Orcapod needs; nothing more. + Backed by plain dicts and stdlib graphlib. Zero external dependencies. + """ + + # Construction + def add_node(self, node: NodeT, **attrs: Any) -> None: ... + def add_edge(self, u: NodeT, v: NodeT) -> None: ... + # Implicitly calls add_node for u and v if not already present. + # Adding a duplicate edge is a no-op (idempotent). + + # Node attribute access — replaces nx.DiGraph.nodes[key] + def node_attrs(self, node: NodeT) -> dict[str, Any]: ... + # Returns the mutable attribute dict for node. + # KeyError if node not present. + + # Membership + def __contains__(self, node: object) -> bool: ... + + # Traversal + def nodes(self) -> Iterable[NodeT]: ... + def edges(self) -> Iterable[tuple[NodeT, NodeT]]: ... + def successors(self, node: NodeT) -> frozenset[NodeT]: ... + # Returns a snapshot frozenset — callers cannot corrupt _in_degree + # by mutating the returned collection. + def in_degree(self, node: NodeT) -> int: ... + + # Ordering + def topological_sort(self) -> list[NodeT]: ... + # Non-deterministic (insertion-order DFS via graphlib). + # Raises CycleError if the graph contains a cycle. + + def topological_sort_deterministic(self) -> list[NodeT]: ... + # Deterministic (Kahn's + min-heap). Type-safe because NodeT is + # bounded to Comparable. Used for snapshot hash computation where + # ordering must be stable across runs and Python versions. +``` + +### Internal representation + +``` +_attrs: dict[NodeT, dict[str, Any]] # node → attribute dict +_successors: dict[NodeT, set[NodeT]] # node → set of outgoing neighbours +_in_degree: dict[NodeT, int] # node → count of incoming edges +``` + +All three structures are updated atomically in `add_node` and `add_edge`. No +networkx object is referenced anywhere in the implementation. + +### Replacing `nx.DiGraph` in call sites + +| networkx call | OrcaDAG equivalent | +|---|---| +| `nx.DiGraph()` | `OrcaDAG()` | +| `g.add_edge(u, v)` | `g.add_edge(u, v)` | +| `g.add_node(n)` | `g.add_node(n)` | +| `nx.topological_sort(g)` | `g.topological_sort()` | +| `g.nodes[key]` | `g.node_attrs(key)` | +| `g.nodes[key].get("x")` | `g.node_attrs(key).get("x")` | +| `g.nodes[key]["x"] = v` | `g.node_attrs(key)["x"] = v` | +| `for n in g` | `for n in g.nodes()` | +| `for n in g.nodes()` | `for n in g.nodes()` | +| `for u, v in g.edges()` | `for u, v in g.edges()` | +| `g.in_degree(n)` | `g.in_degree(n)` | +| `for s in g.successors(n)` | `for s in g.successors(n)` | +| `n not in g` | `n not in g` | + +The only non-trivial translation is `g.nodes[key]` → `g.node_attrs(key)`, which +is a straightforward find-and-replace in `graph.py`. + +--- + +## 6. Prototype (`dag.py`) + +The working prototype is committed at `src/orcapod/pipeline/dag.py` as part of +this spike. It implements the full interface above and is covered by +`tests/test_pipeline/test_dag.py`. + +The prototype is **self-contained** — it does not yet replace networkx in +`graph.py`, `sync_orchestrator.py`, or `async_orchestrator.py`. That wiring is +left to the follow-on issue (ENG-494) to keep this spike's diff reviewable. + +--- + +## 7. Migration Surface Map (for follow-on issue ENG-494) + +The full migration requires changes in exactly five places: + +| File | Change required | +|---|---| +| `src/orcapod/pipeline/graph.py` | Replace all `nx.DiGraph()` with `OrcaDAG()`; replace `.nodes[key]` with `.node_attrs(key)`; replace `nx.topological_sort(g)` with `g.topological_sort()` or `g.topological_sort_deterministic()`; remove `LazyModule("networkx")` import; update type annotations | +| `src/orcapod/pipeline/sync_orchestrator.py` | Replace `nx.DiGraph` type annotation with `OrcaDAG`; replace `nx.topological_sort(graph)` with `graph.topological_sort()` | +| `src/orcapod/pipeline/async_orchestrator.py` | Same as sync_orchestrator | +| `tests/test_pipeline/test_graph_rendering.py` | Remove `import networkx as nx`; update type annotations; update any direct `nx.DiGraph` construction in test fixtures | +| `pyproject.toml` | Remove `"networkx"` from `dependencies` | + +See **ENG-494**: https://linear.app/enigma-metamorphic/issue/ENG-494 + +Estimated call-site count: ~38 changes across these files (9 DiGraph +instantiations, 7 add_edge, 4 add_node, 4 topological_sort, 4 nodes[], 1 +in_degree, 1 successors, 4 nodes(), 4 edges() — plus import and type annotation +cleanups). + +--- + +## 8. Testing Plan for `OrcaDAG` + +`tests/test_pipeline/test_dag.py` covers: + +- `add_node` / `add_edge` — node and edge membership, implicit node creation +- `node_attrs` — read/write attributes, KeyError on missing node +- `nodes()` / `edges()` — correct enumeration +- `in_degree()` / `successors()` — correct values after edge additions +- `topological_sort()` — valid topological order on a representative DAG +- `topological_sort_deterministic()` — stable ordering across repeated calls +- `CycleError` on a graph with a cycle +- `__contains__` — membership check +- Generic usage — `OrcaDAG[str]` and `OrcaDAG[object]` both work + +--- + +## References + +- [ENG-492 Linear Issue](https://linear.app/enigma-metamorphic/issue/ENG-492) +- [networkx 1.x → 2.0 migration guide](https://networkx.org/documentation/stable/release/migration_guide_from_1.x_to_2.0.html) +- [networkx 2.x → 3.0 migration guide](https://networkx.org/documentation/stable/release/migration_guide_from_2.x_to_3.0.html) +- [networkx PyPI page](https://pypi.org/project/networkx/) (3.6.1: 2.1 MB wheel) +- [Python stdlib graphlib](https://docs.python.org/3/library/graphlib.html) +- Orcapod networkx call sites: `src/orcapod/pipeline/graph.py`, `sync_orchestrator.py`, `async_orchestrator.py` diff --git a/tests/test_pipeline/test_dag.py b/tests/test_pipeline/test_dag.py new file mode 100644 index 000000000..4c653fad3 --- /dev/null +++ b/tests/test_pipeline/test_dag.py @@ -0,0 +1,432 @@ +"""Tests for OrcaDAG — the lean in-house DAG replacing networkx.DiGraph. + +Covers all nine API shapes used by Orcapod: +- add_node / add_edge (construction) +- node_attrs (attribute dict access) +- nodes() / edges() (traversal) +- in_degree() / successors() (local graph queries) +- topological_sort() / topological_sort_deterministic() (ordering) +- __contains__ / __len__ / __iter__ (membership and sizing) +""" + +from __future__ import annotations + +import pytest +from graphlib import CycleError + +from orcapod.pipeline.dag import OrcaDAG + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestAddNode: + def test_adds_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + assert "a" in dag + + def test_add_node_idempotent(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + dag.add_node("a") # second call is a no-op + assert len(dag) == 1 + + def test_add_node_with_attrs(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a", label="source", node_type="source") + assert dag.node_attrs("a")["label"] == "source" + assert dag.node_attrs("a")["node_type"] == "source" + + def test_subsequent_add_node_does_not_clear_existing_attrs(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a", label="original") + dag.add_node("a") # no attrs — must not clear existing + assert dag.node_attrs("a")["label"] == "original" + + def test_subsequent_add_node_merges_new_attrs(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a", label="original") + dag.add_node("a", node_type="source") + assert dag.node_attrs("a")["label"] == "original" + assert dag.node_attrs("a")["node_type"] == "source" + + def test_subsequent_add_node_overwrites_same_key(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a", label="original") + dag.add_node("a", label="updated") # same key, different value + assert dag.node_attrs("a")["label"] == "updated" + + +class TestAddEdge: + def test_adds_both_nodes_implicitly(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + assert "a" in dag + assert "b" in dag + + def test_adds_edge(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + assert ("a", "b") in list(dag.edges()) + + def test_duplicate_edge_is_idempotent(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("a", "b") + assert list(dag.edges()).count(("a", "b")) == 1 + + def test_multiple_edges_from_same_source(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("a", "c") + edges = set(dag.edges()) + assert ("a", "b") in edges + assert ("a", "c") in edges + + +# --------------------------------------------------------------------------- +# Node attribute access +# --------------------------------------------------------------------------- + + +class TestNodeAttrs: + def test_returns_mutable_dict(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + attrs = dag.node_attrs("a") + attrs["x"] = 42 + assert dag.node_attrs("a")["x"] == 42 + + def test_dict_access_pattern(self) -> None: + """Mirrors nx.DiGraph.nodes[key] access pattern used in graph.py.""" + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + dag.node_attrs("a")["node_type"] = "source" + assert dag.node_attrs("a").get("node_type") == "source" + assert dag.node_attrs("a").get("missing") is None + + def test_raises_key_error_for_missing_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + with pytest.raises(KeyError): + dag.node_attrs("nonexistent") + + +# --------------------------------------------------------------------------- +# Membership and sizing +# --------------------------------------------------------------------------- + + +class TestMembership: + def test_contains_after_add_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + assert "a" in dag + assert "b" not in dag + + def test_not_in_empty_dag(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert "a" not in dag + + def test_len_empty(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert len(dag) == 0 + + def test_len_after_add_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + dag.add_node("b") + assert len(dag) == 2 + + def test_len_edge_does_not_double_count(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") # implicitly adds 2 nodes + assert len(dag) == 2 + + def test_bool_empty_is_false(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert not dag + + def test_bool_nonempty_is_true(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + assert dag + + def test_iter_yields_nodes(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + dag.add_node("b") + assert set(dag) == {"a", "b"} + + def test_in_degree_comprehension_pattern(self) -> None: + """Mirrors the dict-comprehension pattern in graph.py line 560.""" + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("a", "c") + in_degrees = {n: dag.in_degree(n) for n in dag} + assert in_degrees == {"a": 0, "b": 1, "c": 1} + + +# --------------------------------------------------------------------------- +# Traversal +# --------------------------------------------------------------------------- + + +class TestNodes: + def test_returns_all_nodes(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + dag.add_node("b") + assert set(dag.nodes()) == {"a", "b"} + + def test_empty_dag(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert list(dag.nodes()) == [] + + +class TestEdges: + def test_returns_all_edges(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("b", "c") + assert set(dag.edges()) == {("a", "b"), ("b", "c")} + + def test_empty_dag(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert list(dag.edges()) == [] + + def test_isolated_node_has_no_edges(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("a") + assert list(dag.edges()) == [] + + def test_sorted_edges_pattern(self) -> None: + """Mirrors sorted(g.edges()) used in graph.py line 576.""" + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("b", "c") + dag.add_edge("a", "b") + assert sorted(dag.edges()) == [("a", "b"), ("b", "c")] + + +class TestSuccessors: + def test_returns_direct_successors(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("a", "c") + assert set(dag.successors("a")) == {"b", "c"} + + def test_leaf_node_has_no_successors(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + assert set(dag.successors("b")) == set() + + def test_raises_key_error_for_missing_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + with pytest.raises(KeyError): + list(dag.successors("nonexistent")) + + +class TestPredecessors: + def test_returns_direct_predecessors(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "c") + dag.add_edge("b", "c") + assert dag.predecessors("c") == {"a", "b"} + + def test_root_node_has_no_predecessors(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + assert dag.predecessors("a") == frozenset() + + def test_returns_frozenset_snapshot(self) -> None: + """Returned frozenset must not expose internal state to mutation.""" + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + snap = dag.predecessors("b") + assert isinstance(snap, frozenset) + + def test_raises_key_error_for_missing_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + with pytest.raises(KeyError): + dag.predecessors("nonexistent") + + +class TestInDegree: + def test_source_node_has_in_degree_zero(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + assert dag.in_degree("a") == 0 + + def test_single_incoming_edge(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + assert dag.in_degree("b") == 1 + + def test_multiple_incoming_edges(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "c") + dag.add_edge("b", "c") + assert dag.in_degree("c") == 2 + + def test_raises_key_error_for_missing_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + with pytest.raises(KeyError): + dag.in_degree("nonexistent") + + +# --------------------------------------------------------------------------- +# Topological sort +# --------------------------------------------------------------------------- + + +class TestTopologicalSort: + def _is_valid_topo_order(self, dag: OrcaDAG[str], order: list[str]) -> bool: + """Return True if *order* is a valid topological ordering of *dag*.""" + position = {node: idx for idx, node in enumerate(order)} + for u, v in dag.edges(): + if position[u] >= position[v]: + return False + return True + + def test_linear_chain(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("b", "c") + order = dag.topological_sort() + assert self._is_valid_topo_order(dag, order) + assert set(order) == {"a", "b", "c"} + + def test_diamond_dag(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("a", "c") + dag.add_edge("b", "d") + dag.add_edge("c", "d") + order = dag.topological_sort() + assert self._is_valid_topo_order(dag, order) + assert set(order) == {"a", "b", "c", "d"} + + def test_isolated_nodes_included(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("isolated") + dag.add_edge("a", "b") + order = dag.topological_sort() + assert set(order) == {"isolated", "a", "b"} + + def test_single_node(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_node("only") + assert dag.topological_sort() == ["only"] + + def test_empty_dag(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert dag.topological_sort() == [] + + def test_raises_cycle_error(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("b", "c") + dag.add_edge("c", "a") # cycle + with pytest.raises(CycleError): + dag.topological_sort() + + +class TestTopologicalSortDeterministic: + def _is_valid_topo_order(self, dag: OrcaDAG[str], order: list[str]) -> bool: + position = {node: idx for idx, node in enumerate(order)} + for u, v in dag.edges(): + if position[u] >= position[v]: + return False + return True + + def test_linear_chain(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("b", "c") + order = dag.topological_sort_deterministic() + assert order == ["a", "b", "c"] + + def test_diamond_dag_deterministic(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("a", "c") + dag.add_edge("b", "d") + dag.add_edge("c", "d") + order = dag.topological_sort_deterministic() + assert self._is_valid_topo_order(dag, order) + # With Kahn's + min-heap: "b" < "c" so b before c + assert order.index("b") < order.index("c") + + def test_stable_across_repeated_calls(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("z", "m") + dag.add_edge("z", "a") + dag.add_edge("m", "b") + dag.add_edge("a", "b") + first = dag.topological_sort_deterministic() + second = dag.topological_sort_deterministic() + assert first == second + + def test_insertion_order_independent(self) -> None: + """Same graph, different insertion order → same deterministic output.""" + dag1: OrcaDAG[str] = OrcaDAG() + dag1.add_edge("a", "c") + dag1.add_edge("b", "c") + + dag2: OrcaDAG[str] = OrcaDAG() + dag2.add_edge("b", "c") + dag2.add_edge("a", "c") + + assert dag1.topological_sort_deterministic() == ( + dag2.topological_sort_deterministic() + ) + + def test_raises_cycle_error(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("a", "b") + dag.add_edge("b", "a") + with pytest.raises(CycleError): + dag.topological_sort_deterministic() + + def test_empty_dag(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert dag.topological_sort_deterministic() == [] + + +# --------------------------------------------------------------------------- +# Generic type support +# --------------------------------------------------------------------------- + + +class TestGenericNodeTypes: + def test_string_nodes(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + dag.add_edge("hash_a", "hash_b") + assert "hash_a" in dag + + def test_object_nodes(self) -> None: + """OrcaPod uses GraphNode objects as nodes in _node_graph.""" + + class FakeNode: + def __init__(self, name: str) -> None: + self.name = name + + def __lt__(self, other: object) -> bool: + assert isinstance(other, FakeNode) + return self.name < other.name + + a, b = FakeNode("a"), FakeNode("b") + dag: OrcaDAG[FakeNode] = OrcaDAG() + dag.add_edge(a, b) + assert a in dag + assert b in dag + assert dag.successors(a) == {b} + + def test_integer_nodes(self) -> None: + dag: OrcaDAG[int] = OrcaDAG() + dag.add_edge(1, 2) + dag.add_edge(2, 3) + order = dag.topological_sort() + assert set(order) == {1, 2, 3} diff --git a/tests/test_pipeline/test_networkx_backend.py b/tests/test_pipeline/test_networkx_backend.py new file mode 100644 index 000000000..3cf68490b --- /dev/null +++ b/tests/test_pipeline/test_networkx_backend.py @@ -0,0 +1,315 @@ +"""Tests for NetworkxBackend — the thin networkx.DiGraph adapter. + +Verifies that `NetworkxBackend` is behaviourally equivalent to `OrcaDAG` for +the full `GraphBackend` protocol surface, and that both classes satisfy the +`GraphBackend` protocol at runtime (via `isinstance` with the +`@runtime_checkable` decorator). +""" + +from __future__ import annotations + +import pytest +from graphlib import CycleError + +from orcapod.pipeline.dag import GraphBackend, OrcaDAG +from orcapod.pipeline.networkx_backend import NetworkxBackend + + +# --------------------------------------------------------------------------- +# Protocol conformance +# --------------------------------------------------------------------------- + + +class TestGraphBackendProtocol: + def test_orca_dag_satisfies_protocol(self) -> None: + dag: OrcaDAG[str] = OrcaDAG() + assert isinstance(dag, GraphBackend) + + def test_networkx_backend_satisfies_protocol(self) -> None: + backend: NetworkxBackend[str] = NetworkxBackend() + assert isinstance(backend, GraphBackend) + + def test_both_accept_same_call_sites(self) -> None: + """Verify both backends can be used interchangeably via GraphBackend.""" + + def populate(g: GraphBackend[str]) -> None: # type: ignore[type-arg] + g.add_edge("a", "b") + g.add_edge("b", "c") + + orca: OrcaDAG[str] = OrcaDAG() + nx_b: NetworkxBackend[str] = NetworkxBackend() + populate(orca) + populate(nx_b) + assert set(orca.nodes()) == set(nx_b.nodes()) + assert set(orca.edges()) == set(nx_b.edges()) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestAddNode: + def test_adds_node(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a") + assert "a" in b + + def test_add_node_idempotent(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a") + b.add_node("a") + assert len(b) == 1 + + def test_add_node_with_attrs(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a", label="source") + assert b.node_attrs("a")["label"] == "source" + + def test_subsequent_add_node_does_not_clear_existing_attrs(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a", label="original") + b.add_node("a") + assert b.node_attrs("a")["label"] == "original" + + def test_subsequent_add_node_merges_new_attrs(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a", label="original") + b.add_node("a", node_type="source") + assert b.node_attrs("a")["label"] == "original" + assert b.node_attrs("a")["node_type"] == "source" + + def test_subsequent_add_node_overwrites_same_key(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a", label="original") + b.add_node("a", label="updated") + assert b.node_attrs("a")["label"] == "updated" + + +class TestAddEdge: + def test_adds_both_nodes_implicitly(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert "a" in b + assert "b" in b + + def test_adds_edge(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert ("a", "b") in list(b.edges()) + + def test_duplicate_edge_is_idempotent(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("a", "b") + assert list(b.edges()).count(("a", "b")) == 1 + + +# --------------------------------------------------------------------------- +# Node attribute access +# --------------------------------------------------------------------------- + + +class TestNodeAttrs: + def test_returns_mutable_dict(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a") + attrs = b.node_attrs("a") + attrs["x"] = 42 + assert b.node_attrs("a")["x"] == 42 + + def test_raises_key_error_for_missing_node(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + with pytest.raises(KeyError): + b.node_attrs("nonexistent") + + +# --------------------------------------------------------------------------- +# Membership and sizing +# --------------------------------------------------------------------------- + + +class TestMembership: + def test_contains_after_add_node(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a") + assert "a" in b + assert "z" not in b + + def test_len_counts_nodes(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert len(b) == 2 + + def test_iter_yields_nodes(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_node("a") + b.add_node("b") + assert set(b) == {"a", "b"} + + +# --------------------------------------------------------------------------- +# Traversal +# --------------------------------------------------------------------------- + + +class TestSuccessors: + def test_returns_direct_successors(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("a", "c") + assert b.successors("a") == {"b", "c"} + + def test_leaf_node_has_no_successors(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert b.successors("b") == frozenset() + + def test_raises_key_error_for_missing_node(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + with pytest.raises(KeyError): + b.successors("nonexistent") + + +class TestPredecessors: + def test_returns_direct_predecessors(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "c") + b.add_edge("b", "c") + assert b.predecessors("c") == {"a", "b"} + + def test_root_node_has_no_predecessors(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert b.predecessors("a") == frozenset() + + def test_returns_frozenset_snapshot(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + snap = b.predecessors("b") + assert isinstance(snap, frozenset) + + def test_raises_key_error_for_missing_node(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + with pytest.raises(KeyError): + b.predecessors("nonexistent") + + +class TestInDegree: + def test_source_node_has_in_degree_zero(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert b.in_degree("a") == 0 + + def test_single_incoming_edge(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + assert b.in_degree("b") == 1 + + def test_multiple_incoming_edges(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "c") + b.add_edge("b", "c") + assert b.in_degree("c") == 2 + + def test_raises_key_error_for_missing_node(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + with pytest.raises(KeyError): + b.in_degree("nonexistent") + + +# --------------------------------------------------------------------------- +# Topological sort +# --------------------------------------------------------------------------- + + +class TestTopologicalSort: + def _is_valid_topo_order( + self, backend: NetworkxBackend[str], order: list[str] + ) -> bool: + position = {node: idx for idx, node in enumerate(order)} + for u, v in backend.edges(): + if position[u] >= position[v]: + return False + return True + + def test_linear_chain(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("b", "c") + order = b.topological_sort() + assert self._is_valid_topo_order(b, order) + assert set(order) == {"a", "b", "c"} + + def test_diamond_dag(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("a", "c") + b.add_edge("b", "d") + b.add_edge("c", "d") + order = b.topological_sort() + assert self._is_valid_topo_order(b, order) + assert set(order) == {"a", "b", "c", "d"} + + def test_raises_cycle_error(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("b", "c") + b.add_edge("c", "a") + with pytest.raises(CycleError): + b.topological_sort() + + def test_empty_graph(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + assert b.topological_sort() == [] + + +class TestTopologicalSortDeterministic: + def test_linear_chain(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("b", "c") + assert b.topological_sort_deterministic() == ["a", "b", "c"] + + def test_diamond_dag_deterministic(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("a", "c") + b.add_edge("b", "d") + b.add_edge("c", "d") + order = b.topological_sort_deterministic() + # Kahn's + min-heap: "b" < "c" so b before c + assert order.index("b") < order.index("c") + + def test_stable_across_repeated_calls(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("z", "m") + b.add_edge("z", "a") + b.add_edge("m", "b") + b.add_edge("a", "b") + assert b.topological_sort_deterministic() == b.topological_sort_deterministic() + + def test_matches_orca_dag_output(self) -> None: + """Both backends must produce identical deterministic order.""" + edges = [("z", "m"), ("z", "a"), ("m", "b"), ("a", "b")] + + dag: OrcaDAG[str] = OrcaDAG() + backend: NetworkxBackend[str] = NetworkxBackend() + for u, v in edges: + dag.add_edge(u, v) + backend.add_edge(u, v) + + assert dag.topological_sort_deterministic() == ( + backend.topological_sort_deterministic() + ) + + def test_raises_cycle_error(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + b.add_edge("a", "b") + b.add_edge("b", "a") + with pytest.raises(CycleError): + b.topological_sort_deterministic() + + def test_empty_graph(self) -> None: + b: NetworkxBackend[str] = NetworkxBackend() + assert b.topological_sort_deterministic() == []