Neighborhoods: giving agents a map before they start walking

Ask an agent to add rate limiting to your API and watch what happens in the first sixty seconds. It greps for rate. It finds a config constant, a test fixture, and a comment. It reads a file. That file imports three others, so it reads those. Two turn out to be irrelevant. It greps again with a better term now that it knows what the codebase calls things. It reads four more files. Somewhere around the eighth read it finally finds the middleware chain, which is what it needed all along, and now it can start working.

Nothing there was stupid. That’s a reasonable search strategy for someone dropped into an unfamiliar building with a flashlight. But every one of those reads is tokens, and most of them bought orientation rather than information — the agent wasn’t learning what the code does, it was learning where things are.

The frustrating part is that this is knowledge the system already has. Not the model — the system. Something in your toolchain has already parsed every import and resolved every cross-file symbol reference. It knows the shape of the building. It just wasn’t telling anyone.

Relevance is not location

The obvious fix is semantic search, and it genuinely helps. Embed the query, embed the code, return the top matches. Now the agent starts from five plausibly-relevant files instead of a grep for rate.

But relevance and location are different questions, and a ranked list only answers the first one.

Knowing your friend lives in a town is not the same as knowing where to find them. If someone wanted to visit and asked you where your friend lived, and you said “Seattle — just wander around each street until you run into them,” they’d think you were nuts. You’d give them a neighborhood, a street, ideally a house number.

Consider what a top-5 semantic result actually looks like. Five files, sorted by cosine similarity, each one individually a decent match. What the list doesn’t say: whether those five files are one coherent module or five unrelated corners of the codebase that happen to use similar vocabulary. Whether one of them is the entry point everything else routes through, or whether they’re all leaves. Whether there’s a sixth file — the one that ties them together — that didn’t rank because it’s mostly interface declarations and matches nothing semantically.

A ranked list is a flat structure describing a system that isn’t flat. The agent gets the nodes and has to rebuild the edges by hand, one read at a time. That reconstruction is most of the eight reads.

Code behaves like a social network

The fix isn’t mine, and it’s older than any of this: community detection. Given a graph, find the groups of nodes that are more connected to each other than to everything else. It was built to answer questions about people — who forms a social circle, which friendships cluster into a group. The Louvain method, the standard workhorse here, came out of exactly that world in 2008, and the original paper is still the clearest description of it.

What I find genuinely interesting is that code turns out to behave a lot like a social network. Files keep company with the same small set of other files. They form cliques. A few of them broker between groups that otherwise wouldn’t talk. The structure that emerges from people choosing who to associate with and the structure that emerges from engineers choosing what to import are similar enough that the same algorithm reads both.

The analogy isn’t perfect. Social ties are usually modeled as undirected — if we’re in the same circle, we’re in it together — and an import only points one way. But the dependency graph itself is bidirectional: it tracks what a module imports and who uses the things inside it. Inbound and outbound are both first-class, which is where entry points come from later on. The clustering step then flattens that into an undirected weighted graph, because when the question is “do these files belong together?”, which way the arrow points matters a lot less than whether there’s an arrow at all — and how many.

So: a codebase is a graph, files are nodes, and “which files form a module?” is precisely the question community detection already answers. Konstruct runs Louvain over the dependency graph and calls the results neighborhoods.

The interesting design decision isn’t the algorithm, it’s the edge weights. The naive version treats every import as an edge of weight 1, which throws away most of the signal. Instead, edges are weighted by resolved cross-file symbol references — how many times one file actually reaches into another’s code:

UI.tsx imports styles.ts                  → baseline weight = 1
UI.tsx calls 5 functions from helpers.ts  → weight = 5

Result: UI.tsx ↔ helpers.ts binds tighter than UI.tsx ↔ styles.ts

Both are imports. They are not the same relationship, and pretending otherwise produces mushy clusters. When symbol references don’t resolve — external packages, path aliases — it falls back to import adjacency at weight 1, which is the honest answer when you genuinely don’t know more.

One implementation note worth surfacing, because it bit us: a single local-moving pass over a sparse graph shatters it into hundreds of tiny communities, and the resolution knob does nothing useful. You need the multilevel step — collapse each community into a super-node, aggregate the edges, run it again — before clusters coalesce into anything a human would recognize as a module. The clustering is also deterministic, seeded RNG, same input to the byte. An agent hint that reshuffles between runs is worse than no hint, because now you can’t reproduce a session.

