Skip to content

query_graph: RETURN DISTINCT type(r) silently returns a subset of edge types (5 of 22) — regression vs 0.8.1 #1364

Closed

Description

@braggintime

Version

codebase-memory-mcp 0.9.0

Platform

macOS (Apple Silicon)

Install channel

GitHub release archive / install.sh / install.ps1

Binary variant

standard

What happened, and what did you expect?

RETURN DISTINCT type(r) silently reports only a subset of the edge types present in the graph. On an index whose get_graph_schema lists 22 edge types, the query returns 5, with total: 5 and no truncation signal.

MATCH ()-[r]->() RETURN DISTINCT type(r) AS edge_type

Actual:

{"columns":["edge_type"],
 "rows":[["CONTAINS_FILE"],["CONTAINS_FOLDER"],["DEFINES"],["CALLS"],["USAGE"]],
 "total":5}

Expected: the 22 types get_graph_schema reports for the same project.

The omitted types are not empty — they are individually countable in the same session:

type present in DISTINCT type(r)? count(r)
DEFINES yes 22,386
USAGE yes 15,687
CALLS yes 10,509
IMPORTS no 3,460
CONTAINS_FILE yes 2,250
DEFINES_METHOD no 1,866
DECORATES no 841
WRITES no 480
CONTAINS_FOLDER yes 338
FILE_CHANGES_WITH no 315
INHERITS no 297
SIMILAR_TO no 294
HTTP_CALLS no 194
SEMANTICALLY_RELATED no 181
HANDLES no 180
TESTS_FILE no 126
CONFIGURES no 71
RAISES no 68
TESTS no 35
DATA_FLOWS no 14
THROWS no 3
HAS_BRANCH no 1

The 22 counts sum to exactly 59,596, which matches MATCH ()-[r]->() RETURN count(r) and the edges figure from index_status. So the graph is complete and only the DISTINCT projection is wrong. There is no obvious rule behind the five that survive — they are not the top five by count (IMPORTS at 3,460 and DEFINES_METHOD at 1,866 both outrank CONTAINS_FOLDER at 338, which is included).

This appears to be a regression in 0.9.0. A second machine running 0.8.1 against the same source tree returns 21 of 21 edge types correctly for the identical query. Only the 0.9.0 machine truncates.

Why this matters

The failure is silent and the output is well-formed, so it reads as an authoritative answer. Discovering the edge model is exactly what an agent does before writing any query_graph traversal — a caller who trusts this concludes that IMPORTS, HTTP_CALLS, DATA_FLOWS, INHERITS etc. do not exist in the graph and never queries them. In my case it also produced a false diagnosis: I read the truncated list as evidence the extractor had stopped emitting those edge types, when in fact all 22 were present.

Same family as #1292 (grouping key silently returns one wrong row) — a projection that quietly loses rows rather than erroring.

Reproduction

Repo-independent — reproduces on any indexed project:

codebase-memory-mcp cli get_graph_schema --project <slug>
# note the edge_types list and its length

codebase-memory-mcp cli query_graph --project <slug> \
  --query "MATCH ()-[r]->() RETURN DISTINCT type(r) AS edge_type"
# returns a strict subset, with no truncation signal

Cross-check any omitted type individually to confirm it is non-empty:

codebase-memory-mcp cli query_graph --project <slug> \
  --query "MATCH ()-[r:IMPORTS]->() RETURN count(r) AS n"

Project scale (if relevant)

TypeScript/Swift/SQL monorepo, 2,250 files, 25,269 nodes / 59,596 edges, 22 edge types per get_graph_schema. Reproduced identically through the MCP server and cli mode.

Confirmations

  • I searched existing issues and this is not a duplicate.
  • The reproduction is repo-independent — it is a schema-level query that runs against any indexed project, and the mismatch is against the tool's own get_graph_schema output.

