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 <> / after WITH (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
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.
Summary
In
query_graph, aWHERE NOT <predicate>that references the target node or the relationship of aMATCHpattern returns 0 rows with no error — even when the predicate is a tautology (NOT b.name = 'zzz'against a graph where nobis namedzzz), and even when the query is an aggregate (RETURN count(*)returns zero rows, not one row with0). The same predicate spelled with<>returns the correct rows,NOTon the source node works, and moving theNOTbehind aWITHalso 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/core.py:main.py:Baseline, so the expected numbers are on the record:
Results
NOTon a single-node pattern is correct in every form:MATCH (n:Function) WHERE n.name <> 'alpha' RETURN count(*)MATCH (n:Function) WHERE NOT n.name = 'alpha' RETURN count(*)MATCH (n:Function) WHERE NOT (n.name = 'alpha') RETURN count(*)MATCH (n:Function) WHERE NOT n.name ENDS WITH 'helper' RETURN n.nameMATCH (n:Function) WHERE NOT n.name IN ['alpha','beta'] RETURN n.nameMATCH (n:Function) WHERE NOT n.name STARTS WITH 'a' RETURN n.nameMATCH (n:Function) WHERE NOT n.name CONTAINS 'et' RETURN n.nameNOTon the source node of a relationship pattern is correct:MATCH (a)-[:CALLS]->(b) WHERE a.name <> 'run' RETURN a.name, b.nameMATCH (a)-[:CALLS]->(b) WHERE NOT a.name = 'run' RETURN a.name, b.nameMATCH (a:Function)-[:CALLS]->(b:Function) WHERE NOT a.name = 'run' RETURN a.name, b.nameNOTon the target node or the relationship returns nothing, silently:MATCH (a)-[:CALLS]->(b) WHERE b.name <> 'alpha' RETURN a.name, b.nameMATCH (a)-[:CALLS]->(b) WHERE NOT b.name = 'alpha' RETURN a.name, b.nameMATCH (a:Function)-[:CALLS]->(b:Function) WHERE NOT b.name = 'alpha' RETURN a.name, b.nameMATCH (a)-[:CALLS]->(b) WHERE NOT (b.name = 'alpha') RETURN count(*)5MATCH (a)-[:CALLS]->(b) WHERE NOT b.name IN ['alpha'] RETURN a.name, b.nameMATCH (a)-[:CALLS]->(b) WHERE NOT b.name ENDS WITH 'helper' RETURN a.name, b.nameMATCH (a)-[:CALLS]->(b) WHERE a.name = 'run' AND NOT b.name = 'alpha' RETURN a.name, b.nameMATCH (a)-[:CALLS]->(b) WHERE NOT b.name = 'zzz' RETURN count(*)8(tautology)MATCH (a)-[r:CALLS]->(b) WHERE r.line <> 0 RETURN count(*)88✅ (control)MATCH (a)-[r:CALLS]->(b) WHERE NOT r.line = 0 RETURN count(*)8MATCH (a)-[]->(b) WHERE b.name <> 'alpha' RETURN count(*)4343✅ (control)MATCH (a)-[]->(b) WHERE NOT b.name = 'alpha' RETURN count(*)43Two results that narrow where it lives:
MATCH (a)-[:CALLS]->(b) WHERE b.name = 'alpha' OR NOT b.name = 'alpha' RETURN count(*)8✅ — the sameNOTterm is fine inside anORMATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = 'alpha' RETURN a.name, b.nameNOTterm is fine after aWITHThe
--jsonenvelope 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):
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, whereb/ris not bound yet, andNOT <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 anOR, or after aWITH, i.e. once it can no longer be pushed as a standalone conjunct; (b)NOTon 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).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-windows-amd64.zip(SHA-256 verified againstchecksums.txt), run throughcli; also reproduced on 0.10.8.--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.