Skip to content

Commit 343ccb4

Browse files
feat(neighbors): navigate DECLARES.* composed edge types in one call (#171)
* feat(neighbors): navigate DECLARES.* composed edge types in one call Enable 2-hop DECLARES.DECLARES_CLIENT, DECLARES.DECLARES_PRODUCER, and DECLARES.EXPOSES traversal for type Symbol origins (out only), with via_id in attrs and count parity against describe edge_summary. Update describe hint templates and docs; keep OVERRIDDEN_BY* rejected and empty neighbors hints dot-key-free. Co-authored-by: Cursor <cursoragent@cursor.com> * fix review: dedupe EDGE-NAVIGATION composed traversals Use _COMPOSED_MEMBER_TYPE_TRAVERSAL alone in the doc generator (it already includes the two-hop alternative). Document mixed flat+composed pagination ordering; drop duplicate method-origin test covered in compose. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: land neighbors dot-key propose/plan; track #172 Move NEIGHBORS-DOT-KEY-TRAVERSAL propose and plan to completed/ with landed status (PR #171). Follow-up #172 tracks unifying composed dot-key constants across kuzu_queries, mcp_v2, and java_ontology. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bf8b550 commit 343ccb4

14 files changed

Lines changed: 342 additions & 141 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/
244244
| `find` | Locate nodes by structured filter. | `kind: "symbol"\|"route"\|"client"`, `filter: NodeFilter \| str`, `limit: int=25`, `offset: int=0` | `{"kind":"symbol","filter":{"role":"CONTROLLER"}}` |
245245
| `describe` | Full record + edge counts for one node. For **type** symbols, `edge_summary` may include composed dot-keys (`DECLARES.DECLARES_CLIENT`, `DECLARES.EXPOSES`); for **method** symbols it may include override-axis virtual keys (`OVERRIDDEN_BY`, …) and an `OVERRIDES` row that **merges** stored `[:OVERRIDES]` in/out with the dispatch-up rollup (per direction `max`). See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`describe`). | `id: str` | `{"id":"sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest)"}` |
246246
| `resolve` | Identifier-shaped node lookup (symbol / route / client). Returns `status` `one`, `many`, or `none`; prefer over `describe(fqn=…)` when an FQN may collide. See [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md) (`resolve`). | `identifier: str`, `hint_kind: "symbol"|"route"|"client" \| null` | `{"identifier":"com.bank.chat.core.api.ChatController","hint_kind":"symbol"}` |
247-
| `neighbors` | One-hop walk. **Required**: `direction` and `edge_types`. | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None` | `{"ids":"route:chat-core:POST:/chat/joinOperator","direction":"in","edge_types":["HTTP_CALLS","ASYNC_CALLS"]}` |
247+
| `neighbors` | Graph walk. **Required**: `direction` and `edge_types` (stored labels; type Symbols may also pass composed `DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES` — `out` only — see [`docs/AGENT-GUIDE.md`](./docs/AGENT-GUIDE.md)). | `ids: str \| list[str]`, `direction: "in"\|"out"`, `edge_types: list[str]`, `limit: int=25`, `offset: int=0`, `filter: NodeFilter \| str \| None` | `{"ids":"sym:…ChatController","direction":"out","edge_types":["DECLARES.DECLARES_CLIENT"]}` |
248248

249249
**`NodeFilter` notes:**
250250

docs/AGENT-GUIDE.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,16 +225,16 @@ Exact allowed values for roles, capabilities, client kinds, etc. live in `java_o
225225

226226
#### `describe`
227227

