Repository navigation
bug: AST and semantic extractors generate different node IDs for the same symbol → god nodes duplicated, cluster cohesion degraded #1145
Description
Summary
On a single /graphify <path> run, the AST extractor and the semantic-extraction subagents produce different node IDs for the same code symbol because they disagree on two things:
- Stem derivation. AST uses
<parent_dir>_<filename>(e.g.harness_runnerfromtests/harness/runner.ts). The SKILL.md prompt tells semantic subagents to use just<filename>("stem is the filename without extension"). Result: AST emitsharness_runner_skillevaluationrunner, semantic emitsrunner_skillevaluationrunnerfor the same class. source_fileprefix. AST records paths relative to the scan root's parent (paths starttests/...). Semantic subagents record paths as given to them (paths startskills/tests/...). Same file, two differentsource_filevalues.
Because IDs differ, the Part C merge in SKILL.md (if n['id'] not in seen) treats them as separate nodes. Every god-node in a sufficiently-sized run ends up doubled: one AST copy with source_location=L<n> and one semantic copy with source_location=None. The two copies land in different Louvain communities (the AST copy lives in a tight functional cluster; the semantic copy gets pulled into a sprawling cross-references cluster), which inflates the community count and tanks cohesion scores for the affected clusters.
Environment
- graphifyy via
uv tool installon Windows 10 / Python 3.13 (git bash shell) - Claude Code with the
graphifyskill installed viagraphify install --platform claude - Run command:
/graphify skillson a vendored gstackskills/bundle (187 files, ~221k words) - No
GEMINI_API_KEYset → semantic extraction ran via parallel Claude general-purpose subagents per SKILL.md Step 3B
Repro / Evidence
From the resulting graphify-out/graph.json, the same nine god-node symbols each appear twice:
"SkillEvaluationRunner" — 2 nodes:
id=harness_runner_skillevaluationrunner src=tests/harness/runner.ts loc=L67 (AST)
id=runner_skillevaluationrunner src=skills/tests/harness/runner.ts loc=None (semantic)
"CodeEvaluator" — 2 nodes:
id=harness_evaluator_codeevaluator src=tests/harness/evaluator.ts loc=L40 (AST)
id=evaluator_codeevaluator src=skills/tests/harness/evaluator.ts loc=None (semantic)
"RalphLoopController" — 2 nodes:
id=harness_ralph_loop_ralphloopcontroller src=tests/harness/ralph-loop.ts loc=L130 (AST)
id=ralph_loop_controller src=skills/tests/harness/ralph-loop.ts loc=None (semantic)
"SkillCopilotClient", "AcceptanceCriteriaLoader", "ConsoleReporter",
"MarkdownReporter", "FeedbackBuilder", "MockCopilotClient",
"checkCopilotAvailable()" — all show the same pattern.
Detection script that reproduces this from any graph.json:
import json
from collections import defaultdict
g = json.load(open('graphify-out/graph.json'))
by_label = defaultdict(list)
for n in g['nodes']:
by_label[n['label']].append(n)
for label, instances in by_label.items():
if len(instances) <= 1: continue
# Same-symbol duplicates have one AST node (source_location set) and one semantic (None)
has_ast = any(n.get('source_location') for n in instances)
has_sem = any(not n.get('source_location') for n in instances)
if has_ast and has_sem:
print(label, [(n['id'], n.get('source_file'), n.get('source_location')) for n in instances])Run summary on my corpus:
- 461 total nodes, 710 edges, 38 communities
- ~10 god-node symbols each duplicated (so ~10 "shadow" nodes that should have merged)
- The two largest communities ("Copilot Client Layer" 49 nodes / cohesion 0.06, "Test Harness Core" 40 nodes / cohesion 0.06) are partly shadow-copies of the smaller, well-formed AST communities ("Evaluation Runner" 6 nodes / cohesion 0.17, "Code Evaluator" 3 nodes / cohesion 0.18, "Ralph Loop Controller" 6 nodes, etc.). The cohesion-0.06 hubs are flagged in the report's "Suggested Questions" as "should be split" — but they're actually duplicates, not real god-objects.
Why this happens
The SKILL.md subagent prompt says:
Node ID format: lowercase, only
[a-z0-9_], no dots or slashes. Format:{stem}_{entity}where stem is the filename without extension … This must match the ID the AST extractor generates so cross-references between code and semantic nodes connect correctly.
But the AST extractor in graphify/extract.py does not use just the filename — it appears to include at least one parent directory in the stem. So the contract written into the prompt doesn't actually match the AST extractor's behavior, and the "must match" guarantee silently fails.
Separately, source_file is left to whatever the subagent decides to write. Since SKILL.md passes absolute paths to subagents (which on Windows are long), the subagents normalize differently than the AST extractor does, so even if stems matched, the IDs would still diverge if AST chose to include source_file parts in the stem.
Suggested fixes
Pick whichever is canonical, then enforce it from both sides:
Option A (preferred): Have graphify generate the canonical ID in code from a (source_file, label) pair after the merge. Don't trust extractors to agree on IDs — canonicalize source_file (resolve to a path relative to a single declared scan_root, drop the scan_root segment) and re-key every node. Then dedupe by (canonical_source_file, label).
Option B: Fix the prompt to match the AST extractor's actual rule (include the immediate parent dir in the stem), and pre-canonicalize source_file before passing files to subagents (so semantic and AST observe the same path prefix).
A is more robust because it doesn't rely on the model following a path-derivation rule under pressure. The same fix would also unblock #803 (the dedup KeyError 'source') by ensuring source/target IDs survive the merge.
Workaround for now
Post-process graph.json to merge nodes that share a label and a normalized source_file suffix. Rewrite edges to point at the surviving ID. Re-run clustering via graphify cluster-only .. (I haven't scripted this myself yet — just noting that it's a viable patch until a real fix lands.)
Related
- dedup: KeyError 'source' in deduplicate_entities when merging extraction into existing graph.json #803 —
KeyError 'source'indeduplicate_entitiesduring--updatemerge. Same family of problem (IDs that should match don't). - bug: absolute paths in manifest.json, .graphify_root, and cache/ast/*.json break git-shared graphify-out/ across machines #777 — absolute paths in
manifest.json/cache breaking cross-machinegraphify-out/sharing. Same family (path canonicalization is inconsistent across extractor outputs). - Question —
graphify updatenon-determinism produces ~11k-line diff per run on unchanged source: intended or addressable? #741 —graphify updatenon-determinism producing ~11k-line diffs. Could be related if duplicate node IDs are deterministically generated differently across runs depending on subagent fan-out order.
Happy to test a patch.
Activity
fix(extract): three graph quality fixes (#1145 #1146 #1147)
safishamsi commentedon Jun 6, 2026
Fixed in v0.8.33 (a380b34).
build_from_jsonnow runs a two-pass merge after all nodes are added: first collects AST nodes (those withsource_location=L<n>), then finds semantic ghosts (samesource_filebasename + label, nosource_location). Ghosts are removed from the graph and their IDs added tonorm_to_idso all edges re-point to the canonical AST node.For graphs built before this fix, run
graphify extract . --forceto clean up any existing ghosts.Upgrade:
uv tool upgrade graphifyyfix(build): ghost-merge keys on full source_file, not bare basename (#…
Squashed commit of the following:
fix(extract): three graph quality fixes (Graphify-Labs#1145 Graphify-…
fix(build): ghost-merge keys on full source_file, not bare basename (G…
fix(build): ghost-merge nodes whose source_file is the referencing do…
fix(dedup): let AST nodes win their twin, as build_from_json already …