Skip to content

Commit 87259bb

Browse files
propose: neighbors dot-key traversal for DECLARES.* composed keys (#166)
* propose: neighbors dot-key traversal for DECLARES.* edge_summary keys Addresses #162 — instead of adding capabilities to NodeRef, make neighbors accept the composed dot-keys that edge_summary already surfaces (DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, DECLARES.EXPOSES). Single 2-hop Cypher, no re-index. OVERRIDDEN_BY.* family deferred to #165. Co-authored-by: Cursor <cursoragent@cursor.com> * address review: decision reversal, scope completions, locked TBDs - Add Decision reversal section (supersedes DESCRIBE-MEMBER-EDGE-ROLLUP decision #11, with rationale for why the read-only invariant was insufficient in practice) - Expand scope to include docs/AGENT-GUIDE.md, mcp_hints.py, server.py tool descriptions - Lock all 4 open questions as decisions - Add Limitations section acknowledging #162 is partially solved; per-method NodeRef signals tracked as #167 - Add origin kind constraint (type Symbol required, clear error for method/route/client/producer) - Add counting semantics alignment requirement - Align Cypher RETURN with flat neighbors attr set - Add tests: origin rejection, count alignment, OVERRIDDEN_BY still rejected, hint template update Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ed3097d commit 87259bb

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# NEIGHBORS-DOT-KEY-TRAVERSAL-PROPOSE
2+
3+
## Status
4+
5+
Proposal — not yet implemented.
6+
7+
Addresses [#162](https://github.com/HumanBean17/java-codebase-rag/issues/162)
8+
(partially — see [Limitations](#limitations)).
9+
10+
## Decision reversal
11+
12+
This proposal **deliberately reverses decision #11** from
13+
[`DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE`](./completed/DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE.md)
14+
(PR-89), which made composed dot-keys read-only by construction —
15+
Pydantic `EdgeType` rejected them at the type-system level, and
16+
`AGENT-GUIDE.md` documented them as hop affordances only.
17+
18+
**Why reverse now:** In practice, the "read-only" invariant created a
19+
discoverability dead-end. `edge_summary` shows the agent that a 2-hop
20+
path exists, then tells it the only way to traverse it is to manually
21+
decompose the path into individual hops — the exact multi-call workflow
22+
the rollup was meant to shortcut. The surface was not obvious enough
23+
for agents (which tend to take dot-keys literally and try to pass them)
24+
or for humans reading the guide. Making `DECLARES.*` dot-keys navigable
25+
closes the loop that the rollup opened.
26+
27+
**Scope of reversal:** only the `DECLARES.*` family (3 stored 2-hop
28+
paths). `OVERRIDDEN_BY.*` keys remain describe-only — they require
29+
signature-matching computation, not stored edge traversal, and reversing
30+
their read-only status is a separate design question
31+
([#165](https://github.com/HumanBean17/java-codebase-rag/issues/165)).
32+
33+
Supersedes:
34+
- Decision #11 in `DESCRIBE-MEMBER-EDGE-ROLLUP-PROPOSE.md`
35+
- `AGENT-GUIDE.md` guidance that composed dot-keys are "not valid
36+
`EdgeType` literals"
37+
- `test_neighbors_rejects_overridden_by_and_dot_keys` (must be split:
38+
accept `DECLARES.*`, still reject `OVERRIDDEN_BY.*`)
39+
- `mcp_hints.py` templates `TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS` /
40+
`TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS` (prescribe the old multi-hop
41+
recipe; must be updated to the single-call dot-key alternative)
42+
43+
## Problem Statement
44+
45+
When `neighbors` or `find` returns a batch of method `NodeRef`s, the agent
46+
has no signal about which methods declare clients, producers, or expose
47+
routes. The `NodeRef` schema carries only structural identity fields — no
48+
edge-presence indicators.
49+
50+
Consider a typical workflow:
51+
52+
1. `search("payment processing")` → class FQN
53+
2. `neighbors([class_id], 'out', ['DECLARES'])` → 15 method `NodeRef`s
54+
3. Agent must decide which methods are interesting for cross-service tracing
55+
56+
At step 3 the agent must either call `describe` on all 15 methods (15 tool
57+
calls), guess by name (unreliable), or hope it already described the class
58+
and noticed `edge_summary` dot-keys — which it may not have done if it
59+
arrived at the class via `find` or `neighbors` from another node.
60+
61+
The `describe` tool already surfaces the answer: `edge_summary` includes
62+
composed dot-keys like `DECLARES.DECLARES_CLIENT: {"out": 2}` for type
63+
Symbols. But `neighbors` rejects those dot-keys today:
64+
65+
> *"do not pass them to `neighbors(edge_types=…)`"*
66+
67+
This creates a discoverability dead-end: `edge_summary` shows a 2-hop path
68+
exists, but the agent cannot act on it without decomposing the path into
69+
individual hops manually.
70+
71+
## Proposed Solution
72+
73+
Make `neighbors` accept the `DECLARES.*` composed dot-key family as valid
74+
`edge_types` values. When a dot-key is requested, `neighbors` executes a
75+
single 2-hop Cypher query and returns the terminal edges/nodes directly.
76+
77+
**Principle: what you see in `edge_summary` is what you can request in
78+
`neighbors`.**
79+
80+
### Supported dot-keys (v1)
81+
82+
| Dot-key | Graph path | Terminal node |
83+
|---|---|---|
84+
| `DECLARES.DECLARES_CLIENT` | `Symbol -[:DECLARES]-> Symbol -[:DECLARES_CLIENT]-> Client` | `Client` |
85+
| `DECLARES.DECLARES_PRODUCER` | `Symbol -[:DECLARES]-> Symbol -[:DECLARES_PRODUCER]-> Producer` | `Producer` |
86+
| `DECLARES.EXPOSES` | `Symbol -[:DECLARES]-> Symbol -[:EXPOSES]-> Route` | `Route` |
87+
88+
### Agent workflow with dot-keys (3 tool calls)
89+
90+
```
91+
search("payment processing") → class FQN
92+
describe(class_id) → edge_summary includes DECLARES.DECLARES_CLIENT: {out: 2}
93+
neighbors(class_id, 'out', ['DECLARES.DECLARES_CLIENT']) → 2 Client NodeRefs directly
94+
```
95+
96+
Down from 17+ tool calls to 3.
97+
98+
### Edge result shape
99+
100+
Each returned `Edge` for a dot-key traversal:
101+
102+
| Field | Value |
103+
|---|---|
104+
| `origin_id` | The starting node passed by the agent (the class) |
105+
| `edge_type` | The dot-key: `DECLARES.DECLARES_CLIENT` |
106+
| `direction` | `out` |
107+
| `other` | Terminal node `NodeRef` (Client / Producer / Route) |
108+
| `attrs` | Terminal edge attributes (confidence, strategy, plus all other attrs that flat `neighbors` projects — see [Cypher attrs](#cypher-implementation)) plus `via_id` — the intermediate method Symbol id |
109+
110+
`edge_type` echoes the dot-key rather than the bare terminal label to avoid
111+
implying a direct single-hop edge that does not exist from the origin node.
112+
`via_id` in `attrs` identifies the intermediate method, letting the agent
113+
trace back to the declaring member without extra tool calls.
114+
115+
### Origin kind constraint
116+
117+
v1 dot-keys require the origin to be a **type** Symbol
118+
(`kind ∈ {class, interface, enum, record, annotation}`). This matches the
119+
scope of `member_edge_rollup_for`, which only computes dot-key counts for
120+
type Symbols.
121+
122+
If a method, route, client, or producer id is passed with a dot-key
123+
`edge_type`, `neighbors` returns `success=False` with a validation error:
124+
`"Composed edge types (DECLARES.DECLARES_CLIENT) require a type Symbol origin"`.
125+
126+
### Direction constraint
127+
128+
v1 supports **outbound only** (`direction="out"`). The `edge_summary`
129+
dot-keys only carry `out` counts today, and the inbound reverse path
130+
(e.g. "which class declared this client?") is already navigable via
131+
`neighbors(client_id, 'in', ['DECLARES_CLIENT'])` → method, then
132+
`neighbors(method_id, 'in', ['DECLARES'])` → class.
133+
134+
`direction="in"` with a dot-key returns `success=False` with a clear error.
135+
136+
### EdgeType validation
137+
138+
The current `EdgeType` Literal covers stored graph labels. Dot-keys are
139+
composed navigation paths, not graph labels.
140+
141+
Add a `ComposedEdgeType` Literal for the three dot-keys and accept
142+
`EdgeType | ComposedEdgeType` in the `neighbors` edge_types parameter.
143+
`_NEIGHBOR_EDGE_TYPES_ADAPTER` validation is updated accordingly. Flat and
144+
composed types may be mixed in one call (they resolve independently).
145+
146+
### Cypher implementation
147+
148+
The 2-hop queries mirror what `member_edge_rollup_for` already executes in
149+
`kuzu_queries.py`, but `RETURN` the target node columns instead of
150+
`count(e)`.
151+
152+
The projected columns on the terminal edge `e` must match the same attr set
153+
that flat `neighbors_v2` projects: `confidence`, `strategy`, `match`,
154+
`mechanism`, `annotation`, `field_or_param`, `source`, `call_site_line`,
155+
`call_site_byte`, `arg_count`, `resolved`. Most will be `NULL` for a given
156+
edge type (e.g. `DECLARES_CLIENT` only carries `confidence` and `strategy`),
157+
but projecting the full set keeps the `attrs` contract uniform.
158+
159+
```cypher
160+
MATCH (t:Symbol {id: $id})-[:DECLARES]->(m:Symbol)-[e:DECLARES_CLIENT]->(c:Client)
161+
RETURN m.id AS via_id, label(e) AS edge_type,
162+
c.id AS other_id, e.confidence AS confidence, e.strategy AS strategy,
163+
e.match AS match, e.mechanism AS mechanism, e.annotation AS annotation,
164+
e.field_or_param AS field_or_param, e.source AS source,
165+
e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte,
166+
e.arg_count AS arg_count, e.resolved AS resolved
167+
```
168+
169+
Single query per origin per dot-key — no N+1 fan-out.
170+
171+
### Counting semantics alignment
172+
173+
Unfiltered `len(neighbors(..., ['DECLARES.DECLARES_CLIENT']))` must equal
174+
`edge_summary["DECLARES.DECLARES_CLIENT"]["out"]` for the same origin
175+
(assuming no `limit`/`offset` truncation). Both count **edge rows**, not
176+
distinct methods — one method with multiple `Client` rows contributes its
177+
full edge count.
178+
179+
Duplicate `other.id` values with different `via_id` values are expected
180+
(rare but possible if two methods declare clients pointing to the same
181+
`Client` node).
182+
183+
`limit`/`offset` apply to the combined result set, so an empty page with
184+
non-zero `edge_summary` count is possible.
185+
186+
## Scope
187+
188+
- Extend `neighbors` to accept `DECLARES.DECLARES_CLIENT`,
189+
`DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` as edge_types
190+
- Add `ComposedEdgeType` to `mcp_v2.py`; update validation adapter
191+
- Add 2-hop Cypher dispatch in the `neighbors` handler
192+
- Add origin kind validation (type Symbol required for dot-keys)
193+
- Populate `via_id` in `Edge.attrs` for composed results
194+
- Update `edge_summary` description on `NodeRecord` to remove the
195+
"do not pass them to `neighbors(edge_types=…)`" prohibition for
196+
`DECLARES.*` keys (keep it for `OVERRIDDEN_BY.*` keys)
197+
- Update `docs/EDGE-NAVIGATION.md` typical-traversals to mention
198+
the single-call dot-key alternative
199+
- Update `docs/AGENT-GUIDE.md` — rewrite the "composed keys are read-only"
200+
paragraphs; `DECLARES.*` keys become navigable, `OVERRIDDEN_BY.*` stays
201+
describe-only
202+
- Update `mcp_hints.py``TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS` and
203+
`TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS` must prescribe the dot-key
204+
single-call recipe instead of the old multi-hop walk
205+
- Update `server.py``describe` and `neighbors` tool `description=`
206+
strings (MCP contract surface)
207+
- Update README tool table `neighbors` description
208+
- Split `test_neighbors_rejects_overridden_by_and_dot_keys`: accept
209+
`DECLARES.*`, still reject `OVERRIDDEN_BY.*`
210+
211+
## Schema / Ontology / Re-index impact
212+
213+
- Ontology bump: **not required** (no graph schema or enrichment change)
214+
- Re-index required: **no** (reads existing graph data via new query paths)
215+
- Config/tool surface changes: `neighbors` accepts 3 new `edge_types`
216+
values; `Edge` results for those types include `via_id` in `attrs`
217+
218+
## Tests / Validation
219+
220+
- **Unit**: `test_neighbors_declares_dot_key_client` — from a type Symbol
221+
with known `DECLARES.DECLARES_CLIENT` count, verify correct terminal
222+
Client nodes returned with `via_id` attrs
223+
- **Unit**: `test_neighbors_declares_dot_key_producer` — same for
224+
`DECLARES.DECLARES_PRODUCER`
225+
- **Unit**: `test_neighbors_declares_dot_key_exposes` — same for
226+
`DECLARES.EXPOSES`
227+
- **Unit**: `test_neighbors_dot_key_mixed_with_flat` — mixed
228+
`["DECLARES", "DECLARES.DECLARES_CLIENT"]` call returns both member
229+
Symbols and terminal Clients
230+
- **Unit**: `test_neighbors_dot_key_inbound_rejected``direction="in"`
231+
with a dot-key returns a clear error message
232+
- **Unit**: `test_neighbors_dot_key_method_origin_rejected` — method id
233+
with a dot-key returns a validation error
234+
- **Unit**: `test_neighbors_dot_key_count_matches_edge_summary` — verify
235+
unfiltered result count equals `edge_summary` out count for same origin
236+
- **Unit**: `test_neighbors_still_rejects_overridden_by``OVERRIDDEN_BY`
237+
and `OVERRIDDEN_BY.*` dot-keys remain rejected
238+
- **Hint**: verify updated hint templates prescribe dot-key recipe
239+
- **Regression**: existing `neighbors` calls with flat edge_types unchanged
240+
241+
## Decisions (locked)
242+
243+
1. `edge_type` in the result echoes the **dot-key**
244+
(`DECLARES.DECLARES_CLIENT`), not the bare terminal label. Avoids
245+
implying a direct edge from the origin that does not exist.
246+
247+
2. `via_id` lives in **`attrs`**, not as a top-level `Edge` field. Keeps
248+
the `Edge` model stable; `via_id` is only meaningful for composed
249+
traversals.
250+
251+
3. `origin_id` is the **starting node** (class) — what the agent passed
252+
as input. `via_id` in attrs provides the intermediate link.
253+
254+
4. `NodeFilter` **applies to the terminal node** (same semantics as flat
255+
edge_types). E.g. `filter={"microservice":"chat-core"}` with
256+
`DECLARES.DECLARES_CLIENT` filters the returned Clients.
257+
258+
## Limitations
259+
260+
This proposal solves class-level bulk enumeration: from a type Symbol,
261+
one `neighbors` call retrieves all terminal Client/Producer/Route nodes,
262+
with `via_id` linking back to the declaring method.
263+
264+
It does **not** add per-method edge-presence signals on method `NodeRef`s
265+
returned by `neighbors(..., ['DECLARES'])` or `find(kind="symbol")`. An
266+
agent that receives a list of 15 method `NodeRef`s still cannot tell which
267+
ones are interesting without either (a) calling `describe` on the parent
268+
class first, or (b) describing individual methods.
269+
270+
If method-list filtering (without a prior `describe`) proves to be a pain
271+
point, a future proposal could add lightweight signals (e.g. `capabilities`)
272+
to `NodeRef` for symbol-kind nodes. Tracked as follow-up:
273+
[#167](https://github.com/HumanBean17/java-codebase-rag/issues/167).
274+
275+
## Out of scope
276+
277+
- `OVERRIDDEN_BY.*` dot-keys — these require describe-time signature
278+
matching computation, not a stored graph traversal. Deferred to
279+
follow-up: [#165](https://github.com/HumanBean17/java-codebase-rag/issues/165).
280+
- Per-method `NodeRef` signals (capabilities / edge-presence fields) —
281+
separate concern, tracked as follow-up (see [Limitations](#limitations))
282+
- Changes to `SearchHit` model
283+
- Inbound (`direction="in"`) dot-key traversals
284+
285+
## Sequencing / Follow-ups
286+
287+
Single implementation PR. Touches `mcp_v2.py` (model + handler),
288+
`mcp_hints.py` (hint templates), `server.py` (tool descriptions),
289+
`docs/AGENT-GUIDE.md`, `docs/EDGE-NAVIGATION.md`, `README.md`, and
290+
test files.
291+
292+
Follow-up issues:
293+
- [#165](https://github.com/HumanBean17/java-codebase-rag/issues/165)
294+
`OVERRIDDEN_BY.*` dot-key support
295+
- [#167](https://github.com/HumanBean17/java-codebase-rag/issues/167)
296+
Per-method `NodeRef` edge-presence signals (if needed)

0 commit comments

Comments
 (0)