Skip to content

fix(queries): match :EDGE and filter on kind in typed traversals - #70

Open
r0h1tb wants to merge 1 commit into
mainfrom
fix/typed-relationship-queries
Open

fix(queries): match :EDGE and filter on kind in typed traversals#70
r0h1tb wants to merge 1 commit into
mainfrom
fix/typed-relationship-queries

Conversation

@r0h1tb

@r0h1tb r0h1tb commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Description

Eight read sites matched relationship types that no writer ever creates, so
they could only ever return empty — silently, with no error.

batch_upsert_edges writes every edge as a single untyped :EDGE
relationship and keeps the semantic kind as a property:

MERGE (a)-[r:EDGE {id: e.id}]->(b)
SET r += e

The only other relationship types created anywhere are :RELATES and
:CONTAINS_BLOCK, both of which have matching readers and are fine.

This is option A, as you chose on the issue — align the readers to the
writer. Each site now matches [r:EDGE] and filters with WHERE r.kind IN $...,
the shape #50 already moved the call path to. Variable-length traversals use
all(rel IN rels WHERE rel.kind IN $...), matching find_callers/find_callees.

Related Issue

Fixes #61

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature
  • Breaking change
  • Documentation update

What was broken

file:line was now
api/ast_rag_api.py:470 [:INHERITS|EXTENDS|IMPLEMENTS*1..N] [rels:EDGE*1..N] + all(rel IN rels …)
api/ast_rag_api.py:486 same same
api/ast_rag_api.py:520 [:OVERRIDES*1..N] [rels:EDGE*1..N] + all(rel IN rels …)
api/ast_rag_api.py:916 [r:TYPES] [r:EDGE] + r.kind IN $type_kinds
api/ast_rag_api.py:921 [r:INHERITS|EXTENDS|IMPLEMENTS] [r:EDGE] + r.kind IN $inheritance_kinds
api/ast_rag_api.py:995 [r:TYPES] [r:EDGE] + r.kind IN $type_kinds
api/ast_rag_api.py:1019 [r:INHERITS|EXTENDS|IMPLEMENTS] [r:EDGE] + r.kind IN $inheritance_kinds
services/search_service.py:499 [:CAPTURES] [r:EDGE] + r.kind IN $capture_kinds

User-visible effect: find_subclasses, find_superclasses, find_overrides
and the type-usage / inheritance counts in the node-detail and impact paths
returned nothing regardless of the graph.

Two details worth flagging yourself

1. type(r) would have silently changed meaning. The paginated inheritance
query returned type(r) as rel_type, which after this change is always "EDGE".
It returns r.kind instead, so the reported reference type still says
INHERITS/EXTENDS/IMPLEMENTS rather than degrading to EDGE. A naive
rewrite of the eight sites would have broken that field.

2. :CAPTURES is corrected but still returns nothing — for a different
reason, which I want to be explicit about rather than imply it now works. The
parsing layer emits no CAPTURES edges and no Variable nodes at all, so
find_lambdas(with_captured_vars=True) reports captured_vars: [] either way.
The query shape is fixed so it starts working the moment the extractor lands,
and EdgeKind.CAPTURES is declared so the kind is no longer a bare undeclared
string. Happy to drop that hunk if you would rather keep this PR strictly to
the sites where edges actually exist.

Option B

Left undone deliberately, and documented in a comment next to the new kind
groups. Emitting real typed relationships keeps traversals natural and lets
Neo4j use relationship-type indexes for the *1..N inheritance walks, but it
needs a dynamic write (APOC or a per-kind MERGE branch) and is a breaking
graph-schema change requiring a re-index. Worth doing deliberately rather than
as a bug fix.

Checklist

  • My code follows the code style of this project
  • I have added tests that prove my fix works
  • All new and existing tests passed (pytest tests/ -v)
  • I have updated the documentation accordingly (comments; no doc pages affected)
  • I have run ast-rag evaluate --allnot run, see Testing below
  • My changes generate no new warnings

Testing

The regression test is static, for the same reason test_query_parameters_bound
is: proving a traversal returns rows needs a populated graph with real
inheritance and override edges. It collects the relationship types the writers
create and the ones the readers match, and asserts the second is a subset of the
first. It is derived from the source rather than hard-coded, so if a writer is
later changed to emit typed relationships (option B), the matching readers stop
failing it on their own. A second test guards the guard — if the scan finds no
:EDGE writer, the subset check proves nothing and it says so.

