Skip to content

Two extractor bugs systematically inflate god-node centrality (rationale fragments + calls direction inversion) #563

Description

@WillRiverpoint

Two extractor bugs systematically inflate god-node centrality

graphifyy version: 0.4.21
Python: 3.14.4
OS: Windows 11
Corpus: ~1,300,000 words across 209 files (mixed Python middleware + markdown vault)
Run mode: --update

TL;DR

After running graphify --update on a code+docs corpus, the highest-degree "god node" surfaced was a passive Python class (SQLiteQueue, 67 edges, 54 INFERRED). Audit of the 54 inferred edges found:

  • 0 correctly-stated outbound edges
  • 20 correct-relationship-but-wrong-direction (SQLiteQueue --calls--> contact_form() when truth is the reverse)
  • 29 pointing at phantom *_rationale_NNN nodes that are docstring text fragments, not real entities
  • 5 categorically wrong (claiming SQLiteQueue uses phone-validation tests + Odoo-tag chatter — unrelated domains)

True meaningful relationships from this node ≈ 8, all inbound. Centrality inflated ≈ 3×.

Site-wide, this graph has:

  • 280 rationale pseudo-nodes (16.3% of all 1720 nodes)
  • 158 --uses--> edges pointing AT those pseudo-nodes (the broken pattern, by my count this run)
  • 279 non-AST calls edges (likely candidates for the direction-inversion bug)

The same pattern almost certainly inflates every other god node ranking and a meaningful fraction of "surprising connections" findings.

Bug 1: Rationale extraction creates one node per docstring paragraph

The skill prompt for semantic extraction asks subagents to extract rationale (which is a great feature). But subagents currently emit one node per docstring/comment paragraph, with IDs like <filestem>_rationale_<line>. For a single Python file with 4 multi-paragraph docstrings, that produces 4-15+ rationale pseudo-nodes.

These nodes then attract --uses--> edges from every imported entity in the same file. So importing SQLiteQueue in test_customer_dedup.py causes SQLiteQueue to gain edges like:

{
  "source": "queue_sqlitequeue",
  "target": "test_customer_dedup_rationale_46",
  "relation": "uses",
  "confidence": "INFERRED",
  "confidence_score": 0.5
}

Where test_customer_dedup_rationale_46 has the label "US phone without country code uses default region US → +1..." — this is a docstring fragment about phone number normalization, completely unrelated to SQLiteQueue.

Specific examples from my graph (all from one node, SQLiteQueue):

target id target label (truncated) source file should this edge exist?
test_customer_dedup_rationale_46 "US phone without country code uses default region US → +1..." test_customer_dedup.py:L22 NO — phone-validation, not queue-related
test_customer_dedup_rationale_57 "UK phone with +44 prefix parses regardless of default region." same NO
test_customer_dedup_rationale_69 "Phone that parses but isn't a valid number → None." same NO
test_customer_dedup_rationale_201 "If the tag isn't seeded, still post the chatter note." same NO — Odoo tagging
test_customer_dedup_rationale_211 "Tag write failure is logged but doesn't stop the chatter note." same NO
test_worker_rationale_136 "The cli.retry_log module's main() prints a header + one row per queue entry." test_worker.py:L22 NO — describes retry_log, not SQLiteQueue

These edges exist purely because the docstring sits in a file that imports SQLiteQueue. There is no semantic relationship.

Suggested fixes

Option A (minimal): in the subagent prompt, restrict rationale node emission to one rationale node per file (collapse all docstring fragments into a single <file>_rationale node) OR cap at 1-2 rationale nodes per "decision/concept node" they explain.

Option B (better): stop creating rationale nodes at all; attach docstring text as a rationale metadata field on the function/class node it documents. This preserves the WHY-content but doesn't create separate graph entities.

Option C (strict): require subagents to emit rationale_for as the relation type when linking rationale to a concept. Currently they default to uses, which is a category error (a class can't "use" a sentence of prose). The skill prompt already documents rationale_for as a valid relation but doesn't enforce it.

In my run, of 350 edges pointing at rationale nodes, 158 used relation uses and the remaining 192 were spread across references, conceptually_related_to, etc. — none used rationale_for. The relation type spec from the skill prompt isn't being followed by subagents.

Bug 2: AST calls edges have inverted source/target

Every --calls--> edge involving SQLiteQueue points outbound from SQLiteQueue to a function that actually calls one of SQLiteQueue's methods. Examples:

{
  "source": "queue_sqlitequeue",
  "target": "contact_form_contact_form",
  "relation": "calls",
  "confidence": "INFERRED",
  "confidence_score": 0.8
}

In source code at middleware/app/routers/contact_form.py:L222:

existing_lead = queue.check_idempotency(payload_obj.idempotency_key)

contact_form() calls queue.check_idempotency(). The graph has the arrow reversed.

This pattern repeats on 18 of SQLiteQueue's 54 inferred edges (every test function, every router, the CLI, the lifespan context manager). The classes don't call the functions; the functions call the class methods.

Suggested fix

When extracting calls edges from AST, the source should be the function/method containing the call site, not the class whose method is being called. This appears to be an off-by-one or conceptual flip in how AST nodes get attributed during edge construction.

Repro: any Python project where Class X is imported by file F.py and F.py contains a function foo() that does x_instance.method(). Expected edge: foo --calls--> X.method (source=foo). Observed edge: X --calls--> foo (source=X).

Combined impact: god-node centrality is structurally inflated

Together these bugs inflate the degree count of any class/concept that is:

  1. Imported by multiple files (more places to attract phantom rationale edges)
  2. Has methods called from many call sites (more direction-inverted calls edges)

That means infrastructure components, base classes, and frequently-imported utilities will systematically appear at the top of god_nodes() rankings — but mostly because of this artifact, not because they're truly central.

In my run, the top 10 god nodes are likely to all share this issue:

  1. SQLiteQueue (67 edges) — audited, ~80% noise/inverted
  2. OdooClient (55 edges) — same pattern expected
  3. Index - Riverpoint (Master Hub) (52 edges) — markdown hub, likely heavy rationale pollution
  4. Index - Decisions (43 edges) — same
  5. Index - Rich TODO (41 edges) — same
  6. Odoo (System of Record) (40 edges)
  7. OdooWriteError (38 edges)
  8. Tech Stack (35 edges)
  9. Core Architecture Rules (Non-Negotiable) (33 edges)
  10. n8n (33 edges)

The "Suggested Questions" feature surfaced this exact concern by asking "Are the 54 inferred relationships involving SQLiteQueue actually correct?" — kudos for that, the system is partially self-correcting. But once a user does the audit and finds the answer is "no", they need a remediation path beyond per-node manual cleanup.

What would help

  1. Fix Bug 2 (direction inversion) — single change to AST edge attribution, high-confidence fix.
  2. Decide on a Bug 1 strategy (collapse rationale, attach as metadata, or strict relation typing) — this is more design choice than bug.
  3. Optionally: a graphify clean subcommand that strips known-broken edge patterns from graph.json post-extraction. Useful for users who want a quick fix without waiting for upstream changes.

Happy to share the full audit (with quoted code evidence for every classification) if it would help — it's at C:\Users\will\.claude\plans\you-are-auditing-my-rustling-candy.md on my machine. Can paste here if you want.

Thanks for graphify — it's a great tool, and the fact that the Suggested Questions feature flagged its own data-quality issue is genuinely impressive.

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