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_runner from tests/harness/runner.ts). The SKILL.md prompt tells semantic subagents to use just <filename> ("stem is the filename without extension"). Result: AST emits harness_runner_skillevaluationrunner, semantic emits runner_skillevaluationrunner for the same class.
source_file prefix. AST records paths relative to the scan root's parent (paths start tests/...). Semantic subagents record paths as given to them (paths start skills/tests/...). Same file, two different source_file values.
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 install on Windows 10 / Python 3.13 (git bash shell)
- Claude Code with the
graphify skill installed via graphify install --platform claude
- Run command:
/graphify skills on a vendored gstack skills/ bundle (187 files, ~221k words)
- No
GEMINI_API_KEY set → 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
Happy to test a patch.
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:<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 withsource_location=L<n>and one semantic copy withsource_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
uv tool installon Windows 10 / Python 3.13 (git bash shell)graphifyskill installed viagraphify install --platform claude/graphify skillson a vendored gstackskills/bundle (187 files, ~221k words)GEMINI_API_KEYset → semantic extraction ran via parallel Claude general-purpose subagents per SKILL.md Step 3BRepro / Evidence
From the resulting
graphify-out/graph.json, the same nine god-node symbols each appear twice:Detection script that reproduces this from any
graph.json:Run summary on my corpus:
Why this happens
The SKILL.md subagent prompt says:
But the AST extractor in
graphify/extract.pydoes 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_fileis 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 includesource_fileparts 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 — canonicalizesource_file(resolve to a path relative to a single declaredscan_root, drop thescan_rootsegment) 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_filebefore 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.jsonto merge nodes that share a label and a normalized source_file suffix. Rewrite edges to point at the surviving ID. Re-run clustering viagraphify cluster-only .. (I haven't scripted this myself yet — just noting that it's a viable patch until a real fix lands.)Related
KeyError 'source'indeduplicate_entitiesduring--updatemerge. Same family of problem (IDs that should match don't).manifest.json/cache breaking cross-machinegraphify-out/sharing. Same family (path canonicalization is inconsistent across extractor outputs).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.