Dependency-Ordered Context: Rank by Structure, Not Similarity
A function and its caller may share no vocabulary at all. Two unrelated modules that both handle dates look almost identical. Similarity is the wrong axis for code.
Relevance has an order, and it is not similarity
Most approaches to choosing what code goes into context rank by similarity to the task description. That is the wrong axis for code, because code has a structure that says what depends on what, and dependency is a better predictor of relevance than semantic resemblance.
A function you are about to change and the function that calls it are strongly related and may share almost no vocabulary. Two unrelated modules that both handle dates look similar and are not related at all.
Build the graph, then walk outward
import ast, json, pathlib
from collections import defaultdict
imports = defaultdict(set) # module -> modules it imports
importers = defaultdict(set) # module -> modules that import it
def module_name(p: pathlib.Path) -> str:
return str(p.with_suffix("")).replace("/", ".")
for p in pathlib.Path("src").rglob("*.py"):
mod = module_name(p)
try:
tree = ast.parse(p.read_text(encoding="utf-8"))
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
target = node.module
elif isinstance(node, ast.Import):
target = node.names[0].name
else:
continue
if target.startswith("src"):
imports[mod].add(target)
importers[target].add(mod)
json.dump({"imports": {k: sorted(v) for k, v in imports.items()},
"importers": {k: sorted(v) for k, v in importers.items()}},
open(".deps.json", "w"), indent=1)
With that graph, "what should the agent read to change
src.billing.invoice" has a structural answer rather than a
guess.
Order by distance, then cut at the budget
import json, tiktoken, pathlib
enc = tiktoken.get_encoding("cl100k_base")
deps = json.load(open(".deps.json"))
def tokens(mod: str) -> int:
p = pathlib.Path(mod.replace(".", "/") + ".py")
return len(enc.encode(p.read_text(encoding="utf-8"))) if p.exists() else 0
def plan(target: str, budget: int = 60_000) -> list[tuple[str, str]]:
tiers = [
("full", [target]), # editing it
("full", sorted(deps["imports"].get(target, []))), # it calls these
("signatures", sorted(deps["importers"].get(target, []))), # these call it
]
# two hops out: signatures only, and only if there is room
two_hop = {m for d in deps["imports"].get(target, [])
for m in deps["imports"].get(d, [])}
tiers.append(("signatures", sorted(two_hop - {target})))
out, used = [], 0
for depth, mods in tiers:
for m in mods:
cost = tokens(m) if depth == "full" else tokens(m) // 8
if used + cost > budget:
return out
out.append((m, depth))
used += cost
return out
Two design decisions there are worth naming.
Direction matters more than distance. Modules the target imports are read in full, because changing the target means using their APIs correctly. Modules that import the target get signatures only, because what matters is not breaking their call sites — not how they work internally.
The budget cuts, rather than the ranking scoring. Walk outward and stop when full. That is simpler than any weighting scheme and produces a defensible answer: everything within one hop, then as much of the second hop as fits.
The // 8 above is a rough ratio of signature-listing to full
source for typical code. Measure yours once against real modules — it decides
how many peripheral files fit, and being wrong by a factor of two changes the
plan materially.
Hub modules poison the plan
Every codebase has a module imported by two hundred others — a config module, a types module, a utility grab-bag. Following its importer edges returns most of the repository.
HUB_THRESHOLD = 25
hubs = {m for m, users in deps["importers"].items()
if len(users) > HUB_THRESHOLD}
# A hub tells you nothing about relevance — everything imports it.
# Traverse through it, never expand it.
Excluding hubs from expansion is what keeps the plan bounded. It is also diagnostic: a module imported by two hundred others is usually a module that should be several modules, and the threshold surfacing it is free information.
Where the import graph is not enough
Static imports capture most dependencies and miss several kinds, and it is worth knowing which so you do not over-trust the plan.
| Coupling | Invisible because | Recover it from |
|---|---|---|
| Dependency injection | The consumer imports an interface, not the impl | Wiring module — read it once, cache the map |
| Events and message queues | Producer and consumer share no import | Topic names; grep for the constant |
| Database coupling | Two modules share a table, not a symbol | Model class usage |
| HTTP between services | Different repositories entirely | Vendored contracts |
| Reflection, dynamic dispatch | No static edge exists | Nothing reliable — flag it |
The first two are worth handling explicitly, because they are common in exactly the codebases where this technique is most useful. A wiring module read once gives you the interface-to-implementation map; a grep for topic constants gives you producer-consumer pairs. Both are cheap additions to the graph and both catch real callers the import edges miss.
Give the plan to the agent, not just the files
Handing over a pile of files loses the structure that made the selection sensible. Say what each one is for.
Task: add a `deferred` state to invoice processing.
Editing:
src/billing/invoice.py (full source below)
Depends on — you will call into these:
src/domain/money.py (full source)
src/domain/tier.py (full source)
Depended on by — do not break these call sites:
src/api/routes/billing.py (signatures only)
src/jobs/reconcile.py (signatures only)
src/reports/monthly.py (signatures only)
Not included, ask if you need them:
src/config.py, src/util/logging.py (imported everywhere)
The last line is the one that changes behaviour. An agent that knows a file was deliberately excluded asks for it; one that simply never saw it invents what it contains.
Measure it against reading everything
The check is straightforward and worth running once on your own repository: take ten real tasks, run each with a dependency-ordered plan and with whatever the agent selects on its own, and compare tokens consumed and whether the task completed without asking for more files.
| Agent selects | Dependency plan | |
|---|---|---|
| Tokens to first edit | Higher — exploration | Lower — arrives loaded |
| Files read but unused | Several per task | Few |
| Missed a caller | Occasionally | Rarely — importers are explicit |
| Setup cost | None | An afternoon, once |
Row three is the substantive benefit rather than the token saving. An agent
that never opened reconcile.py does not know it calls the function
being changed, and the resulting break surfaces in a nightly job rather than in
review.
Rank context by dependency, not similarity — code has a structure that says what depends on what. Read imports in full and importers as signatures, since the questions differ. Exclude hub modules from expansion or the plan swallows the repository. And tell the agent what was left out, so it asks rather than invents.