Skip to content

bug: AST and semantic extractors generate different node IDs for the same symbol → god nodes duplicated, cluster cohesion degraded #1145

Description

@lmngoldtrade-arch

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions