28. Validate a Model-Serving Pipeline Has No Cycle
Problem
A voice-agent pipeline is configured as a set of named stages (audio ingestion, speech-to-text, turn detection, an LLM, text-to-speech, and so on) wired together by "must run before" dependency edges. Before a pipeline config can be deployed, the orchestrator needs to reject any configuration whose dependency graph contains a cycle, since a cyclic pipeline can never be scheduled to run stage-by-stage at all.
Given the list of stage names and a list of directed dependency edges, decide whether the graph they form is a valid DAG or contains a cycle.
Source: src/28_validate_pipeline_dag.py
def has_cycle(stages: list[str], dependencies: list[tuple[str, str]]) -> bool: ...
>>> has_cycle(["asr", "nlu", "llm", "tts"], [("asr", "nlu"), ("nlu", "llm"), ("llm", "tts")])
False
>>> has_cycle(["a", "b", "c"], [("a", "b"), ("b", "c"), ("c", "a")])
True
Step-by-Step Approach
- Build an adjacency list from the dependency edges: for each
(a, b), stageahas an edge to stageb. - Give every stage one of three colors: WHITE (unvisited), GRAY (currently on the DFS path from the root of this traversal), or BLACK (fully explored, no cycle found through it).
- Run DFS from every WHITE stage in turn (the graph may not be connected). On entering a node, mark it GRAY.
- For each outgoing edge from the current node: if the neighbor is GRAY, that's a back-edge into the current path — a cycle. If the neighbor is WHITE, recurse into it.
- Once every outgoing edge from a node has been explored with no cycle found, mark that node BLACK — it can never be part of a cycle discovered later, so future traversals can skip re-exploring it.
- If any DFS call ever finds a GRAY neighbor, the graph has a cycle; if every stage reaches BLACK without one, the graph is a valid DAG.
The key insight is that GRAY specifically marks "on the current recursion stack," which is different from "already visited" — a plain visited/unvisited DFS can't distinguish a legitimate re-visit of an already-finished node (fine, it's a DAG) from a back-edge into an ancestor still being explored (a cycle), which is exactly the distinction three-color DFS captures.
Reference solution
WHITE, GRAY, BLACK = 0, 1, 2
def has_cycle(stages: list[str], dependencies: list[tuple[str, str]]) -> bool:
graph: dict[str, list[str]] = {s: [] for s in stages}
for a, b in dependencies:
graph[a].append(b)
# WHITE = unvisited, GRAY = on the current DFS path, BLACK = fully
# explored with no cycle found through it. A back-edge to a GRAY node
# is exactly what makes a directed graph cyclic.
color = {s: WHITE for s in stages}
def dfs(node: str) -> bool:
color[node] = GRAY
for nxt in graph[node]:
if color[nxt] == GRAY:
return True # back-edge into the current path: cycle
if color[nxt] == WHITE and dfs(nxt):
return True
color[node] = BLACK
return False
for s in stages:
if color[s] == WHITE and dfs(s):
return True
return False
Key Functions & Tricks
- Three-color DFS (WHITE/GRAY/BLACK) — distinguishes "ancestor on the current path" (GRAY) from "already fully explored elsewhere" (BLACK), which a simple visited-set can't.
- Looping over every stage as a potential DFS root — needed because the dependency graph may be disconnected; a single DFS from one stage won't reach every stage.
- Marking a node BLACK on the way out — prunes future DFS calls from re-exploring subgraphs already proven cycle-free.
- Short-circuiting with early
return True— stops the search the instant any back-edge is found, avoiding wasted work. - Adjacency list built once up front — O(V + E) to construct, keeping the whole algorithm O(V + E) instead of O(V · E).
How to Recognize This Pattern
The signal is any "must run before" / "depends on" dependency list paired with a question about whether a valid execution order exists — that's a directed graph, and "does a valid order exist" is equivalent to "is it acyclic." Three-color DFS and Kahn's algorithm (repeatedly removing zero-in-degree nodes) both detect cycles in O(V + E); Kahn's has the advantage of also producing a valid topological order for free when the graph is acyclic, which is handy if the next question is "in what order should the pipeline actually run." A common pitfall is using a single visited set instead of three states: that conflates "already fully explored" with "currently being explored," so a DAG with converging paths (a node reachable via two different routes) gets falsely flagged as cyclic the second time it's reached.