Skip to content

Commit 60a0eee

Browse files
HumanBean17claude
andcommitted
address review feedback on trace v2 proposal
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ceb44f4 commit 60a0eee

1 file changed

Lines changed: 72 additions & 16 deletions

File tree

propose/active/TRACE-TOOL-V2-PROPOSE.md

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ TreeNode:
7474
edge_from_parent: EdgeFromParent | None # null for seed nodes
7575
children: list[TreeNode] # nested children (empty list for leaves)
7676
collapsed: bool = False # true if this node was produced by collapsing
77-
collapsed_intermediates: list[str] # node IDs of collapsed intermediates
77+
collapsed_intermediates: list[str] # node IDs of collapsed intermediates (retained in nodes dict)
7878

7979
EdgeFromParent:
80+
direction: Literal["in", "out"] # traversal direction that produced this edge
8081
edge_type: str
8182
hop: int
8283
cross_service_boundary: bool = False
@@ -95,6 +96,8 @@ RankedLeaf:
9596
- The `tree` format is what agents actually consume. The flat format was a v1 compromise to avoid token-cost concerns on large traces. Re-scoping as a replacement eliminates the redundancy.
9697
- `ranked_leaves` is a lightweight summary (node_id + score) that preserves the "which paths matter most" signal without duplicating the full edge lists.
9798

99+
**Collapsed intermediates accessibility.** Unlike v1 (which removed collapsed nodes from the `nodes` dict), v2 **retains** collapsed intermediate nodes in `nodes` so agents can inspect them via `describe` or `neighbors`. The `collapsed_intermediates` list on the `TreeNode` references valid keys in `nodes`. The intermediate is simply removed from the tree structure (its children are not nested under it) but remains accessible as a standalone entry.
100+
98101
**Token budget.** A nested tree with `TreeNode` objects reuses node IDs from the shared `nodes` dict -- node payloads are not duplicated at each nesting level. Empirical: a depth-3 trace on the bank-chat fixture produces ~30 TreeNodes vs ~24 TraceEdges (the tree format is slightly larger due to nesting structure but eliminates the `paths` list).
99102

100103
### Enhancement 2: Configurable `collapse_trivial` heuristic
@@ -118,13 +121,12 @@ The degree-1 rule (exactly 1 inbound + 1 outbound CALLS in the result set) remai
118121

119122
### Enhancement 3: Source-relative fan-out ranking
120123

121-
Replace the static `_ROLE_PRIORITY` dict with a **source-relative** priority that shifts based on the source node's role:
124+
Replace the static `_ROLE_PRIORITY` dict with a **source-relative** priority that shifts based on the source node's role. The priority table is built from `VALID_ROLES` (via `java_ontology.py`) to avoid duplicating role string literals across modules:
122125

123126
```python
124-
# From SERVICE: REPOSITORY is most relevant downstream
125-
# From CONTROLLER: SERVICE is most relevant downstream
126-
# From REPOSITORY: no strong signal, fall back to static order
127-
127+
# Coupled to VALID_ROLES in java_ontology.py — when roles are added/removed there,
128+
# this table must be updated. The keys are source-node roles; the inner dicts map
129+
# target-node roles to priority values (higher = more relevant from that source).
128130
_SOURCE_RELATIVE_PRIORITY: dict[str, dict[str, int]] = {
129131
"CONTROLLER": {"SERVICE": 5, "REPOSITORY": 4, "CLIENT": 3, "OTHER": 2, "CONTROLLER": 1},
130132
"SERVICE": {"REPOSITORY": 5, "CLIENT": 4, "SERVICE": 3, "OTHER": 2, "CONTROLLER": 1},
@@ -133,6 +135,8 @@ _SOURCE_RELATIVE_PRIORITY: dict[str, dict[str, int]] = {
133135
}
134136
```
135137

138+
All role strings in this table must be members of `VALID_ROLES`. A startup assertion validates this so the table cannot silently drift from the ontology.
139+
136140
When the source node's role is not in `_SOURCE_RELATIVE_PRIORITY`, the existing static `_ROLE_PRIORITY` is used as fallback. This is a zero-config change -- the behavior improves automatically without new parameters.
137141

138142
**Post-pruning budget** (`min_result_nodes`): When aggressive pruning (`prune_roles`, `fan_out_cap`) would reduce the result below `min_result_nodes`, the engine relaxes the fan-out cap incrementally until the target is met. This prevents the common failure mode where a trace returns 2 nodes because 48 were pruned.
@@ -166,7 +170,7 @@ if direction == "both":
166170