228-
- **Purpose:** Full node payload + `edge_summary`: `in` / `out` counts **per stored graph edge label** (what exists as edges in Kuzu). For **type** Symbols only (`class`, `interface`, `enum`, `record`, `annotation`), the same map may also include **describe-time composed** dot-keys — summaries of member edges, not stored labels — see the next bullets (`DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES`); those keys are **not** valid in `neighbors(edge_types=…)`. For **method** Symbols, the map may include **override-axis virtual keys** (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`) plus an **`OVERRIDES` row** that merges stored `[:OVERRIDES]` incident counts with the describe-time dispatch-up rollup (per direction `max`, so inbound stored overrides are preserved); see **Override-axis keys (method Symbols)** below — those virtual keys are **not** `neighbors` arguments. The **stored** relationship label **`OVERRIDES`** **is** a valid `EdgeType` for `neighbors` (same spelling as the map key; the map row is the merged view).
228+
- **Purpose:** Full node payload + `edge_summary`: `in` / `out` counts **per stored graph edge label** (what exists as edges in Kuzu). For **type** Symbols only (`class`, `interface`, `enum`, `record`, `annotation`), the same map may also include **describe-time composed** dot-keys — summaries of member edges, not stored labels — see the next bullets (`DECLARES.DECLARES_CLIENT`, `DECLARES.DECLARES_PRODUCER`, `DECLARES.EXPOSES`); those three keys **are** valid in `neighbors(edge_types=…)` for type Symbol origins (`direction="out"` only). For **method** Symbols, the map may include **override-axis virtual keys** (`OVERRIDDEN_BY`, `OVERRIDDEN_BY.DECLARES_CLIENT`, `OVERRIDDEN_BY.DECLARES_PRODUCER`, `OVERRIDDEN_BY.EXPOSES`) plus an **`OVERRIDES` row** that merges stored `[:OVERRIDES]` incident counts with the describe-time dispatch-up rollup (per direction `max`, so inbound stored overrides are preserved); see **Override-axis keys (method Symbols)** below — those virtual keys are **not** `neighbors` arguments. The **stored** relationship label **`OVERRIDES`** **is** a valid `EdgeType` for `neighbors` (same spelling as the map key; the map row is the merged view).
229229
- **Args:** `id` (symbol, route, or client id) or **`fqn`** (exact symbol FQN when you do not have the graph id). When both are set, `id` wins. For identifier-shaped inputs and FQN collision handling, see **Identifier resolution** above.
230230

231231
**Composed `edge_summary` keys (type Symbols).** Keys use dot notation: `<parent_relation>.<projected_relation>`. Three are emitted today:
232232

233-
- `DECLARES.DECLARES_CLIENT` — the type's methods declare brownfield HTTP clients (count is the number of `Client` rows reached through `DECLARES → DECLARES_CLIENT`). To enumerate them: `neighbors(ids=<class_id>, direction="out", edge_types=["DECLARES"])`for each method id, `neighbors(ids=<method_id>, direction="out", edge_types=["DECLARES_CLIENT"])`.
234-
- `DECLARES.DECLARES_PRODUCER`the type's methods declare async producers. Same walk shape with `DECLARES_PRODUCER`.
235-
- `DECLARES.EXPOSES`the type's methods expose routes. Same walk shape with `EXPOSES`.
233+
- `DECLARES.DECLARES_CLIENT` — the type's methods declare brownfield HTTP clients (count is the number of `Client` rows reached through `DECLARES → DECLARES_CLIENT`). Primary recipe: `neighbors(ids=<class_id>, direction="out", edge_types=["DECLARES.DECLARES_CLIENT"])`terminal `Client` nodes with `via_id` in `attrs`. Alternative (two hops): `neighbors(..., ["DECLARES"])` then per-method `DECLARES_CLIENT`.
234+
- `DECLARES.DECLARES_PRODUCER`producers via members. Use `edge_types=["DECLARES.DECLARES_PRODUCER"]` (or the two-hop `DECLARES``DECLARES_PRODUCER` walk).
235+
- `DECLARES.EXPOSES`routes via members. Use `edge_types=["DECLARES.EXPOSES"]` (or the two-hop `DECLARES``EXPOSES` walk).
236236

237-
Composed keys are **read-only**: they cannot be passed to `neighbors(edge_types=…)` (the dot is not a valid `EdgeType` literal — the call fails with a Pydantic `ValidationError`). Use them as a hop affordance only.
237+
These three `DECLARES.*` dot-keys are navigable on **type** Symbol origins (`out` only). `OVERRIDDEN_BY*` virtual keys remain describe-only.
238238

239239
Note on counting semantics: composed counts measure **edge rows**, not distinct member methods. One method that declares multiple `Client` rows (e.g. a `rest_template` method with several call sites) contributes its full edge count to `DECLARES.DECLARES_CLIENT`. The "does this class have any clients?" predicate is answered by `count > 0`; the count itself is an affordance for how rich the downstream walk will be.
240240

@@ -248,7 +248,7 @@ Walk recipe (manual, if you need types in the middle): `neighbors(ids=<method_id
248248

249249
Static methods suppress the entire override-axis rollup. Constructors do not receive these keys.
250250

251-
Virtual keys (`OVERRIDDEN_BY`, …) and composed dot-keys are **not** valid `EdgeType` literals`neighbors(edge_types=["OVERRIDDEN_BY"])` fails at the Pydantic boundary. Use them as hop affordances only. **`OVERRIDES`** in `edge_types=[...]` selects the **stored** override relationship (same spelling as the `OVERRIDES` map key, whose `in`/`out` merge Kuzu edges with the dispatch-up rollup).
251+
Virtual keys (`OVERRIDDEN_BY`, …) are **not** valid `neighbors` arguments`neighbors(edge_types=["OVERRIDDEN_BY"])` fails at the Pydantic boundary. **`OVERRIDES`** in `edge_types=[...]` selects the **stored** override relationship (same spelling as the `OVERRIDES` map key, whose `in`/`out` merge Kuzu edges with the dispatch-up rollup).
252252

253253
#### `resolve`
254254

@@ -261,6 +261,7 @@ Virtual keys (`OVERRIDDEN_BY`, …) and composed dot-keys are **not** valid `Edg
261261
- **Purpose:** One hop over explicit edge types; returns **edges** with attributes (`confidence`, `strategy`, `match`, …) and the **`other`** node.
262262
- **Args:** `ids` (string or array — batch allowed), **`direction`** (`in`|`out`), **`edge_types`** (non-empty list), `limit`, `offset`, optional `filter` on the other node.
263263
- **Batching:** Multiple origins are expanded; pagination slices the **combined** edge list — use larger `limit` when batching many ids.
264+
- **Mixed flat + composed `edge_types`:** flat edges are appended before composed edges, then `limit`/`offset` apply. A small `limit` with e.g. `["DECLARES", "DECLARES.DECLARES_CLIENT"]` may return only member Symbols and no Clients — use the dot-key alone when enumerating terminals.
264265
- **Confidence:** Cross-service edges (`HTTP_CALLS`, `ASYNC_CALLS`) carry confidence, strategy, and match metadata on `edge.attrs` (`attrs.confidence`, `attrs.strategy`, `attrs.match`). Low confidence means the resolver had to guess at the route binding — treat it as a **resolver gap signal**, not a hallucination. Report low-confidence edges with their confidence value, not as facts. Intra-service edges (`CALLS`, `INJECTS`, `IMPLEMENTS`, `EXTENDS`, `DECLARES`, `DECLARES_CLIENT`, `EXPOSES`, `OVERRIDES`) faithfully represent the static graph; the resolved set is still a **lower bound** under reflection / dynamic dispatch (see *What this MCP is NOT*).
265266

266267
### Ontology glossary (version 14)

docs/EDGE-NAVIGATION.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@
160160

161161
**Typical traversals**:
162162

163-
- `type_subject`: neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['EXPOSES'])
163+
- `type_subject`: neighbors(['{id}'],'out',['DECLARES.EXPOSES']) — or neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['EXPOSES'])
164164
- `member_subject`: neighbors(['{id}'],'out',['EXPOSES'])
165165
- `alien_subject`: EXPOSES connects method Symbol → Route; use a method Symbol id
166166

@@ -180,7 +180,7 @@
180180

181181
**Typical traversals**:
182182

183-
- `type_subject`: neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['DECLARES_CLIENT'])
183+
- `type_subject`: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT']) — or neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['DECLARES_CLIENT'])
184184
- `member_subject`: neighbors(['{id}'],'out',['DECLARES_CLIENT'])
185185
- `alien_subject`: DECLARES_CLIENT connects method Symbol → Client
186186

@@ -200,7 +200,7 @@
200200

201201
**Typical traversals**:
202202

203-
- `type_subject`: neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['DECLARES_PRODUCER'])
203+
- `type_subject`: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER']) — or neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['DECLARES_PRODUCER'])
204204
- `member_subject`: neighbors(['{id}'],'out',['DECLARES_PRODUCER'])
205205
- `alien_subject`: DECLARES_PRODUCER connects method Symbol → Producer
206206

java_ontology.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ class EdgeSpec:
155155
"then neighbors(member_ids,'{direction}',['{edge}'])"
156156
)
157157

158+
_COMPOSED_MEMBER_TYPE_TRAVERSAL = (
159+
"neighbors(['{id}'],'out',['DECLARES.{edge}']) — or "
160+
"neighbors(['{id}'],'out',['DECLARES']) then neighbors(member_ids,'{direction}',['{edge}'])"
161+
)
162+
158163
EDGE_SCHEMA: dict[str, EdgeSpec] = {
159164
"EXTENDS": EdgeSpec(
160165
name="EXTENDS",

kuzu_queries.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727

2828
log = logging.getLogger(__name__)
2929

30+
# Composed describe / neighbors dot-keys (not stored graph edge labels).
31+
_MEMBER_EDGE_COMPOSED_REL_MAP: tuple[tuple[str, str], ...] = (
32+
("DECLARES.DECLARES_CLIENT", "DECLARES_CLIENT"),
33+
("DECLARES.DECLARES_PRODUCER", "DECLARES_PRODUCER"),
34+
("DECLARES.EXPOSES", "EXPOSES"),
35+
)
36+
_MEMBER_EDGE_COMPOSED_REL_BY_KEY: dict[str, str] = dict(_MEMBER_EDGE_COMPOSED_REL_MAP)
37+
3038

3139
def _coerce_id_list(raw: Any) -> list[str]:
3240
"""Normalize Kuzu ``collect(DISTINCT ...)`` list results to string ids."""
@@ -629,11 +637,7 @@ def member_edge_rollup_for(self, type_id: str) -> dict[str, dict[str, int]]:
629637
"""
630638
params = {"id": type_id}
631639
rollup: dict[str, dict[str, int]] = {}
632-
for key, rel in (
633-
("DECLARES.DECLARES_CLIENT", "DECLARES_CLIENT"),
634-
("DECLARES.DECLARES_PRODUCER", "DECLARES_PRODUCER"),
635-
("DECLARES.EXPOSES", "EXPOSES"),
636-
):
640+
for key, rel in _MEMBER_EDGE_COMPOSED_REL_MAP:
637641
rows = self._rows(
638642
f"MATCH (t:Symbol {{id: $id}})-[:DECLARES]->(m:Symbol)-[e:{rel}]->() "
639643
"RETURN count(e) AS n",
@@ -644,6 +648,25 @@ def member_edge_rollup_for(self, type_id: str) -> dict[str, dict[str, int]]:
644648
rollup[key] = {"in": 0, "out": n}
645649
return rollup
646650

651+
def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dict[str, Any]]:
652+
"""2-hop DECLARES member traversal for a type Symbol (neighbors dot-key path)."""
653+
rel = _MEMBER_EDGE_COMPOSED_REL_BY_KEY.get(composed_key)
654+
if rel is None:
655+
return []
656+
# Untyped [e] + label(e) filter: typed unions fail the binder when RETURN references
657+
# columns that exist on only some rel types (same pattern as flat neighbors_v2).
658+
return self._rows(
659+
"MATCH (t:Symbol {id: $id})-[:DECLARES]->(m:Symbol)-[e]->(term) "
660+
"WHERE label(e) = $rel "
661+
"RETURN m.id AS via_id, label(e) AS stored_edge_type, "
662+
"term.id AS other_id, e.confidence AS confidence, e.strategy AS strategy, "
663+
"e.match AS match, e.mechanism AS mechanism, e.annotation AS annotation, "
664+
"e.field_or_param AS field_or_param, e.source AS source, "
665+
"e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, "
666+
"e.arg_count AS arg_count, e.resolved AS resolved",
667+
{"id": type_id, "rel": rel},
668+
)
669+
647670
def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> int:
648671
"""Count outgoing ``rel`` edges from method symbols (describe rollup helper)."""
649672
total = 0

mcp_hints.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,22 @@
1717
MCP_HINTS_FIELD_DESCRIPTION = (
1818
"Road-sign hints pointing to likely next calls. Each hint is a short string "
1919
"referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. "
20-
"Maximum 5 hints per output. Hints never recommend dot-key edge labels (composed "
21-
"rollups) as neighbors() arguments. For neighbors with multiple origin ids, "
22-
"empty-result structural hints describe the first origin only."
20+
"Maximum 5 hints per output. Describe-time type rollup hints may recommend "
21+
"DECLARES.* dot-keys for neighbors(); empty neighbors structural hints never use "
22+
"dot-key edge labels. For neighbors with multiple origin ids, empty-result "
23+
"structural hints describe the first origin only."
2324
)
2425

2526
# --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
2627

2728
TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS = (
28-
"clients via members: neighbors(['{id}'],'out',['DECLARES']) "
29-
"then neighbors(member_ids,'out',['DECLARES_CLIENT'])"
29+
"clients via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT'])"
3030
)
3131
TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS = (
32-
"routes via members: neighbors(['{id}'],'out',['DECLARES']) "
33-
"then neighbors(member_ids,'out',['EXPOSES'])"
32+
"routes via members: neighbors(['{id}'],'out',['DECLARES.EXPOSES'])"
3433
)
3534
TPL_DESCRIBE_TYPE_PRODUCERS_VIA_MEMBERS = (
36-
"producers via members: neighbors(['{id}'],'out',['DECLARES']) "
37-
"then neighbors(member_ids,'out',['DECLARES_PRODUCER'])"
35+
"producers via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_PRODUCER'])"
3836
)
3937
TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'in',['OVERRIDES'])"
4038
TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = (

0 commit comments

Comments
 (0)