Skip to content

[Bug]: graphify query silently guesses an ambiguous node name instead of refusing it #4511

Closed

Description

@kaka-in-home

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: 0 an answer, 3 an ambiguous name, 4 no 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.json

The 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 $?
0

web::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 below

Minor secondary point: the --seed refusal exits 0 rather than 3. 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

  1. Scripts cannot trust exit code 0. The documented contract makes 0 mean "an answer", and 3 mean "ambiguous". A script branching on the exit code will treat an arbitrary answer as authoritative.
  2. 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.
  3. Agent-facing impact is worse: the skill instructs the model to run graphify query and 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 / path call serve._resolve_single_node(G, label), whose second return value is a refusal message that cli.py converts via _is_ambiguity_refusal(...) into EXIT_AMBIGUOUS (3). (cli.py imports these at the explain branch; the query branch imports only _QUERY_NO_MATCH / _query_graph_text.)
  • An explicit --seed routes through affected.resolve_seed, which walks candidate tiers and only accepts a tier that yields exactly one hit:
    exact_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]
    With two hits it falls through every tier and returns no match — hence the observed No unique node match for --seed: Response.
    Unguarded path — the seed auto-selected from the question text: with no --seed, _query_graph_text derives 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_message performs 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_text is shared with the MCP query_graph tool, whose handler surfaces _AMBIGUOUS refusals — worth confirming the MCP path is unaffected by the same route, since the docstring above _query_graph_text claims "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 correctly

Both 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

  1. github-actions commented on Oct 17, 2026

    @github-actions

    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.

  2. changed the title [-]# 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[/+] on Oct 17, 2026
  3. kaka-in-home commented on Oct 17, 2026

    @kaka-in-home
    Author

    Additional context after searching related issues:

    This looks like the last unfixed surface of a pattern that has been
    reported and fixed piecemeal elsewhere:

    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.

  4. Deepjyoti-Sarmah commented on Oct 17, 2026

    @Deepjyoti-Sarmah
    Contributor

    Hi, I looked into this and can reproduce it on 0.9.92.

    When two nodes share a name, query keeps only one of them. The start-point picker treats same-named nodes as duplicates. That rule was added so that many same-file GET handlers don't fill every search slot, but it doesn't check the file. So a Response in another project gets dropped without a message. Both nodes score the same, so the first one in the file wins every time.

    explain and path already refuse in this case. I'm putting together a PR that makes query do the same:

    • A name that matches nodes in different files returns the candidate list and exits 3.
    • --seed with an ambiguous name also exits 3.
    • Duplicates inside one file behave as before. path::symbol and full node ids keep working.

    I'll link the PR here once it's up.

  5. safishamsi commented on Oct 20, 2026

    @safishamsi
    Member

    Fixed in 0.9.93 by #4517. graphify query no 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/

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