The same dependency graph, before and after community detection On the left, fourteen files drawn as an unstructured graph with five semantic search hits highlighted. On the right, the identical graph with the same nodes grouped into three neighborhoods; three of the five hits fall inside one module, whose most-imported file is marked as the entry point. What semantic search returns What the graph already knew five ranked files, no structure src/auth — 3 of 5 hits land here entry point three modules, one obvious place to start
The same fourteen files both times. Dashed rings are semantic search hits; dashed lines are edges that cross a module boundary. The square is the neighborhood's most-imported file — which didn't rank as a hit at all.

Cohesion, or: how much should you trust this

Clustering will always return something. Ask for communities in random noise and you get communities in random noise. So each neighborhood carries a cohesion score, derived from conductance:

conductance = (edges leaving the neighborhood) / min(internal volume, external volume)
cohesion    = 1 - conductance

1.0 = isolated, no external edges
0.5 = as many edges leave as stay
0.0 = mostly connected to the outside

This is the part I think matters most, and it’s the part that’s easy to leave out. A neighborhood at 0.9 is a real module and the agent should trust the boundary. A neighborhood at 0.4 is the clustering algorithm shrugging — the files are related, but the boundary is soft and the agent should expect the answer to lie partly outside it.

Handing an agent a confident-looking module list with no confidence signal teaches it to trust a boundary that may not exist. That’s worse than a flat list, because a flat list at least doesn’t lie about structure.

There’s a per-file version too, membership strength, which is (internal edges - external edges) / total edges. Near 1.0 means a core member. Near 0.5 means a bridge — a file connecting two modules, which is exactly the file you want to look at when a change is going to ripple. Negative means the file is probably misassigned, which is useful in a different way.

Entry points

Knowing the module isn’t quite the same as knowing where to start reading it. So each neighborhood also surfaces entry points: the three members with the highest inbound degree — the most-imported files in the module, which in practice is its public API.

This is the single highest-leverage thing in the payload. “Here are twelve relevant files” makes an agent read twelve files. “Here are twelve relevant files, and src/auth/index.ts is what the rest of the codebase actually calls into” makes it read one file and then decide. Inbound degree is a crude proxy for importance, but it’s cheap, it’s stable, and it’s right often enough to change where the agent points its flashlight.

Labels come from the longest common directory prefix — src/auth/login.ts and src/auth/logout.ts give you src/auth. When a cluster spans packages and there’s no shared prefix, it falls back to the filename of the highest-degree member. Less pretty, still legible.

Putting it together at conversation start

One of the best things you can do for an agent is give it as much information as possible up front. It gets where you’re going faster, and the first message tends to survive in a way later ones don’t. llama-server, for example, defaults to dropping the middle of a conversation when it runs out of context space — it keeps the beginning and the most recent turns. Whatever you opened with is still there; the exploratory rummaging in the middle is what gets evicted. It’s probably not the only one doing something like this — certainly not from casual observation. The first message is expensive real estate.

Normally you start a session by saying something like “I want to fix the login code,” and the agent goes off and greps, reads files, and wanders around until it finds something it thinks is relevant. What if instead you opened with “I want to fix the login code, and here are some relevant files to start you off — including their neighbors, and how related they are to each other”? That’s a far more directed way to begin.

Of course nobody wants to type all that out. So: what if the tooling just did it for you?

That’s the seeding path, and it runs when you open a new session:

  1. Semantic search on your first message — local embeddings, no LLM tokens spent.
  2. Map each hit to its neighborhood via the membership table.
  3. Rank neighborhoods by how many hits land inside them. Drop singletons.
  4. Inject the top 3 as a block in the first message.
## Relevant code neighborhoods
- src/auth — 12-file module (cohesion 0.87) · entry point: src/auth/index.ts
  matched: src/auth/login.ts, src/auth/logout.ts, src/auth/session.ts

Note what step 3 does: it re-ranks by structure rather than similarity. Five scattered hits across five modules produce a weak, spread-out signal. Five hits inside one module produce a strong one — and the module that wins isn’t necessarily the one holding the single highest-scoring file. Semantic search proposes; the graph decides which proposal is coherent.

