A file path is useful to the author. A node ID is useful to the graph. Keep the mapping between them derived, so moving content does not rewrite semantic edges.
Pass one: collect identities
For each included regular page with node metadata, add an entry keyed by ID:
nodes[id] = { node, page }
Reject a duplicate before it can silently replace an entry. Retain the page object only in the build-time index; exported data should contain explicit fields, not an opaque framework object.
Membership is based on node metadata, not the /graph/ directory. Every expression
on this site contributes a node from its own pillar and cluster folder.
Pass two: resolve relationships
Walk the outbound edges after collecting every ID:
for each source:
for each edge:
require target in nodes
incoming[target].append({source, predicate})
Resolving during the first pass would make validity depend on traversal order. Two passes avoid that accidental coupling.
The algorithm visits nodes and edges; a conventional hash-map implementation can operate in expected O(V + E) time. That is an algorithmic model, not a benchmark of Hugo template map construction. Measure actual builds before making scale claims.
Validate the selected graph
A target may exist in source but be excluded from publication because it is a draft. Source validation alone cannot catch that publication-specific problem. Validate the graph that will actually be emitted. This site fails the build rather than silently dropping unresolved edges.
Cache deliberately
The site derives one index per language within a Hugo build using a cached partial. That avoids rebuilding the same view for every article. It is not a persistent database. If publication variants or private contexts are introduced, the cache scope and selection rules must be reconsidered.
Try it: move one expression file without changing its node ID. Rebuild and inspect its incoming links. The destination URL should change; the stored source relationships should not.