Repository navigation
explain resolves ambiguous terms silently (no warning), unlike path; resolution degrades as graph grows #1969
Description
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:
depot_orders— repo has a SQL tabledepot_orders(schema) and a same-named legacy PHP fileclasses/libs/depot_orders.php, plus assorted test/reference nodes containing the substring.explain "depot_orders"silently resolved toDepotOrdersAdditionalRefillExclusionTest.php's reference node (degree 3) — not the SQL table, not the main legacy file. No warning.element— generic term, matches multiple unrelated node families.explain "element"silently picked one, no warning.ProductFactory— repo has 3 distinct entities namedProductFactory: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 buildsOrmProduct" 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 byexplain.
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_ordersnow resolves to a test-mock reference node;jng_sp_ordersresolves to an unrelated method parameter. Both picks are lower-quality matches than whatexplainreturned 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
tests: add ambiguity regression test for explain scorer (fixes Graphi…
explain: detect and surface ambiguous single-term matches; accept exa…
FolatheDuckofDuckingburg commentedon Jul 17, 2026
Creating a PR to solve this issue.
Abdallah01 commentedon Jul 19, 2026
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
Cdefined atlib/models/<file>.dartproduces (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 throughimportsedges 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 ASTreferencesedges. So bare-termexplaindoesn'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.