Repository navigation
query_graph: patterns with no label on the source node silently return empty — same DB and binary previously returned correct rows (v0.9.0) #1196
Description
Environment: v0.9.0, Windows amd64 (ui variant), same index DB as #1110 / #1132 / #1133 (10 projects; main graph 31k nodes / 176k edges, 43,851 CALLS edges per get_graph_schema). The CLI and the MCP server return identical results. First mentioned in #1110 (comment) — filing it on its own as it's unrelated to the (invalid) direction flag discussed there.
Summary: On 2026-07-15 this query returned 2 correct rows (documented verbatim in #1110's body):
MATCH (a)-[r:CALLS]->(b) WHERE b.name = 'auto_match' RETURN a.qualified_name → 2 rows (correct)
Today, same binary (SHA-pinned) and same project, it returns {"rows":[],"total":0} with the generic no-results hint. Probe matrix:
| Query shape | Result |
|---|---|
MATCH (n) RETURN n.name |
✅ rows |
MATCH (n) WHERE n.name = '<existing>' RETURN ... |
❌ silent empty |
MATCH (n) WHERE n.lines > 100 RETURN ... |
❌ silent empty |
MATCH (n:Method) WHERE n.name = '<same>' RETURN ... |
✅ 1 row |
MATCH (a)-[r]->(b) RETURN a.name (untyped edge) |
✅ rows |
MATCH (a)-[r:CALLS]->(b) RETURN a.name |
❌ silent empty |
MATCH (a:Function)-[r:CALLS]->(b) RETURN a.name, b.name |
✅ rows |
MATCH (a:Function)-[r:CALLS]->(b) WHERE b.name = 'X' RETURN ... |
✅ correct callers (verified vs grep) |
MATCH (a)-[r:CALLS]->(b:Method) WHERE b.name = 'X' RETURN ... |
❌ silent empty (label on the TARGET doesn't help) |
Reproduced identically on a second project in the same DB (13.5k nodes), for string and numeric predicates, via both the CLI and the MCP server.
What changed between "worked" (2026-07-15) and "broken" (2026-07-21): not the binary. The DB went through several delete_project + fresh-index cycles (projects added and removed on 2026-07-16) plus incremental auto-watch updates. It looks like whatever powers unlabeled scans / typed-edge anchor selection consults per-label state that goes stale after those cycles, while label-anchored plans still read the live data.
Impact: the most natural Cypher an LLM agent emits (MATCH (a)-[:CALLS]->(b) WHERE b.name = ...) now silently reads as "no callers" — the same confidently-wrong-empty failure family as #1111 / #1133. Workaround: always put a label on the pattern's source node.
Asks:
- Make unlabeled matching consistent with label-anchored matching after delete/reindex cycles (or rebuild the relevant index on write).
- Until then, fail explicit: if the planner resolves a zero-cardinality anchor while the schema reports non-empty labels/edge types, return an error or a diagnostic instead of a clean empty set.
Happy to run a diagnostic build against this DB — it's private, but I can run anything read-only and share the output.
Activity
DeusData commentedon Jul 22, 2026
Thanks for the probe matrix and same-binary/same-database timeline. This is a high-priority
0.9.1-rcsilent-wrong-result bug under #594. Review will compare unlabeled and source-labelled plans across delete, full rebuild, and incremental cycles, and must fail loudly rather than return a clean empty result when planner metadata disagrees with live graph cardinality.Enferlain commentedon Jul 27, 2026
Confirmed independently on current
dev, Linux x64, with bothOVERRIDEandINHERITS. This is not limited toCALLS, Windows, an old persisted database, or delete/incremental-update cycles.I created a standalone synthetic Python repository and indexed the completed fixture once under a brand-new project name with
mode="full":The relevant tail of the fixture is ordinary direct inheritance:
The file also contains 1,200 unrelated classes with one unrelated method each, generated before these classes. A smaller control with 495
Methodnodes returned consistent results; after increasing the unrelated method-source population to 1,698, the unlabeled-source plan failed.OVERRIDEmatrixtrace_pathproves the two relationships are traversable:{ "function_name": "cbm-query-consistency-fresh.zz_pressure.VeryLateBase.dispatch", "direction": "inbound", "depth": 1, "edge_types": ["OVERRIDE"], "include_tests": true, "mode": "calls" }Actual:
callers_total=2(VeryLateAlpha.dispatch,VeryLateBeta.dispatch).Equivalent unlabeled-source Cypher silently returns zero:
Adding the label to the source returns the correct two rows:
Adding the label only to the target still returns zero:
Broad counts show the same failure:
INHERITSmatrixThe equivalent unlabeled query for
VeryLateBasereturns zero, while the source-labelled query returns both children andtrace_path(edge_types=["INHERITS"])reaches the base.Broad counts are incomplete rather than wholly empty:
All fixture paths have matching current coverage metadata with no recorded gaps.
Interpretation
This evidence points to a scale/order-sensitive source-candidate enumeration or query-planning defect for unlabeled source nodes. It does not look like different graph stores, generations, or edge-name normalization:
get_graph_schema, source-labelledquery_graph, andtrace_pathall agree on the same freshly indexed relationships. The safe workaround remains to label the pattern's source node explicitly.junk151516 commentedon Jul 27, 2026
Original reporter here. @Enferlain's independent repro holds on our side, and chasing it across nine real indexed repositories turned up what looks like the actual mechanism. The trigger is the row limit, not scale and not the label itself. Scale is a symptom.
The limit is applied to source-candidate enumeration, before predicate filtering
With no label on the source node,
max_rowsappears to bound how many candidate source nodes get scanned, rather than how many result rows get returned. The predicate is then evaluated only over that truncated prefix. A label lets the scan start from the label index, so the budget is never spent on unrelated nodes.Same query, same database, same binary (v0.9.0, Windows), only
--max-rowsvaries:The unlabeled result climbs monotonically with the limit until it saturates at the true value. It is not a binary empty/non-empty failure — the limit is buying visibility into more of the candidate space.
Reproduced on a second repository:
Note the contrast in the same table: with the label,
--max-rowsbehaves exactly as documented — it caps the output. Without it, it silently truncates the search.This accounts for every earlier observation
INHERITS244 of 248, our webIA 113 of 1,048) — a partially-consumed budget yields a partial answer, which is the dangerous case: a plausible number that is quietly wrong.COUNT(r)with no limit being correct — we verified this across all nine projects; unlabeledMATCH (a)-[r:CALLS]->(b) RETURN COUNT(r)matchedget_graph_schemaexactly every time, including kumo-app at 43,851 edges. No truncation, no bug.A falsifiable check
If this reading is right, your existing 1,698-method fixture should return the correct two rows for the unlabeled
OVERRIDEquery without any re-indexing, purely by raisingmax_rowswell above the node population. And the 495-method control should be made to fail by lowering it. That would confirm the defect is in limit placement rather than in planning or enumeration correctness, and it would move the fix from the planner to wherever the row budget is bound.This is black-box inference — we have not read the source, so treat the mechanism as a hypothesis and the measurements as the evidence.
Practical note
The safe workaround is unchanged, but worth restating precisely: label the source node. Raising
max_rowsabove the candidate population also works, but it is not a workaround anyone can apply safely — you cannot know the right value without already knowing the graph size, and being wrong fails silently.The corollary matters more than the workaround: an unlabeled-source query carrying a limit cannot be trusted at all, including for counts. It does not merely risk returning nothing; it can return a well-formed, believable subset. We have updated our internal query recipes to forbid the shape outright rather than treat empty results as the only failure signal.
Happy to run further probes on these graphs — they are real codebases spanning 232 to 43,851
CALLSedges, which makes the threshold easy to bracket.DeusData commentedon Jul 28, 2026
Thanks @Enferlain and @junk151516 for the independent fixture and the max_rows sweep. The new diagnosis is confirmed by current main.
scan_pattern_nodes uses max_rows to cap an unlabeled source scan (src/cypher/cypher.c:3016-3026), and execute_single passes that same value into source enumeration before WHERE filtering (cypher.c:4636-4646). The public header, however, defines max_rows as an output-row limit. That is exactly the pre-filter truncation your monotonic table demonstrates.
This remains priority/high in 0.9.1-rc. The acceptance test needs both directions: raising max_rows restores the late matches without reindexing, and lowering it can hide them while the labeled-source query remains correct. Thank you for turning a scale symptom into a falsifiable root cause.
junk151516 commentedon Aug 17, 2026
Retested on v0.10.6 (
windows-amd64, build3099e91c29d8507c94bfb7ed431a1f89459ad9afdcea413cbab23c9ec3cfd972). Still reproduces, with the same signature, on a freshly indexed project (13,506 nodes / 48,224 edges,moderate, isolated cache — no state carried over from the v0.9.0 store where this was first seen).Ground truth for the target: 965 callers labelled
Function, 1,048 callers across all labels.--max-rowsa:Functionon sourceMonotonic until saturation, same as the v0.9.0 and v0.9.1-rc.1 measurements (0 → 113 → 729 → 1048 on this same project in July). The mechanism described earlier in this thread still holds: without a label the limit bounds how many candidate nodes are scanned, not how many rows are returned, and the
WHEREruns only over that prefix.New finding: this contaminates aggregates, and there the label workaround does not save you
This is the part I did not know when I filed the issue, and it is worse than the empty/undercounted lists — because the output looks like a fact rather than a truncated list.
Counting edges by type. Ground truth: 13,691
DEFINESedges (verified independently:MATCH (a)-[r:DEFINES]->(b) RETURN count(r)= 13,691 with no--max-rows; every one of them originates at aFilenode — theModulevariant returns 0 — so the label does not change the set).Query:
MATCH (a)-[r]->(b) WITH type(r) AS t RETURN t, count(*) AS c ORDER BY c DESC--max-rowsa:Fileon sourceThe result set is 26 rows (26 distinct edge types), so at
--max-rows 1000nothing is being truncated on the output side — yet the count is short by 32%. The limit is bounding the scan and the aggregate is computed over the scanned prefix.The practical consequence for anyone using the documented workaround: putting a label on the source node protects lists, but not counts.
MATCH (a:File)-[r]->(b) ... count(*)with a limit set returns 9,360 — a plausible, precise, wrong number, with a label present and no warning. Our internal rule was "always label the source node"; it turns out that is necessary but not sufficient, and we have had to amend it to "any aggregation runs without--max-rows, label or no label".One thing I cannot explain and am not going to guess at:
--max-rows 8and--max-rows 1000produce the identical 9,360 in the labelled case, where the unlabelled case moves monotonically. That suggests the budget is charged in units other than rows, but I have not isolated it and I am not claiming a mechanism.Suggested acceptance criterion
Whatever the fix, the falsifiable check is cheap and does not need our corpus: an aggregation's result must not depend on
--max-rowswhen the number of result rows is below the limit. If bounding the scan is deliberate, then a query containing an aggregate should either ignore the limit or fail loudly — the current behaviour satisfies neither, and it produces its most convincing wrong answer exactly where a reader is least likely to audit it.Caveats on scope: one machine (Windows 11), one project, one binary variant. The list-shaped half has now reproduced across v0.9.0, v0.9.1-rc.1 and v0.10.6 on two different projects, so I am confident in that part; the aggregate half is measured here for the first time.
DeusData commentedon Aug 17, 2026
Reopening deliberately after merging #1323 (22d93dd, in the next release): that PR fixes the FIRST mechanism in this thread — the unlabeled source scan was capped at 10×max_rows before WHERE, and now materializes the full candidate set, so unlabeled lists stop truncating (your monotonic table's left column). What it does NOT fix is the second mechanism your v0.10.6 retest exposed: aggregates computed over a scan bounded by max_rows, where even a LABELED source undercounts (your 9,360-vs-13,691 DEFINES table with a:File at --max-rows 1000 — 26 output rows, so nothing output-side explains it). That is a different code site (the expansion feeding WITH/aggregation, not source enumeration) and it stays open here. Your reframing — that a truncated COUNT looks like a fact rather than a truncated list — is exactly why this remains priority: silent wrong results violate this project's first principle. The max_rows-sweep methodology across nine repositories is the most decisive diagnosis this issue could have gotten; thank you for carrying it across three releases.
fix(cypher): expansion materializes every matched row — the cap falsi…
Merge pull request #1698 from DeusData/fix/1196-aggregate-scan-trunca…
DeusData commentedon Aug 18, 2026
Both truncation mechanisms are now fixed on main:
count(rel)sees the complete match set regardless of max_rows, which only limits OUTPUT rows as documented.Regression tests pin both mechanisms (aggregate-beyond-cap asserts 60 edges where the old cap returned 20). Thanks for the sharp report and for pushing on the second mechanism after the first fix landed — that follow-up comment was exactly right.
junk151516 commentedon Aug 19, 2026
Confirming the fix from our side on v0.10.8 (
windows-amd64, buildb4b403b1d7c4def3785f148b93f345ce8427858f4f5489ce28580c4387a336a6). Both mechanisms are gone. We re-ran the same--max-rowssweep that carried this issue across v0.9.0, v0.9.1-rc.1 and v0.10.6, on the same two projects and against the same targets.Method note: the store was not reindexed — v0.10.8 read the v0.10.6 store as-is — and we reused the identical query targets, so the ground truths act as a control. They came back identical to the v0.10.6 run (kumo-app 5,080 unlabeled / 3,435
a:Function; webIA 1,048 / 965). Same graph, same query, same target: the only variable that moved is the binary.1. Lists — fixed
Callers of
str, unlabeled source vs labeled, previous numbers in parentheses:--max-rowsEvery rung now returns exactly
min(limit, truth). The monotonic climb is gone, and so is the plausible-but-wrong 4,889 that kumo-app used to produce at 5000.2. Aggregates — fixed, including the labeled case
DEFINESedges on webIA, queryMATCH (a)-[r]->(b) WITH type(r) AS t RETURN t, count(*) AS c ORDER BY c DESC:--max-rowsa:FileFlat across the whole sweep, labeled and unlabeled. At
--max-rows 8the output is correctly truncated to 8 of the 24 type rows while the counts inside them stay complete — which is precisely the documented contract.One honest bookkeeping note: the ground truth moved from 13,691 to 13,679 between our v0.10.6 run and this one, because the watcher reindexed webIA in between. We re-measured it rather than carrying the old number forward; it is not a discrepancy introduced by the fix.
The acceptance criterion we proposed earlier in this thread — an aggregation's result must not depend on
--max-rowswhen the number of result rows is below the limit — now holds at every rung we tested. And the thing we flagged as unexplained (labeled8and1000both returning exactly 9,360 while the unlabeled case moved monotonically) is moot: there is no longer a budget being charged against the scan.Practical consequence for us
Our internal rules were "always label the source node" and, after the aggregate finding, "any aggregation runs without
--max-rows". We have now retired both, against our own measurements rather than the changelog. What we kept, because it was never the bug:--max-rowstruncates output silently, so a 100-row list over a 5,080-row truth is correct and partial; and labeling still matters semantically, sincea:Function(3,435) and unlabeled (5,080) are genuinely different sets, not one truncated view of the other.Scope caveats, same as before: one machine (Windows 11), two projects, one binary variant. The two projects are the ones where this reproduced across four releases, and small projects were never diagnostic here — 7 of our 10 passed even while the bug was live, because their node population fit inside the budget.
Thanks for chasing the second mechanism after the first fix landed — the expansion-buffer half was the one that produced wrong answers where a reader was least likely to audit them.