On this branch with the source changes reverted and the test kept:

$ git stash                       # revert source, keep the new test
$ pytest tests/test_relationship_types_emitted.py -q

E   AssertionError: These queries match relationship types no writer creates,
E   so they can only ever return empty:
E     :CAPTURES matched at ast_rag/services/search_service.py:499
E     :EXTENDS matched at ast_rag/api/ast_rag_api.py:470, :486, :921, :1019
E     :IMPLEMENTS matched at ast_rag/api/ast_rag_api.py:470, :486, :921, :1019
E     :INHERITS matched at ast_rag/api/ast_rag_api.py:470, :486, :921, :1019
E     :OVERRIDES matched at ast_rag/api/ast_rag_api.py:520
E     :TYPES matched at ast_rag/api/ast_rag_api.py:916, :995
E
E   Relationship types actually written: ['CONTAINS_BLOCK', 'EDGE', 'RELATES'].

1 failed, 1 passed

Full suite, Python 3.12 on macOS:

passed failed skipped xfailed
baseline (main @ 41e48af) 232 0 1 1
this branch 234 0 1 1

Exactly +2, the two new tests. No pre-existing failures on either side.

test_query_parameters_bound still passes, which matters here — it is what
would catch a rewritten query whose new $inheritance_kinds / $type_kinds /
$capture_kinds parameter was not actually bound at some session.run site.

$ ruff check ast_rag/          # All checks passed!
$ ruff format --check ast_rag/ tests/   # 88 files already formatted

ast-rag evaluate --all was not run. It needs a live Neo4j + Qdrant, and
the committed ast_rag_config.json points at a private LAN (#62), so I have no
way to run it from here. Nothing in this change touches indexing, embeddings or
scoring — it only rewrites read-side Cypher — but flagging it rather than
ticking the box.

Additional Notes

Ping me if you would prefer this split per query family, or if you want the
:CAPTURES hunk dropped.

batch_upsert_edges writes every edge as a single untyped :EDGE
relationship and keeps the semantic kind as a property:

    MERGE (a)-[r:EDGE {id: e.id}]->(b)
    SET r += e

Eight read sites instead matched relationship types that no writer
creates (:INHERITS, :EXTENDS, :IMPLEMENTS, :OVERRIDES, :TYPES,
:CAPTURES). Those queries are valid Cypher over an empty set: they
compile, they run, and they return nothing, for ever, with no error.

That silently emptied find_subclasses, find_superclasses, find_overrides
and the type-usage and inheritance counts in the node-detail and impact
paths.

This is option A from #61, as chosen on the issue: align the readers to
the writer. Each site now matches [r:EDGE] and filters with
`WHERE r.kind IN $...`, the shape #50 already moved the call path to.
Variable-length traversals use `all(rel IN rels WHERE rel.kind IN $...)`,
matching find_callers/find_callees.

Option B — emitting real typed relationships — keeps traversals natural
and lets Neo4j use relationship-type indexes for the *1..N inheritance
walks, but needs a dynamic write and a re-index. Documented in a comment
next to the new kind groups; worth doing deliberately rather than as a
bug fix.

Two details worth calling out:

- The paginated inheritance query returned `type(r) as rel_type`, which
  would now always be "EDGE". It returns `r.kind` instead, so the
  reported reference type still says INHERITS/EXTENDS/IMPLEMENTS.
- The :CAPTURES site is corrected for consistency but still returns
  nothing, for a different reason: the parsing layer emits no CAPTURES
  edges or Variable nodes yet. EdgeKind.CAPTURES is declared so the kind
  is no longer a bare undeclared string.

The regression test is static, for the same reason test_query_parameters_bound
is: proving a traversal returns rows needs a populated graph with real
inheritance and override edges. It collects the relationship types the
writers create and the ones the readers match, and asserts the second set
is a subset of the first — so a future typed writer makes its matching
readers pass on their own.

Fixes #61
@lexasub

lexasub commented Aug 8, 2026

Copy link
Copy Markdown
Owner

@good pr, may merge

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Typed-relationship queries match relationship types the writer never emits (:INHERITS, :OVERRIDES, :TYPES, :CAPTURES)

2 participants