Pipeline / DAG design
A simple pipeline is a straight line: step 1, step 2, step 3. A real AI pipeline is a DAG — a *directed acyclic graph*. fetch and embed have no dependencies and could run in either order; retrieve needs both of them; answer needs retrieve. You can't just pick any order — each step must run after the steps it depends on.
Working out a valid order is a topological sort. The clean way is Kahn's algorithm:
- Build a map of each node to its set of unmet dependencies.
- Repeatedly find a node with no unmet deps (it's *ready*), place it in the order, and remove it from everyone else's dependency set.
- Repeat until every node is placed.
To make the result deterministic, when several nodes are ready pick the alphabetically-smallest one. And here's the cycle check, free of charge: if at some point no node is ready but nodes remain, they all depend on each other in a loop — that's a cycle, and a cyclic graph has *no* valid order. Raise an error rather than loop forever.
This is pure Python; in production it's Airflow / Dagster / Prefect — the same DAG idea driven by a real scheduler.
Syntax
def topo_order(stages):
deps = {s["name"]: set(s["deps"]) for s in stages}
order = []
while deps:
ready = sorted(n for n, d in deps.items() if not d) # no unmet deps
if not ready:
raise ValueError("cycle detected") # nothing ready, nodes remain
node = ready[0] # smallest-ready (deterministic)
order.append(node)
del deps[node]
for d in deps.values():
d.discard(node) # node is now satisfied for others
return orderWorked examples
A diamond: a → (b, c) → d, ordered correctly
def topo_order(stages):
deps = {s["name"]: set(s["deps"]) for s in stages}
order = []
while deps:
ready = sorted(n for n, d in deps.items() if not d)
if not ready:
raise ValueError("cycle detected")
node = ready[0]
order.append(node)
del deps[node]
for d in deps.values():
d.discard(node)
return order
stages = [
{"name": "d", "deps": ["b", "c"]},
{"name": "b", "deps": ["a"]},
{"name": "c", "deps": ["a"]},
{"name": "a", "deps": []},
]
print(topo_order(stages))
# Output:
# ['a', 'b', 'c', 'd']A cycle has no valid order — detect it, don't loop forever
def topo_order(stages):
deps = {s["name"]: set(s["deps"]) for s in stages}
order = []
while deps:
ready = sorted(n for n, d in deps.items() if not d)
if not ready:
raise ValueError("cycle detected")
node = ready[0]
order.append(node)
del deps[node]
for d in deps.values():
d.discard(node)
return order
cyclic = [
{"name": "x", "deps": ["y"]},
{"name": "y", "deps": ["x"]}, # x needs y, y needs x — impossible
]
try:
topo_order(cyclic)
except ValueError as e:
print("raised:", e)
# Output:
# raised: cycle detectedrun_dag threads each step's artifact to its dependants
def topo_order(stages):
deps = {s["name"]: set(s["deps"]) for s in stages}
order = []
while deps:
ready = sorted(n for n, d in deps.items() if not d)
if not ready:
raise ValueError("cycle detected")
node = ready[0]
order.append(node)
del deps[node]
for d in deps.values():
d.discard(node)
return order
def run_dag(stages):
by_name = {s["name"]: s for s in stages}
artifacts = {}
for name in topo_order(stages):
stage = by_name[name]
inputs = {dep: artifacts[dep] for dep in stage["deps"]}
artifacts[name] = stage["run"](inputs)
return artifacts
stages = [
{"name": "answer", "deps": ["retrieve"], "run": lambda i: f"answer({i['retrieve']})"},
{"name": "retrieve", "deps": ["fetch", "embed"], "run": lambda i: f"hits[{i['fetch']}|{i['embed']}]"},
{"name": "fetch", "deps": [], "run": lambda i: "docs"},
{"name": "embed", "deps": [], "run": lambda i: "vecs"},
]
print(topo_order(stages))
print(run_dag(stages)["answer"])
# Output:
# ['embed', 'fetch', 'retrieve', 'answer']
# answer(hits[docs|vecs])Common mistakes & gotchas
Forgetting to discard the placed node from others
The line d.discard(node) is the heart of Kahn's algorithm — once a node is placed, every node that depended on it no longer has to wait. Skip it and no further node ever becomes ready, so the loop either stalls into a false 'cycle detected' or (without the guard) spins forever.
A cycle that silently loops instead of raising
Without the if not ready: raise check, a cyclic graph leaves nodes that never become ready and the while deps loop never empties — an infinite loop. The check turns 'impossible to order' into a clear, fast ValueError. Always have a termination guard on graph algorithms.
Assuming a unique order
A DAG often has many valid topological orders (the diamond could be a,b,c,d or a,c,b,d). That's correct — any order that respects the dependencies is valid. Picking smallest-ready-first just makes the output reproducible; don't write code that depends on one specific order beyond the dependency constraints.
Why it matters
The moment a pipeline has more than one upstream input, it's a DAG, and ordering it by hand is how subtle 'ran before its data was ready' bugs creep in. Topological sort is the standard, provably-correct answer — every workflow engine (Airflow, Dagster, build systems like Make, even package managers) is doing this underneath. Knowing it means you can reason about, and debug, all of them.