Skip to content

neighbors returns wrong-labeled edges: Kuzu 0.11.3 silently drops label(e) IN $list predicate #119

@HumanBean17

Description

@HumanBean17

Summary

neighbors_v2 returns wrong-labeled edges because Kuzu 0.11.3 silently drops the label(e) IN $list predicate. When the requested edge_types is a strict subset of the origin node's actual out-edges, the tool returns edges whose label is NOT in the requested set.

This is a silent correctness bug, not a performance regression. The output looks plausible (it returns Edges with real edge_type field populated) but the filter is a no-op.

Repro

import sys; sys.path.insert(0, '.')
from kuzu_queries import KuzuGraph
from mcp_v2 import neighbors_v2

g = KuzuGraph('/tmp/your_kuzu')
# Pick any method that has both DECLARES_CLIENT and CALLS edges out
mid = 'acf9c57a9f50a153542dc5d00f5f47fa4b188f8a'  # from tests/bank-chat-system

out = neighbors_v2(ids=mid, direction='out', edge_types=['DECLARES_CLIENT'], graph=g)
print(out.results[0].edge_type)  # observed: 'CALLS'
                                  # expected: 'DECLARES_CLIENT'

Root cause

Direct Cypher probe against Kuzu 0.11.3 on tests/bank-chat-system fixture, method with 15 CALLS + 1 DECLARES_CLIENT + 1 HTTP_CALLS out-edges:

Cypher predicate Rows returned Label seen
label(e) IN $list_param (parameterized) 1 CALLS
label(e) IN ["DECLARES_CLIENT"] (literal list) 1 CALLS
label(e) = "DECLARES_CLIENT" (equality) 1 DECLARES_CLIENT
MATCH (a)-[e:DECLARES_CLIENT]->(b) (typed pattern) 1 DECLARES_CLIENT
no filter 17 15 CALLS + 1 DECLARES_CLIENT + 1 HTTP_CALLS (ground truth)

So label(e) IN $list is broken in both parameterized and literal forms. Typed-pattern matching and equality work correctly. This is upstream — Kuzu issue, not user-rag issue — but our code uses the broken predicate.

Call sites

Only two, both in mcp_v2.py:

  • Line 547–548: direction="out" branch
  • Line 558–559: direction="in" branch

No other code in the repo uses label(e) IN or label(r) IN.

Why we didn't see this earlier

The bug only surfaces when the requested labels are a strict subset of the actual edges on the origin node. Tests typically:

  • Ask for one specific label on a node where that's the only out-edge → broken filter is a no-op, looks right
  • Ask for "all" labels (no edge_types filter, or covering all present labels) → broken filter is a no-op, looks right
  • Iterate per-label assertions → each iteration matches the "only one label exists" case

The failure shape ("returns 1 row of the wrong label") is also unusually misleading — it doesn't return 0, it doesn't error, it returns a plausible-looking Edge whose edge_type field is populated with the actual queried-edge label. Naive assertion assert out.results[0].edge_type == "X" would fail loud, but assertions checking len(results) > 0 would silently pass with garbage.

Proposed fix

Replace label(e) IN $edge_types with typed pattern matching:

# edge_types is already validated by _NEIGHBOR_EDGE_TYPES_ADAPTER against the
# EdgeType Literal allowlist — interpolation is injection-safe.
edge_pattern = "|".join(edge_types)
rows = g._rows(
    f"MATCH (a)-[e:{edge_pattern}]->(b) WHERE a.id = $id "
    f"RETURN b.id AS other_id, label(e) AS edge_type, "
    f"e.confidence AS confidence, e.strategy AS strategy, ...",
    {"id": origin_id},
)

Same shape for the direction="in" branch with <-[e:LABEL|...]-.

Why this is safe

  • EdgeType is Literal[...] (mcp_v2.py:17–31), enforced by _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(edge_types) (line 534) before the Cypher is built. Only allowlisted values reach the f-string.
  • No new Cypher parameter contract — Kuzu's typed-pattern syntax is stable across recent versions.
  • Identical semantics on the happy path; the only behavior change is that broken cases now return the correct subset instead of the wrong row.

Suggested test coverage

A regression test that fails today, passes after the fix:

def test_neighbors_filters_by_requested_edge_types():
    """Regression: Kuzu 0.11.3 label(e) IN $list silently drops the filter."""
    g = build_test_graph_with_method_having_multiple_edge_types()
    # method has 15 CALLS, 1 DECLARES_CLIENT, 1 HTTP_CALLS out

    out = neighbors_v2(ids=method_id, direction='out',
                      edge_types=['DECLARES_CLIENT'], graph=g)
    assert all(e.edge_type == 'DECLARES_CLIENT' for e in out.results)
    assert len(out.results) == 1

    out = neighbors_v2(ids=method_id, direction='out',
                      edge_types=['CALLS', 'HTTP_CALLS'], graph=g)
    assert set(e.edge_type for e in out.results) == {'CALLS', 'HTTP_CALLS'}
    assert len(out.results) == 16

Plus existing neighbors tests should be re-audited — they were running under broken-filter assumptions.

Severity

High. Every neighbors call with a non-trivial edge_types filter is suspect. The MCP V2 surface is the primary agent affordance, and neighbors is the only traversal primitive. Without this fix, the rollup-decomposition walk from issue #118 cannot work in practice — even when the agent does the right 2-hop decomposition, step 3 (neighbors([methods], "out", ["DECLARES_CLIENT"])) returns CALLS edges instead of clients.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions