Skip to content

Identity is a key, not a string

Before a tool can say that A depends on B, it has to be able to say that this B and that B are the same B. Identity sounds like a detail and it is actually the load-bearing decision of the whole model: it decides whether an edge lands on the right node, whether two overloads stay two entities, whether the same class in two snapshots of a repository is recognised as one thing over time, and whether a union of models from different languages can be formed at all.

Codegraph’s answer is stated as a metamodel decision, cited from the code, and short enough to quote whole.

MM-1 · Identity is the structured key, not an opaque string

Identity is the structured key (lang, module, symbol, disambiguator?). Consumers compare it component-wise and never parse a rendered string. A rendered id — java:com.acme.order/OrderService.bill(com.acme.order.Order) — is a display-only projection.

Component Role
lang the language id, frozen per profile (java, ts, clj, …)
module the owning module. A module names itself here, with an empty symbol
symbol the path below the module — dots for nesting; empty only for a module
disambiguator optional: file:line:column for anonymous entities, param: / local: markers for sub-members; absent when the symbol is already unique

Uniqueness is over the union: no two entities share (lang, module, symbol, disambiguator). The key is also multi-declaration tolerant — one key may legitimately be declared in several places, which is what TypeScript declaration merging and C# partial classes do.

Why the rendered form exists at all, and why nothing parses it

A structured key is awkward to print. So core renders one, as <lang>:<module>[/<symbol>][#<disambiguator>], and that string is what appears in a report, on a navigator row, in a --scope argument and in a DOT label. It is a projection for humans.

The rule that everything else follows from is that nothing parses it back. Not the analyzer, not the city, not the navigator. This is not fastidiousness; it is a direct consequence of extracting without a build. An id looks like it names its package — java:a.b/C.m() seems to say the package is a.b — but which part of a name is a package is the extractor’s private business, and in noClasspath mode a plausible-looking package may be one Spoon invented.

So every consumer that needs containment walks the model’s own parent chain instead. The analyzer’s fold to type or module level is a memoized ancestor walk. The navigator nests com.acme.order.adapter under com.acme.order because the extractor emitted that parent relation, and puts java.lang at the root because nothing said otherwise. The city’s districts come from the same walk. In each case a string split would have been shorter to write and would have been an inference dressed as a fact.

Rendering has to be injective, or entities silently merge

If the rendered id is a projection, it has to be a lossless one — otherwise two distinct keys can render as the same string, and downstream one entity quietly absorbs the other, with no error anywhere.

That is why / and # are reserved in the key’s components: a module may contain neither, a symbol may not contain #, and a present disambiguator is non-empty. Under those rules rendering is injective. Core’s renderId validates and throws rather than emitting a lossy id.

This failure mode is not hypothetical; it was found one level down first. The reference corpus contains two overloads whose parameter types have identical simple names in different packages:

void archive(java.util.List<Order> orders)          {  }
void archive(com.acme.order.legacy.List orders)     {  }

Under simple names both render OrderService.archive(List), collapse into one id, and one method disappears from the model — no diagnostic, just a smaller graph. Ids therefore use erased fully-qualified names in the parameter list. The separator reservation is the same bug caught one level up: a collision between two keys, prevented by construction rather than detected afterwards.

The column in an anonymous entity’s disambiguator is load-bearing for the same reason. A single line can start several nameless entities — the fixture has two lambdas beginning on the same line, which is not a typo — and without the column they collapse into one id. Worse, a nested one then becomes its own parent.

Why a module names itself

The one component that surprises people is module: a package’s own key names itself there, with an empty symbol, rather than naming its parent package.

The alternative was tried and rejected for two reasons. It renders the package java:com.acme.order as java:com.acme/order, breaking the id shape everything already displays. And it needs a parent for every module — including the stub package java:java.util, whose parent is a module called java that no corpus declares and that we would therefore have to fabricate. Fabricating containers to satisfy a scheme is exactly what the stub discipline exists to prevent, so the scheme changed instead.

Canonical order belongs to the model

Sorting is part of identity’s job. The canonical order is sort by natural key, component by component, with a missing disambiguator ordering before any present one — and it is deliberately not the same as sorting rendered ids as strings, where / and . are ordinary characters with their own collation.

That order belongs to the model, not to any encoding, because it is what makes two things possible: a snapshot diff that a human can review, and extractor cross-validation, where one extractor’s edge set is checked against a richer one’s. Both compare files line by line, and both are meaningless if the line order is incidental. Every encoding then decides how the order manifests physically — the line format assigns its integer surrogates in exactly this order, which is why they come out deterministic.

What a surrogate is not

The interchange spells references as dense integers, and the SQLite store keeps those same integers as its row ids. It is worth being explicit: a surrogate is not identity. It is file-scoped, it is not stable across runs, and it never crosses a file boundary. Two models of the same corpus extracted a minute apart may number their entities differently and still describe exactly the same entities, because sameness is the key.

The rule cuts the other way too. Anything that joins across files — a multi-language union, checking whether an id is corpus-declared, comparing two revisions of a repository — joins on the natural key tuple, never on a number and never on a rendered string.

The dividend nobody planned for

Making identity a key was a correctness decision. It turned out to be what made the time axis affordable.

“The same entity in two snapshots” is normally a research problem — diff heuristics, similarity matching, name tracking. Here it is key equality, for free, for every named entity. A class that survives fifty-five release tags is the same key at all fifty-five. That is what codegraph timeline reads, and what lets the replay freeze one layout over the union of every key that ever existed.

The limits are stated rather than hidden. A renamed symbol is a death plus a birth, because a different name is a different key and rename matching is heuristic machinery we chose not to build until a corpus proves it necessary. Anonymous entities are not tracked over time at all: their disambiguator is a file position, and any edit above them shifts it.

Where this shows up

Last updated on