Repository navigation
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
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:
NOT <predicate>on the target node / relationship evaluates the same as<>/ afterWITH(the correct rows).- If the shape is genuinely unsupported, the query is refused with an error, the way the v0.11.0 Cypher work refuses a
WITHwider than its binding — not answered with an empty result set that is indistinguishable from "no such edges". An empty result forNOT 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 againstchecksums.txt), run throughcli; also reproduced on 0.10.8. - Windows 11 Pro 10.0.26200, x64, 32 GB RAM.
- Store built fresh by 0.11.0 (
--mode fastfor the 3-file repo,--mode moderatefor the large one).
Happy to re-run the table against a candidate build; the small fixture makes it a 10-second check.
Activity
DeusData commentedon Oct 1, 2026
Thank you for the minimal fixture and the careful comparison of equivalent query forms. I checked main at c61b380: in src/cypher/cypher.c, execute_single evaluates WHERE on the initial binding before expanding relationships (around lines 5139-5161); an unbound property returns NULL, eval_condition treats that as true, and EXPR_NOT negates it (lines 2566-2600 and 2644-2737). That supports the early-filtering diagnosis for this shape. This was source inspection, not a new runtime reproduction. PR #2253 is an open fix candidate for that boundary. Keeping this open and high priority until the fix is reviewed and lands; no additional information is needed from you for that next step. Thank you for making the silent wrong answer so precise.