Skip to content

query_graph: WHERE NOT <predicate> on the target node or relationship of a MATCH pattern silently returns 0 rows — same predicate with <>, inside OR, or after WITH returns the right rows #2250

Description

@junk151516

Summary

In query_graph, a WHERE NOT <predicate> that references the target node or the relationship of a MATCH pattern returns 0 rows with no error — even when the predicate is a tautology (NOT b.name = 'zzz' against a graph where no b is named zzz), and even when the query is an aggregate (RETURN count(*) returns zero rows, not one row with 0). The same predicate spelled with <> returns the correct rows, NOT on the source node works, and moving the NOT behind a WITH also works. So this is not "negation unsupported": it is a silent wrong answer for one shape of query.

I checked #1293 first — that one is about inbound anti-joins (NOT EXISTS { (f)<-[:CALLS]-() }); this is plain property negation and I could not find it reported.

Reproduction (public, 3 files, 25 nodes)

pkg/__init__.py      (empty)
pkg/core.py
main.py

pkg/core.py:

def alpha():
    return 1


def beta():
    return alpha() + 1


def gamma():
    return alpha() + beta()


def delta_helper():
    return gamma()

main.py:

from pkg.core import alpha, beta, gamma, delta_helper


def run():
    return alpha() + beta() + gamma() + delta_helper()
codebase-memory-mcp cli index_repository --repo-path <dir> --name cbmnot --mode fast
codebase-memory-mcp cli query_graph --project cbmnot --format json --query "<query>"

Baseline, so the expected numbers are on the record:

MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name
-> 8 rows: run→alpha, run→beta, run→gamma, run→delta_helper, beta→alpha, gamma→alpha, gamma→beta, delta_helper→gamma

Results

NOT on a single-node pattern is correct in every form:

query rows
MATCH (n:Function) WHERE n.name <> 'alpha' RETURN count(*) 6 ✅
MATCH (n:Function) WHERE NOT n.name = 'alpha' RETURN count(*) 6 ✅
MATCH (n:Function) WHERE NOT (n.name = 'alpha') RETURN count(*) 6 ✅
MATCH (n:Function) WHERE NOT n.name ENDS WITH 'helper' RETURN n.name 6 ✅
MATCH (n:Function) WHERE NOT n.name IN ['alpha','beta'] RETURN n.name 5 ✅
MATCH (n:Function) WHERE NOT n.name STARTS WITH 'a' RETURN n.name 6 ✅
MATCH (n:Function) WHERE NOT n.name CONTAINS 'et' RETURN n.name 6 ✅

NOT on the source node of a relationship pattern is correct:

query rows
MATCH (a)-[:CALLS]->(b) WHERE a.name <> 'run' RETURN a.name, b.name 4 ✅
MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = 'run' RETURN a.name, b.name 4 ✅
MATCH (a:Function)-[:CALLS]->(b:Function) WHERE NOT a.name = 'run' RETURN a.name, b.name 4 ✅

NOT on the target node or the relationship returns nothing, silently:

query expected got
MATCH (a)-[:CALLS]->(b) WHERE b.name <> 'alpha' RETURN a.name, b.name 5 5 ✅ (control)
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = 'alpha' RETURN a.name, b.name 5 0
MATCH (a:Function)-[:CALLS]->(b:Function) WHERE NOT b.name = 'alpha' RETURN a.name, b.name 5 0
MATCH (a)-[:CALLS]->(b) WHERE NOT (b.name = 'alpha') RETURN count(*) 1 row: 5 0 rows
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name IN ['alpha'] RETURN a.name, b.name 5 0
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name ENDS WITH 'helper' RETURN a.name, b.name 7 0
MATCH (a)-[:CALLS]->(b) WHERE a.name = 'run' AND NOT b.name = 'alpha' RETURN a.name, b.name 3 0
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = 'zzz' RETURN count(*) 1 row: 8 (tautology) 0 rows
MATCH (a)-[r:CALLS]->(b) WHERE r.line <> 0 RETURN count(*) 8 8 ✅ (control)
MATCH (a)-[r:CALLS]->(b) WHERE NOT r.line = 0 RETURN count(*) 1 row: 8 0 rows
MATCH (a)-[]->(b) WHERE b.name <> 'alpha' RETURN count(*) 43 43 ✅ (control)
MATCH (a)-[]->(b) WHERE NOT b.name = 'alpha' RETURN count(*) 1 row: 43 0 rows

Two results that narrow where it lives:

query got
MATCH (a)-[:CALLS]->(b) WHERE b.name = 'alpha' OR NOT b.name = 'alpha' RETURN count(*) 8 ✅ — the same NOT term is fine inside an OR
MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = 'alpha' RETURN a.name, b.name 5 ✅ — the same NOT term is fine after a WITH

The --json envelope carries no warning or diagnostic field for the failing cases; the only thing that comes back is the generic "hint": "Query returned no results. Use get_graph_schema() to see available labels and edge types.", which points the user at the schema rather than at the query.

Also on a large graph, and on the previous release

Same shape on an indexed project of 108,825 nodes / 558,430 edges (v0.11.0, freshly built):

MATCH (a:Function)-[:CALLS]->(b) WHERE b.name <> 'str'            RETURN count(*)  -> 116905
MATCH (a:Function)-[:CALLS]->(b) WHERE NOT b.name = 'str'         RETURN count(*)  -> 0 rows
MATCH (a:Function)-[:CALLS]->(b) WHERE NOT b.name = 'zzz_no_such' RETURN count(*)  -> 0 rows

I had the same result on v0.10.8 against the same repositories before upgrading (that is how I noticed it), so it is not a v0.11.0 regression — it predates the Cypher fail-closed work (#1875, #1918, #1922, #1998) and is not covered by it.

Reading of the mechanism (a guess, offered to be shot down)

The pattern fits a predicate pushdown: a top-level NOT … that references only the target node (or the relationship) is pushed to the expansion stage, where b/r is not bound yet, and NOT <null> evaluates to false for every candidate — so a tautology filters everything out. The evidence for that reading is that (a) the identical term is evaluated correctly once it sits inside an OR, or after a WITH, i.e. once it can no longer be pushed as a standalone conjunct; (b) NOT on the source node, which is bound at expansion time, is correct; (c) <> is never affected, so it presumably is not pushed the same way. I have not read the planner, so treat the paragraph as a hypothesis, not a finding.

Expected

Either of these would be fine; the current behaviour is the one thing that should not happen:

  1. NOT <predicate> on the target node / relationship evaluates the same as <> / after WITH (the correct rows).
  2. If the shape is genuinely unsupported, the query is refused with an error, the way the v0.11.0 Cypher work refuses a WITH wider than its binding — not answered with an empty result set that is indistinguishable from "no such edges". An empty result for NOT b.name = 'zzz' on a graph with 8 edges is a wrong answer, not a limitation.

Environment

  • codebase-memory-mcp 0.11.0, release binary codebase-memory-mcp-windows-amd64.zip (SHA-256 verified against checksums.txt), run through cli; also reproduced on 0.10.8.
  • Windows 11 Pro 10.0.26200, x64, 32 GB RAM.
  • Store built fresh by 0.11.0 (--mode fast for the 3-file repo, --mode moderate for the large one).

Happy to re-run the table against a candidate build; the small fixture makes it a 10-second check.

Activity

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

    cypherCypher query language parser/executor bugsparsing/qualityGraph extraction bugs, false positives, missing edgeswindowsWindows-specific issues

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions