Context Architecture

Dependency-Ordered Context: Rank by Structure, Not Similarity

David Guzenburg/ / 9 min read

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.

contextdependency graphtoken budgetretrieval

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.

Signatures are roughly an eighth

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.

CouplingInvisible becauseRecover it from
Dependency injectionThe consumer imports an interface, not the implWiring module — read it once, cache the map
Events and message queuesProducer and consumer share no importTopic names; grep for the constant
Database couplingTwo modules share a table, not a symbolModel class usage
HTTP between servicesDifferent repositories entirelyVendored contracts
Reflection, dynamic dispatchNo static edge existsNothing 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 selectsDependency plan
Tokens to first editHigher — explorationLower — arrives loaded
Files read but unusedSeveral per taskFew
Missed a callerOccasionallyRarely — importers are explicit
Setup costNoneAn 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.

Takeaway

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.

Keep reading
Context Architecture

Token Budget in Multi-File Refactoring: Sequencing Over Squeezing

Where the context budget actually goes during a large refactor, reading signatures instead of implementations, bounding tool output, and staging work so each session can discard the last.

Codex vs Claude

Why a Six-Word Question Costs What It Does

Every request carries the whole conversation, and tool output is most of the weight. Caching discounts repetition without removing it, the discount lapses after a break, idle sessions still spend, and compacting a large session is expensive in itself.

Context Architecture

AST-Based Repository Indexing: Giving an Agent Exact Answers

Why text search returns confidently wrong answers in codebases with interfaces, a forty-line symbol index you can build with the standard library, and how to keep it from going stale.

Context Architecture

Why Duplicating Your README Into AGENTS.md Makes Agents Worse

Copying project overview into a context file measurably degrades agent performance. The three mechanisms behind it, and a discoverability test for deciding what to cut.

← Context File Size and Token Budget: Measuring What You Can Afford  ·  Writing Build and Test Instructions an Agent Can Actually Execute →

All context architecture articles  ·  Every article