The other thing worth noticing is the cost. This is a table lookup against a precomputed partition. The clustering happens when the graph is built, not per query. The agent gets structural orientation for approximately no marginal tokens, before it has taken a single action.

What this looks like in practice

I use Konstruct to write — it means I spend less time drawing diagrams or hunting for relevant information and more time referencing it. So I asked it to explain how the enrichment of search queries helps it, and here’s its answer:

When I searched the workspace, the response carried "enrich": "light". Every file read came back with a graph_provenance block — inbound_degree for the file, and freshness metadata: stale: false, workingTreeDirty: true, built from commit 5cb4f9a. Search hits arrived with suggested_read line ranges attached, so “find the thing” and “know which forty lines to read” were one round trip instead of two.

The freshness flag is easy to skip past — its value is less obvious than the rest, but just as important. Trust with no data isn’t worth much, and data with no basis for trust isn’t either. Without it the agent has no way of knowing “this graph is current, but there are working-tree changes that may not be reflected in it” — which, mid-edit, is most of the time. workingTreeDirty: true was correct; I had uncommitted changes for the entire session.

It’s worth remembering that having an answer tells you nothing about how much to trust that answer. It’s the same discipline you learn doing research: find sources you trust, find more than one where you can, cite them so the next person can check your work. Freshness metadata is what tells the agent how skeptical to be about what it just got back.

And the failure compounds in a way that’s easy to miss. Point an agent at a file that has moved and you don’t just waste the read — you teach it to discount the tool, because the pattern it has now learned is “call this thing, get an answer, use the answer, get bad results.” A graph that reports its own staleness is one you can reason about. One that silently serves you yesterday’s topology trains the agent to stop believing it.

Where it falls down

Neighborhoods are only as good as the graph underneath them, and the graph can have holes. Static analysis is only as good as the code on disk, and there are some things — and some languages — that are much harder to detect that way, at least quickly. Anything that’s only visible or constructed at runtime, sometimes from data completely external to the system, isn’t going to be modeled in a graph like this one.

Dynamic dispatch is invisible. Dependency injection, reflection, string-keyed registries — no static edge, no clustering signal. A codebase built on a DI container will cluster worse than one with explicit imports, and it will do so without complaining.

Resolution is a real knob, not a solved problem. A lower resolution gives you fewer, larger communities; a higher one gives you more, smaller ones. Both produce valid clusterings. “Valid” is not “the one matching how you think about your system,” and I don’t think there’s a universal right answer.

Cross-cutting concerns cluster badly. Logging, error handling, config — used everywhere, belonging nowhere. Community detection will assign them somewhere, and that assignment carries low membership strength, which is at least an honest signal that the answer is unsatisfying.

Staleness during heavy edits. Mid-refactor, the graph describes the codebase you had this morning. Which is exactly why it says so.

None of these make the hints useless. They make them hints — priors that shift where an agent looks first, not facts it should treat as ground truth. The failure mode I actively want to avoid is an agent that trusts a 0.4-cohesion neighborhood boundary the way it would trust a compiler error.

The pattern underneath

I keep landing on the same idea from different directions: the model is the backend, and everything around it is compiler flags. Same weights, same question, dramatically different output depending on what context arrives and in what shape.

Neighborhood seeding is one flag. It doesn’t make the model smarter. It changes what the model knows in its first token — replacing eight exploratory reads with a map that says here’s the module, here’s how confident we are, here’s the door.

Unrolling a loop doesn’t make a compiler smarter either. It trades a branch you’d otherwise pay for on every iteration against a flat run of instructions worked out ahead of time. The work still happened; it just happened somewhere cheaper. Seeding is the same trade — the clustering ran when the graph was built, so the agent gets handed the answer instead of deriving it eight reads at a time.

It isn’t the only one, either. Seeding the conversation is a flag. So is enriching results continuously, including for tools as dumb as grep — a search that hands back matches is one thing; a search that hands back matches, the line range worth reading, and how the file sits relative to everything else is a different tool wearing the same name. None of that changes the model. All of it changes what comes out.

Most of what an agent wastes tokens on is rediscovering context that some other part of the system already knew. This is one specific instance of that, fixed with an algorithm from 2008.

Filed under: technology, software, konstruct, ai