167171
**Merge semantics:**
168172
- The `nodes` dict is a union of both traversals (deduplicated by node ID).
169-
- The `tree` has the seed at the root with two subtrees: `in_children` and `out_children`. In practice, the tree format already supports this -- the seed node has children from both directions.
173+
- The `tree` has the seed at the root with a single `children` list containing nodes from both directions. Directionality is preserved via `edge_from_parent.direction` on each child -- agents can distinguish "who calls me" (`direction="in"`) from "what do I call" (`direction="out"`).
170174
- `stats` aggregates both traversals (nodes discovered = in + out, etc.).
171175
- `ranked_leaves` merges and re-ranks leaves from both directions.
172176
- If the same node is discovered in both directions, it appears once in `nodes` and once in the tree (in whichever direction it was first discovered; the other direction records the edge but does not duplicate the node).
@@ -181,7 +185,8 @@ if direction == "both":
181185

182186
1. **`mcp_trace.py`**: Replace `TraceEdge`, `TracePath` with `TreeNode`, `EdgeFromParent`, `RankedLeaf`. Refactor `_collapse_trivial_chains` to use configurable heuristic. Refactor `_fan_out_sort_key` to use source-relative priority. Add bidirectional BFS merge logic. Add `min_result_nodes` retry logic.
183187
2. **`server.py`**: Update `trace` tool registration to reflect new parameters (`collapse_roles`, `collapse_min_chain_length`, `min_result_nodes`, `direction="both"`).
184-
3. **Breaking API change**: `TraceOutput.edges` and `TraceOutput.paths` are removed. `TraceOutput.tree` and `TraceOutput.ranked_leaves` are added.
188+
3. **`mcp_hints.py`**: Update `_trace_structured_hints` to consume `tree` and `ranked_leaves` instead of `edges` and `paths`. The hint at line 526 reads `payload.get("edges")` -- this must be rewritten to walk the tree structure. Cross-service boundary detection (line 571) must traverse tree nodes checking `edge_from_parent.cross_service_boundary` instead of iterating a flat `edges` list. Pruned/collapsed drill-down hint (line 557) must check `TreeNode.collapsed` on tree nodes instead of `e.get("collapsed")` on edges. The `_high_fanout_trace_hint` function (used by `neighbors` and `describe` paths) is unaffected -- it does not read trace output fields.
189+
4. **Breaking API change**: `TraceOutput.edges` and `TraceOutput.paths` are removed. `TraceOutput.tree` and `TraceOutput.ranked_leaves` are added.
185190
4. **No graph schema changes**: No new node kinds, edge types, or edge attributes.
186191
5. **No re-index required**: The tool reads the existing graph.
187192
6. **No ontology bump**: No changes to `java_ontology.py`.
@@ -192,6 +197,7 @@ if direction == "both":
192197
- `search`, `find`, `describe`, `resolve` are unchanged.
193198
- `kuzu_queries.py` is not modified.
194199
- `mcp_v2.py` types (`NodeFilter`, `EdgeFilter`, `NodeRef`) are unchanged.
200+
- `mcp_hints.py` hint functions for non-trace tools (`_high_fanout_trace_hint` used by `neighbors`/`describe`) are unchanged -- only `_trace_structured_hints` is updated.
195201
- Indexer, graph builder, CLI are unchanged.
196202

197203
## Schema / Ontology / Re-index impact
@@ -204,14 +210,63 @@ if direction == "both":
204210

205211
### Updated unit tests
206212

