|
| 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