Repository navigation
[Bug]: graphify query silently guesses an ambiguous node name instead of refusing it #4511
Description
Package: graphifyy 0.9.92
CLI: graphify (installed via uv tool install)
Platform: Ubuntu 26.04.1 LTS, Python 3.14.4
Reproduced against: shipped CLI + a merged multi-repo graph
Summary
The README states that ambiguous node names are refused rather than guessed. graphify explain and graphify path honour this. graphify query does not — when several nodes share a name, it silently picks one and returns exit code 0, so a caller cannot tell a confident answer from an arbitrary one.
Documentation contract
README (branch v8, "Using the graph directly"):
A name that matches several nodes is refused with the candidates listed rather than guessed. Scripts can read the outcome from the exit code:
0an answer,3an ambiguous name,4no matching node.
Reproduction
Two small projects, deliberately sharing the symbol Response.
mkdir -p repro/web repro/ml && cd repro
cat > web/server.py <<'EOF'
class HttpServer:
def handle_request(self, req): return Response()
class Response:
def render(self): return "html"
EOF
cat > ml/train.py <<'EOF'
class Response:
def compute(self): return 0.5
class Trainer:
def handle_request(self, batch): return batch
EOF
graphify extract web --code-only --no-label
graphify extract ml --code-only --no-label
graphify merge-graphs web/graphify-out/graph.json ml/graphify-out/graph.json \
--out m/graph.jsonThe merged graph namespaces node ids correctly and contains both candidates:
web::server_response label "Response" source_file "server.py"
ml::train_response label "Response" source_file "train.py"
Behaviour matrix
| Invocation | Result | Exit |
|---|---|---|
graphify explain "Response" |
✅ Ambiguous: 'Response' matches 2 nodes in different files. + both candidates |
3 |
graphify path "Response" "Trainer" |
✅ same refusal | 3 |
graphify query "Response" --seed "Response" |
✅ No unique node match for --seed: Response |
0 |
graphify query "Response" |
❌ silently returns only the ml::train_response subtree |
0 |
Observed output for the failing case
$ graphify query "Response" --graph m/graph.json
Graph: m/graph.json (10 nodes) | Traversal: BFS depth=2 | Start: ['Response'] | 4 nodes found
NODE Response [src=train.py loc=L1 community=2]
NODE train.py [src=train.py loc=L1 community=2]
NODE .compute() [src=train.py loc=L2 community=2]
NODE Trainer [src=train.py loc=L3 community=3]
EDGE train.py --contains [EXTRACTED]--> Response at=train.py:L1
EDGE Response --method [EXTRACTED]--> .compute() at=train.py:L2
EDGE train.py --contains [EXTRACTED]--> Trainer at=train.py:L3
$ echo $?
0web::server_response and its subtree are absent, with no warning. The choice is deterministic (reproduced across repeated runs), not random.
For comparison, the guards that DO work:
$ graphify explain "Response" --graph m/graph.json
Ambiguous: 'Response' matches 2 nodes in different files.
server.py
id: web::server_response
train.py
id: ml::train_response
Retry with path::symbol using one of the paths above (e.g. <path>::Response) or the full node id.
$ echo $?
3$ graphify path "Response" "Trainer" --graph m/graph.json
Ambiguous: 'Response' matches 2 nodes in different files.
server.py
id: web::server_response
train.py
id: ml::train_response
Retry with path::symbol using one of the paths above (e.g. <path>::Response) or the full node id.
$ echo $?
3$ graphify query "Response" --seed "Response" --graph m/graph.json
No unique node match for --seed: Response
$ echo $?
0 # refuses correctly, but still exits 0 — see note belowMinor secondary point: the
--seedrefusal exits0rather than3. Its text is unambiguous, but a script branching only on the exit code would read it as success. Worth aligning with the documented "3= an ambiguous name".
Why this matters
- Scripts cannot trust exit code 0. The documented contract makes
0mean "an answer", and3mean "ambiguous". A script branching on the exit code will treat an arbitrary answer as authoritative. - Wrong-subtree answers are plausible, so a human reading the output has no cue that a different file was selected. On a merged/multi-repo graph, generic names (
Response,Config,User,Handler,Client) collide constantly. - Agent-facing impact is worse: the skill instructs the model to run
graphify queryand trust the result, so an ambiguous query can quietly ground an answer in the wrong repository.
Likely root cause
The four entry points resolve their start node through two different mechanisms, and only one of them verifies uniqueness.
Guarded path — explain, path, and explicit --seed:
explain/pathcallserve._resolve_single_node(G, label), whose second return value is a refusal message thatcli.pyconverts via_is_ambiguity_refusal(...)intoEXIT_AMBIGUOUS(3). (cli.pyimports these at theexplainbranch; thequerybranch imports only_QUERY_NO_MATCH/_query_graph_text.)- An explicit
--seedroutes throughaffected.resolve_seed, which walks candidate tiers and only accepts a tier that yields exactly one hit:With two hits it falls through every tier and returns no match — hence the observedexact_label_matches = [...] if len(exact_label_matches) == 1: return exact_label_matches[0] ... bare_name_matches = [...] if len(bare_name_matches) == 1: return bare_name_matches[0]
No unique node match for --seed: Response.
Unguarded path — the seed auto-selected from the question text: with no--seed,_query_graph_textderives seeds from the question via the scoring helpers (_score_query/_pick_seeds), whose own docstring describes the selection as a ranked cut: "The 20%-gap cutoff then silently discards all of those substring-tier" candidates, with gap-based winners seated per term. That path produces a winner by construction and never asks whether the winning term matched more than one node in different files, which is exactly the check_ambiguity_messageperforms for the guarded path.
So the guard exists in the codebase and is applied to every entry point except the auto-selected seed. (I verified the behaviour and these two code paths; I did not exhaustively trace every branch of the scoring internals, so the mechanism description is based on the cited functions and their docstrings, not on instrumented tracing.)
Note:_query_graph_textis shared with the MCPquery_graphtool, whose handler surfaces_AMBIGUOUSrefusals — worth confirming the MCP path is unaffected by the same route, since the docstring above_query_graph_textclaims "there is one interpretation" per seed value.
Suggested fix
Apply the existing uniqueness check to auto-selected seeds: when a scored term resolves to multiple nodes in different files, either
a. refuse with the existing _ambiguity_message(G, label, rivals) and exit EXIT_AMBIGUOUS (3), matching explain/path; or
b. if keeping multi-candidate traversal is intentional for NL queries, at minimum emit the collision (like _same_file_ambiguity_message does for same-file hits) and/or return a non-zero exit so scripts can detect it.
Option a is consistent with the README and with the documented exit-code semantics. If some legitimate NL use case needs breadth across same-name nodes, scoping the refusal to cross-file rivals (as _ambiguity_message already does) would preserve it for run-of-the-mill repeated local names while still catching the multi-repo case.
Workarounds
Users can disambiguate today:
graphify query "Response" --seed "train.py::Response" # path::symbol
graphify query "Response" --seed "ml::train_response" # full node id
graphify explain "server.py::Response" # explain/path refuse correctlyBoth path::symbol and full node id forms are verified working.
Environment
graphify 0.9.92
Python 3.14.4
Ubuntu 26.04.1 LTS
extras installed: chinese, leiden, watch, mcp, pdf, office, svg
graph built with: graphify extract <dir> --code-only --no-label
merge: graphify merge-graphs a/graphify-out/graph.json b/graphify-out/graph.json --out m/graph.json
Activity
github-actions commentedon Oct 17, 2026
Thanks for opening this issue, @kaka-in-home. A maintainer will take a look soon.
If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.
To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.
[-]# Bug: `graphify query` silently guesses an ambiguous node name instead of refusing it[/-][+][Bug]: `graphify query` silently guesses an ambiguous node name instead of refusing it[/+]kaka-in-home commentedon Oct 17, 2026
Additional context after searching related issues:
This looks like the last unfixed surface of a pattern that has been
reported and fixed piecemeal elsewhere:
is still open, so it appears to have been fixed independently)
explain and path now refuse correctly on the same graph, which suggests the
guard exists and simply isn't reached by the auto-selected seed path.
Deepjyoti-Sarmah commentedon Oct 17, 2026
Hi, I looked into this and can reproduce it on 0.9.92.
When two nodes share a name,
querykeeps only one of them. The start-point picker treats same-named nodes as duplicates. That rule was added so that many same-fileGEThandlers don't fill every search slot, but it doesn't check the file. So aResponsein another project gets dropped without a message. Both nodes score the same, so the first one in the file wins every time.explainandpathalready refuse in this case. I'm putting together a PR that makesquerydo the same:--seedwith an ambiguous name also exits 3.path::symboland full node ids keep working.I'll link the PR here once it's up.
test: query refuses a name defined in several files (Graphify-Labs#4511)
fix: query refuses an ambiguous name instead of picking one (Graphify…
fix: same-named files from two repos are not one file (Graphify-Labs#…
fix(query): only refuse an EXACT-name ambiguity, not a search keyword (…
safishamsi commentedon Oct 20, 2026
Fixed in 0.9.93 by #4517.
graphify queryno longer silently guesses an ambiguous node name. Thanks @kaka-in-home for the report. Live on PyPI: https://pypi.org/project/graphifyy/0.9.93/