207-
Tests from v1 that reference `edges` and `paths` fields are updated to reference `tree` and `ranked_leaves`. New tests:
213+
All 40 existing v1 tests in `tests/test_mcp_trace.py` must be updated. Every test that asserts on `result.edges` or `result.paths` changes to assert on `result.tree` and `result.ranked_leaves`. The table below lists each existing test and the required change:
214+
215+
| Existing v1 test | Required change |
216+
|---|---|
217+
| `test_trace_outbound_calls_depth_2` | `result.edges[hop=0/1]` → walk `result.tree` children, assert depth-2 nesting |
218+
| `test_trace_inbound_callers_depth_2` | Same pattern for inbound tree |
219+
| `test_trace_max_paths_cap` | `len(result.paths)` → `len(result.ranked_leaves)` |
220+
| `test_trace_budget_stops_early` | `result.edges` → `result.tree` walk; stats assertions unchanged |
221+
| `test_trace_depth_1_equivalent_to_neighbors` | `result.edges` → single-level `result.tree` children |
222+
| `test_trace_stats_counts` | Unchanged (stats fields are the same) |
223+
| `test_trace_empty_seed` | `result.edges == []` → `result.tree == []`; `result.paths == []` → `result.ranked_leaves == []` |
224+
| `test_trace_single_string_seed` | `result.seed_ids` assertion unchanged |
225+
| `test_trace_multiple_seeds` | `result.edges` → `len(result.tree) >= N` seeds |
226+
| `test_trace_invalid_edge_type` | `result.success == False` unchanged |
227+
| `test_trace_direction_required` | `result.success == False` unchanged |
228+
| `test_trace_edge_types_required` | `result.success == False` unchanged |
229+
| `test_trace_max_depth_clamped` | Unchanged (tests parameter clamping, not output format) |
230+
| `test_trace_budget_clamped` | Unchanged (tests parameter clamping, not output format) |
231+
| `test_trace_visited_set_no_cycles` | `result.edges` → tree walk; assert no duplicate node IDs |
232+
| `test_trace_filter_applied` | `result.edges` → tree walk; assert excluded nodes absent |
233+
| `test_trace_filter_vs_prune_roles` | `result.edges` → tree walk; assert pruned-role nodes appear as leaves (no children) |
234+
| `test_trace_edge_filter_calls` | `result.edges` → tree walk; assert filtered edges absent |
235+
| `test_trace_include_unresolved` | `result.edges` → tree walk; assert unresolved nodes present |
236+
| `test_trace_paths_root_to_leaf` | `result.paths` → `result.ranked_leaves`; assert each leaf has a tree path from seed |
237+
| `test_trace_overrides_interface_resolution` | `result.edges` → tree walk; assert OVERRIDES edges present |
238+
| `test_trace_parent_edge_id_seed_null` | **Removed.** `parent_edge_id` no longer exists. Replaced by `test_trace_tree_seed_no_edge_from_parent` (new) |
239+
| `test_trace_parent_edge_id_chain` | **Removed.** `parent_edge_id` no longer exists. Replaced by `test_trace_tree_edge_from_parent_chain` (new) |
240+
| `test_trace_prune_roles` | `result.edges` → tree walk; assert pruned nodes are leaves |
241+
| `test_trace_fan_out_cap` | `result.edges` → tree walk; assert `len(children) <= cap` |
242+
| `test_trace_fan_out_cap_scaffolding_exempt` | `result.edges` → tree walk; assert scaffolding children present despite cap |
243+
| `test_trace_collapse_trivial` | `result.edges[i].collapsed` → `tree_node.collapsed`; assert intermediates in `nodes` dict |
244+
| `test_trace_collapse_trivial_disabled` | `result.edges` → tree walk; assert no `collapsed=True` nodes |
245+
| `test_trace_collapse_parent_edge_id_consistency` | **Removed.** `parent_edge_id` no longer exists. Replaced by `test_trace_tree_collapse_children_reparented` (new) |
246+
| `test_trace_cross_service_http` | `result.edges` → tree walk; assert `edge_from_parent.cross_service_boundary=True` |
247+
| `test_trace_cross_service_async` | Same pattern for async |
248+
| `test_trace_cross_service_edge_attrs` | `edges[i].attrs` → `tree_node.edge_from_parent.attrs` |
249+
| `test_trace_cross_service_boundary_stops` | `result.edges` → tree walk; assert boundary node has no children |
250+
| `test_trace_cross_service_seamless_http` | `result.edges` → tree walk; assert children exist past boundary |
251+
| `test_trace_cross_service_seamless_async` | Same pattern for async |
252+
| `test_trace_cross_service_seamless_respects_budget` | Stats unchanged; tree walk instead of edges |
253+
| `test_trace_cross_service_seamless_exposes_as_scaffolding` | `result.edges` → tree walk; assert EXPOSES followed as scaffolding |
254+
| `test_trace_registered_as_mcp_tool` | Unchanged (tests registration, not output) |
255+
| `test_trace_tool_description_mentions_six_tools` | Unchanged (tests description string) |
256+
| `test_trace_bank_chat_cross_service_http_flow` | `result.edges` → tree walk; full flow assertion on nested structure |
257+
258+
New tests (v2 features):
208259