Activity

  1. AmirF194 commented on Jul 30, 2026

    @AmirF194
    Contributor

    Open PR #1323 fixes the mechanism this hits, though not the whole gap at large scale. Detail below.

    Your query (MATCH ()-[r]->()) starts from an unlabeled node pattern, which routes through scan_pattern_nodes()'s unlabeled branch in src/cypher/cypher.c. On main (a65faeb4753787add7fd63eb3975b7abc91165d8) that branch calls cbm_store_search() with limit = max_rows * 10, and cbm_store_search is a ranked/BM25 search path, not a plain scan, so it does not actually return every node even when the numeric limit is far above the node count. #1323 replaces it with a real unbounded cbm_store_find_nodes().

    I couldn't reproduce the truncation on a same-order-of-magnitude index (qlib, 6.5k nodes: 21/21 came back fine), so I indexed something bigger to get past whatever the search path's effective cutoff is:

    • run-llama/llama_index @ current default branch, 77,599 nodes / 305,782 edges, mode:full.
    • Main (a65faeb): MATCH ()-[r]->() RETURN DISTINCT type(r) → 17 of 21 types, total:17, no truncation signal. Matches your symptom exactly.
    • PR fix(cypher): scan all unlabeled query candidates #1323's branch (cb9be33452b9969e38c9164c7be4495ae187611b), same index, same query → 20 of 21. Big improvement, but HAS_BRANCH (a single edge) is still missing. Confirmed it's really there and queryable: MATCH (a)-[r]->(b:Branch) RETURN DISTINCT type(r) finds it fine (that pattern's scan starts from the far smaller/labeled side).

    So there's a second, separate cap on top of the one #1323 fixes. execute_return_simple() (cypher.c:4344) projects rows with rb->row_count < proj_cap, and proj_cap is max_rows, which defaults to CYPHER_RESULT_CEILING (100,000) when unset. That cap runs before rb_apply_distinct() (called from execute_return_clause, right after), so on a big enough match set the DISTINCT step only ever sees the first ~100k raw rows, not the full set, and the "result exceeded 100k rows" guard a few lines down never fires because it checks the row count after dedup. I didn't step through this with a debugger, so treat the exact mechanism as a read, not an executed proof, but it lines up with what I observed: your project (59,596 edges) is comfortably under 100k, so #1323 alone should fully resolve the case in this issue; the residual gap I found only shows up past that ceiling.

  2. added
    bugSomething isn't working
    cypherCypher query language parser/executor bugs
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    and removed
    windowsWindows-specific issues
    on Aug 3, 2026
  3. added this to the 0.9.1-rc milestone on Aug 3, 2026
  4. DeusData commented on Aug 3, 2026

    @DeusData
    Owner

    Thank you for quantifying the 0.8.1 to RC regression. PR #1323 addresses the early unlabeled candidate scan, but the remaining pre-DISTINCT execution cap can still hide edge types before aggregation and is not covered by that narrower fix. I have removed the unrelated Windows label and kept the issue open at high priority in 0.9.1-rc until the full 22-type contract is reproduced and restored.

  5. DeusData commented on Sep 26, 2026

    @DeusData
    Owner

    Thank you, @braggintime, for the precise 22-versus-5 table, and @AmirF194 for tracing the scan and measuring the remaining gap on llama_index!

    v0.10.7 shipped two fixes: one for the scan that skipped unlabeled nodes, and one that counts every matched row before aggregating. The big loss is gone. On a 150k-edge index, RETURN DISTINCT type(r) now returns 21 of 22 types, and the response is no longer silent: it reports truncated: true, truncation_reason: engine_limit.

    The one missing type (HAS_BRANCH, as predicted) has one remaining cause. Plain RETURN DISTINCT stops at the engine row limit and only then removes duplicates. We'll move de-duplication ahead of the limit. Until then, MATCH ()-[r]->() WITH type(r) AS t RETURN t, count(*) AS c returns all 22.

  6. DeusData commented on Oct 4, 2026

    @DeusData
    Owner

    Thanks again, @braggintime, for the precise 22-versus-5 table, and @AmirF194 for tracing the scan. The last missing type is fixed now.

    Plain RETURN DISTINCT now removes duplicates while it projects, so the engine row cap and an explicit LIMIT both count distinct rows. A value first seen late in the scan, like HAS_BRANCH, can no longer fall off the end. On an index where RETURN DISTINCT type(r) previously returned 22 of 23 edge types with engine_limit, it now returns all 23 with truncated: false, in the same wall time. The WITH ... count(*) workaround is no longer needed.

    Landed on main in 20fd3aa (#2511); it ships with the next release.

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.

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions