Skip to content

explain resolves ambiguous terms silently (no warning), unlike path; resolution degrades as graph grows #1969

Open

Description

@ericknyoto

graphify explain silently resolves ambiguous terms with no warning (contrast: graphify path does warn)

Summary

graphify explain "<term>" picks one node when multiple same-named/similar-named nodes exist in the graph, with no indication that a choice was made or that alternatives exist. graphify path "A" "B" in the same graph does detect and surface ambiguity (a tied-score warning) for the same underlying data. The inconsistency means users cannot tell, from explain's output alone, whether the answer is authoritative or a coin-flip among several equally-named entities — and in our testing, the coin-flip landed wrong more often than not.

Repro (against a 45k-node / 112k-edge real-world PHP monorepo graph)

Tested 3 known-ambiguous terms via graphify explain:

  1. depot_orders — repo has a SQL table depot_orders (schema) and a same-named legacy PHP file classes/libs/depot_orders.php, plus assorted test/reference nodes containing the substring. explain "depot_orders" silently resolved to DepotOrdersAdditionalRefillExclusionTest.php's reference node (degree 3) — not the SQL table, not the main legacy file. No warning.
  2. element — generic term, matches multiple unrelated node families. explain "element" silently picked one, no warning.
  3. ProductFactory — repo has 3 distinct entities named ProductFactory: database/factories/ProductFactory.php (Eloquent factory), classes/psr4/Factories/ProductFactory.php (legacy), Manobo2/Domain/Product/Factory/ProductFactory.php (DDD builder). explain "ProductFactory" silently resolved to the DDD builder — wrong for a "which one builds OrmProduct" question — no warning. By contrast, graphify path "Product" "Factory" did emit an ambiguity warning (tied top-match scores) for a related query on the same graph, then still returned a low-value path through an unrelated node. So the scorer can detect ambiguity in this codepath, it's just not surfaced by explain.

Score: 3/3 ambiguous single-term explain lookups resolved silently, 2/3 picked the wrong entity for the user's actual question.

Regression under growth (new finding, not in original repro set)

We added ~1400 new AST-sourced edges (Tier-2 semantic pass: Eloquent models + raw-SQL-caller edges) connecting 4 legacy files to their SQL table nodes, specifically to fix a different, now-resolved issue (table nodes missing from extraction). After this graph grew from ~111k to ~112.7k edges, we re-ran explain on the same 4 table names:

  • Before Tier-2: explain "depot_orders" etc. resolved to some wrong-but-plausible node (e.g., the legacy PHP file).
  • After Tier-2 (same terms, graph now correctly has real table→code edges with degree 52/84/21/20 respectively — confirmed directly via graph.json edge inspection, bypassing explain): resolution got worse, not better. E.g. depot_orders now resolves to a test-mock reference node; jng_sp_orders resolves to an unrelated method parameter. Both picks are lower-quality matches than what explain returned before we added the correct data.

This suggests the term-matching heuristic degrades as more same-substring nodes are added, i.e., the more correct/complete the graph gets, the less reliable bare-term explain becomes — the opposite of the expected trend. Confidence/degree of the correct node is not weighted heavily enough relative to some other scoring signal (recency of extraction? node-id string length? unclear from outside).

Working deterministic workaround (found during our investigation)

Bare-term lookup is unreliable, but exact node-ID addressing is fully deterministic and always resolves correctly. For SQL-schema-sourced nodes specifically, the ID is a predictable function of the source path:

database/schema/manobo-schema.sql  →  database_schema_manobo_schema_<table_name>

e.g. graphify explain "database_schema_manobo_schema_depot_orders" reliably returns the correct table node (Source: database/schema/manobo-schema.sql L914, degree 52, correct [references] edges from real callers) every time — verified for 4/4 table names in this session. This confirms the graph data and the neighbor-listing logic are both correct; the failure is isolated to term→node resolution when the term is ambiguous.

Suggested fix

Either (a) have explain run the same ambiguity/tied-score detection path already has, and print a disambiguation list instead of silently picking one, or (b) support explicit exact-node-ID input (if not already supported via some undocumented flag) so callers who already know the ID can bypass fuzzy matching entirely and get a documented guarantee of correctness — we found ID-based lookup already works today by accident (raw ID happens to be accepted as the query string), but it isn't documented as a supported/stable interface.

Environment

  • graphify 0.9.16 (skill shipped 0.7.16 — update warning shown but unrelated to this bug)
  • Graph: 45,448 nodes / 112,724 edges, real-world PHP 8.4 Laravel monorepo (41+ DDD bounded contexts + legacy code)

Activity

  1. added 2 commits that reference this issue on Jul 17, 2026
    fc8616f
    d0e0484
  2. FolatheDuckofDuckingburg commented on Jul 17, 2026

    @FolatheDuckofDuckingburg
    Contributor

    Creating a PR to solve this issue.

  3. Abdallah01 commented on Jul 19, 2026

    @Abdallah01

    Corroborating on a different stack + adding a consequence not yet noted here.

    Environment: graphify 0.9.20 (pip graphifyy), Windows, Dart/Flutter monorepo, freshly rebuilt graph (17.8k nodes / 26.7k edges, 893 communities).

    Repro (bare-label explain): a class C defined at lib/models/<file>.dart produces (at least) three same-labeled nodes after a rebuild: the path-qualified definition node (lib_models_<file>_<c>), a bare label node (<c>), and a third in an unrelated file that also declares/references the name. explain "<C>" silently resolves to the bare label node — matching the silent-pick behavior reported above. path "<C>" "<D>" emits the tied-score warning (top score == runner-up) but still starts from the wrong duplicate and routes 4 hops through imports edges for what the graph actually contains as a direct 1-hop semantic edge.

    The added consequence — semantic edges are the systematic casualty: curated/LLM-inferred semantic edges (shares_data_with, conceptually_related_to, depends_on, …) attach to the path-qualified definition ids, while the bare label node the resolver prefers carries only AST references edges. So bare-term explain doesn't just pick a wrong node — it reliably picks the one variant that has none of the graph's highest-value edges, making the semantic layer effectively unreachable through the CLI even though it's present and correct in graph.json (verified by direct inspection). The exact-ID workaround described above works for these too (explain "lib_models_<file>_<c>" returns the full semantic neighborhood).

    +1 on fix direction (a) — a disambiguation list would resolve this class entirely; if candidates were additionally ranked/annotated by whether they carry non-AST semantic edges, the definition node would surface first for exactly the queries where the distinction matters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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