209260
| Test name | Asserts |
210261
|-----------|---------|
211262
| `test_trace_tree_root_is_seed` | Tree root node matches seed ID |
263+
| `test_trace_tree_seed_no_edge_from_parent` | Seed nodes have `edge_from_parent=None` (replaces `test_trace_parent_edge_id_seed_null`) |
264+
| `test_trace_tree_edge_from_parent_chain` | Non-root nodes have `edge_from_parent` with valid edge_type, hop, and direction (replaces `test_trace_parent_edge_id_chain`) |
265+
| `test_trace_tree_edge_from_parent_direction` | `edge_from_parent.direction` is set ("in" or "out") for all non-root nodes |
212266
| `test_trace_tree_children_nested` | Children are nested TreeNodes, not flat |
213-
| `test_trace_tree_edge_from_parent` | Non-root nodes have `edge_from_parent` with correct edge_type and hop |
214267
| `test_trace_tree_collapsed_node` | Collapsed intermediates carry `collapsed=True` and `collapsed_intermediates` |
268+
| `test_trace_tree_collapse_intermediates_in_nodes` | Collapsed intermediate node IDs exist in `nodes` dict (accessible for describe/neighbors) |
269+
| `test_trace_tree_collapse_children_reparented` | After collapsing A→B→C, C appears as child of A in tree (replaces `test_trace_collapse_parent_edge_id_consistency`) |
215270
| `test_trace_ranked_leaves_capped` | `ranked_leaves` does not exceed `max_paths` |
216271
| `test_trace_ranked_leaves_scores` | Leaves are sorted by descending score |
217272
| `test_trace_collapse_roles_custom` | `collapse_roles=["OTHER","SERVICE"]` collapses SERVICE intermediates |
@@ -233,15 +288,15 @@ Tests from v1 that reference `edges` and `paths` fields are updated to reference
233288
- `ruff check` clean on all changed files.
234289
- Existing tool tests (`test_mcp_v2.py`, `test_server.py`) must pass unchanged.
235290

236-
## Open Questions ([TBD])
291+
## Resolved Decisions
237292

238-
1. **`tree` token cost on deep traces** -- The nested format adds structural overhead compared to the flat edge list. On a depth-5 trace with 50+ nodes, the tree JSON may be 15-20% larger. Should we add an optional `flat: bool = False` parameter that falls back to the old `edges` format for agents that prefer it? -- Recommended: No. The tree format is strictly better for LLM consumption. If token cost is a concern, reduce `max_depth` or `max_paths`.
293+
1. **`tree` token cost on deep traces** -- No fallback to flat format. The tree format is strictly better for LLM consumption. If token cost is a concern, reduce `max_depth` or `max_paths`.
239294

240-
2. **`min_result_nodes` retry budget** -- Currently one retry with doubled `fan_out_cap`. Should this be a configurable number of retries? -- Recommended: No. One retry is sufficient. If the doubled cap still produces too few nodes, the graph genuinely has few reachable nodes and more retries won't help.
295+
2. **`min_result_nodes` retry budget** -- One retry with doubled `fan_out_cap`. No configurable retry count. If the doubled cap still produces too few nodes, the graph genuinely has few reachable nodes and more retries won't help.
241296

242-
3. **Bidirectional `edge_types` per direction** -- Should the `direction="both"` mode allow different `edge_types` for in vs out? (e.g., `edge_types_in=["CALLS", "OVERRIDES"], edge_types_out=["CALLS", "HTTP_CALLS"]`). -- Recommended: No for v2. Use the same `edge_types` for both directions. If agents need different edge types per direction, they issue two unidirectional calls (same as today).
297+
3. **Bidirectional `edge_types` per direction** -- Same `edge_types` for both directions. If agents need different edge types per direction, they issue two unidirectional calls (same as today).
243298

244-
4. **`collapse_roles` interaction with `prune_roles`** -- `collapse_roles` determines which nodes are trivial (collapsible). `prune_roles` determines which nodes stop the frontier. Should a role in `collapse_roles` also be eligible for `prune_roles`? -- Recommended: Yes, but independently configured. A role can be in one, both, or neither list. Default: `collapse_roles=["OTHER"]`, `prune_roles` unset.
299+
4. **`collapse_roles` interaction with `prune_roles`** -- Independently configured. A role can be in one, both, or neither list. Default: `collapse_roles=["OTHER"]`, `prune_roles` unset.
245300

246301
## Out of scope
247302

@@ -266,7 +321,8 @@ experimental ← TRACE-V2 (single PR)
266321
4. Implement `direction="both"` bidirectional BFS merge.
267322
5. Update all v1 tests to new output format; add new tests listed above.
268323
6. Update `server.py` tool registration and description.
269-
7. Update `docs/AGENT-GUIDE.md` and `skills/explore-codebase/SKILL.md` for tree output.
324+
7. Update `mcp_hints.py` `_trace_structured_hints` to consume `tree` and `ranked_leaves` instead of `edges` and `paths`.
325+
8. Update `docs/AGENT-GUIDE.md` and `skills/explore-codebase/SKILL.md` for tree output.
270326
271327
**Post-merge follow-ups:**
272328
- Production telemetry on `direction="both"` usage to validate the single edge_types-per-direction decision.

0 commit comments

Comments
 (0)