Skip to content

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

@junk151516

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:

  1. Make unlabeled matching consistent with label-anchored matching after delete/reindex cycles (or rebuild the relevant index on write).
  2. 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

  1. added
    cypherCypher query language parser/executor bugs
    parsing/qualityGraph extraction bugs, false positives, missing edges
    ux/behaviorDisplay bugs, docs, adoption UX
    windowsWindows-specific issues
    on Jul 21, 2026
  2. added
    bugSomething isn't working
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    on Jul 22, 2026
  3. added this to the 0.9.1-rc milestone on Jul 22, 2026
  4. DeusData commented on Jul 22, 2026

    @DeusData
    Owner

    Thanks for the probe matrix and same-binary/same-database timeline. This is a high-priority 0.9.1-rc silent-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.

  5. Enferlain commented on Jul 27, 2026

    @Enferlain
    Contributor

    Confirmed independently on current dev, Linux x64, with both OVERRIDE and INHERITS. This is not limited to CALLS, 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":

    project: cbm-query-consistency-fresh
    3,652 nodes / 5,896 edges
    Method: 1,698
    Class: 1,698
    OVERRIDE: 248
    INHERITS: 248
    generation_matches: true
    parse_partial: 0
    skipped: 0
    

    The relevant tail of the fixture is ordinary direct inheritance:

    class VeryLateBase:
        def dispatch(self) -> str:
            return "base"
    
    class VeryLateAlpha(VeryLateBase):
        def dispatch(self) -> str:
            return "alpha"
    
    class VeryLateBeta(VeryLateBase):
        def dispatch(self) -> str:
            return "beta"

    The file also contains 1,200 unrelated classes with one unrelated method each, generated before these classes. A smaller control with 495 Method nodes returned consistent results; after increasing the unrelated method-source population to 1,698, the unlabeled-source plan failed.

    OVERRIDE matrix

    trace_path proves 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:

    MATCH (a)-[:OVERRIDE]->(b)
    WHERE b.qualified_name = 'cbm-query-consistency-fresh.zz_pressure.VeryLateBase.dispatch'
    RETURN a.qualified_name, b.qualified_name

    Adding the label to the source returns the correct two rows:

    MATCH (a:Method)-[:OVERRIDE]->(b)
    WHERE b.qualified_name = 'cbm-query-consistency-fresh.zz_pressure.VeryLateBase.dispatch'
    RETURN a.qualified_name, b.qualified_name

    Adding the label only to the target still returns zero:

    MATCH (a)-[:OVERRIDE]->(b:Method)
    WHERE b.qualified_name = 'cbm-query-consistency-fresh.zz_pressure.VeryLateBase.dispatch'
    RETURN a.qualified_name, b.qualified_name

    Broad counts show the same failure:

    MATCH (a)-[r:OVERRIDE]->(b) RETURN COUNT(r)                  -> 0 rows
    MATCH (a:Method)-[r:OVERRIDE]->(b:Method) RETURN COUNT(r)    -> 248
    get_graph_schema OVERRIDE count                              -> 248
    

    INHERITS matrix

    The equivalent unlabeled query for VeryLateBase returns zero, while the source-labelled query returns both children and trace_path(edge_types=["INHERITS"]) reaches the base.

    Broad counts are incomplete rather than wholly empty:

    MATCH (a)-[r:INHERITS]->(b) RETURN COUNT(r)                -> 244
    MATCH (a:Class)-[r:INHERITS]->(b:Class) RETURN COUNT(r)    -> 248
    get_graph_schema INHERITS count                            -> 248
    

    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-labelled query_graph, and trace_path all agree on the same freshly indexed relationships. The safe workaround remains to label the pattern's source node explicitly.

  6. junk151516 commented on Jul 27, 2026

    @junk151516
    ContributorAuthor

    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_rows appears 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-rows varies:

    project: kumo-app  (11,619 Function / 3,201 Method / 43,851 CALLS)
    query:   MATCH (a)-[r:CALLS]->(b) WHERE b.name = 'resolveSedeId' RETURN a.qualified_name
    truth:   103   (COUNT(r) with no --max-rows flag)
    
    --max-rows      unlabeled source      MATCH (a:Function)...
        50                   0                  50   (correctly capped)
       103                   0                 103
       200                   0                 103
       500                   0                 103
       600                   0                 103
       800                   0                 103
      1000                   0                 103
      1500                  10                 103
      2000                  36                 103
      2500                  88                 103
      3000                  93                 103
      4000                 103                 103
      5000                 103                 103
     (no flag)             103                 103
    

    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:

    project: webIA  (4,228 Function / 10,816 CALLS)
    query:   MATCH (a)-[r:CALLS]->(b) WHERE b.name = 'str' RETURN a.qualified_name
    truth:   1,048 total  (965 of them from Function)
    
    --max-rows      unlabeled       a:Function
       100               0             100   (correctly capped)
       500             113             500   (correctly capped)
      1000             729             965
      2000           1,048             965
      8000           1,048             965
    

    Note the contrast in the same table: with the label, --max-rows behaves exactly as documented — it caps the output. Without it, it silently truncates the search.

    This accounts for every earlier observation

    • Scale sensitivity (495 methods fine, 1,698 broken) — a larger unrelated node population pushes the matching nodes past the enumeration budget. Nothing about the graph changed; the budget stopped reaching them.
    • Order sensitivity — your fixture generates 1,200 unrelated classes before the relevant ones. That is precisely a scan-prefix effect: the unrelated nodes are enumerated first and exhaust the budget.
    • Partial rather than empty results (your INHERITS 244 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.
    • Broad COUNT(r) with no limit being correct — we verified this across all nine projects; unlabeled MATCH (a)-[r:CALLS]->(b) RETURN COUNT(r) matched get_graph_schema exactly every time, including kumo-app at 43,851 edges. No truncation, no bug.
    • Why small repositories look healthy — seven of our projects (up to 886 Function nodes / 1,704 CALLS) return correct unlabeled results at limits that break the two large ones. Their entire node population fits inside the budget.

    A falsifiable check

    If this reading is right, your existing 1,698-method fixture should return the correct two rows for the unlabeled OVERRIDE query without any re-indexing, purely by raising max_rows well 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_rows above 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 CALLS edges, which makes the threshold easy to bracket.

  7. DeusData commented on Jul 28, 2026

    @DeusData
    Owner

    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.

  8. junk151516 commented on Aug 17, 2026

    @junk151516
    ContributorAuthor

    Retested on v0.10.6 (windows-amd64, build 3099e91c29d8507c94bfb7ed431a1f89459ad9afdcea413cbab23c9ec3cfd972). 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-rows no label on source a:Function on source
    100 0 100
    500 109 500
    1000 727 965 ✓
    2000 1048 ✓ 965 ✓
    8000 1048 ✓ 965 ✓
    none 1048 ✓ 965 ✓

    Monotonic 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 WHERE runs 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 DEFINES edges (verified independently: MATCH (a)-[r:DEFINES]->(b) RETURN count(r) = 13,691 with no --max-rows; every one of them originates at a File node — the Module variant 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-rows no label a:File on source
    8 39 9,360
    100 151 —
    1000 9,387 9,360
    5000 13,691 ✓ —
    none 13,691 ✓ 13,691 ✓

    The result set is 26 rows (26 distinct edge types), so at --max-rows 1000 nothing 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 8 and --max-rows 1000 produce 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-rows when 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.

  9. DeusData commented on Aug 17, 2026

    @DeusData
    Owner

    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.

  10. added 2 commits that reference this issue on Aug 18, 2026
  11. DeusData commented on Aug 18, 2026

    @DeusData
    Owner

    Both truncation mechanisms are now fixed on main:

    1. Unlabeled scans stopping at max_rows before aggregation — fixed by fix(cypher): scan all unlabeled query candidates #1323 (22d93dd).
    2. Relationship-expansion caps (bind_cap×10) truncating matched rows before aggregation — fixed by fix(cypher): expansion materializes every matched row — the cap falsified aggregates (#1196) #1698 (2973ab7): expansion buffers now grow to materialize every matched row, so 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.

  12. junk151516 commented on Aug 19, 2026

    @junk151516
    ContributorAuthor

    Confirming the fix from our side on v0.10.8 (windows-amd64, build b4b403b1d7c4def3785f148b93f345ce8427858f4f5489ce28580c4387a336a6). Both mechanisms are gone. We re-ran the same --max-rows sweep 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-rows kumo-app, no label (v0.10.6) webIA, no label (v0.10.6)
    100 100 0 100 0
    500 500 — 500 109
    1000 1000 0 1000 727
    2000 2000 — 1,048 ✓ 1,048
    5000 5000 4,889 (wrong) — —
    none 5,080 ✓ 5,080 1,048 ✓ 1,048

    Every 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

    DEFINES edges on webIA, query MATCH (a)-[r]->(b) WITH type(r) AS t RETURN t, count(*) AS c ORDER BY c DESC:

    --max-rows no label a:File (v0.10.6: no label / labeled)
    8 13,679 ✓ 13,679 ✓ 39 / 9,360
    100 13,679 ✓ 13,679 ✓ 151 / —
    1000 13,679 ✓ 13,679 ✓ 9,387 / 9,360
    5000 13,679 ✓ 13,679 ✓ 13,691 / —
    none 13,679 ✓ 13,679 ✓ 13,691 / 13,691

    Flat across the whole sweep, labeled and unlabeled. At --max-rows 8 the 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-rows when the number of result rows is below the limit — now holds at every rung we tested. And the thing we flagged as unexplained (labeled 8 and 1000 both 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-rows truncates output silently, so a 100-row list over a 5,080-row truth is correct and partial; and labeling still matters semantically, since a: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.

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

    bugSomething isn't workingcypherCypher query language parser/executor bugsparsing/qualityGraph extraction bugs, false positives, missing edgespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.ux/behaviorDisplay bugs, docs, adoption UXwindowsWindows-specific issues

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions